diff --git a/src/components/table/FillerRows.tsx b/src/components/table/FillerRows.tsx new file mode 100644 index 00000000..5d33127b --- /dev/null +++ b/src/components/table/FillerRows.tsx @@ -0,0 +1,92 @@ +import type { Row } from '@tanstack/react-table' +import type { VirtualItem } from '@tanstack/react-virtual' + +import { type TableFillLevel } from './Table' +import { Td } from './Td' +import { Tr } from './Tr' + +function FillerRow({ + columns, + height, + index, + stickyColumn, + selectable, + fillLevel, + ...props +}: { + columns: unknown[] + height: number + index: number + stickyColumn: boolean + selectable?: boolean + fillLevel: TableFillLevel +}) { + return ( + + + + ) +} + +export function FillerRows({ + rows, + height, + position, + fillLevel, + ...props +}: { + rows: Row[] | VirtualItem[] + columns: unknown[] + height: number + position: 'top' | 'bottom' + stickyColumn: boolean + clickable?: boolean + selectable?: boolean + fillLevel: TableFillLevel +}) { + return ( + <> + + + + ) +} diff --git a/src/components/table/SortIndicator.tsx b/src/components/table/SortIndicator.tsx new file mode 100644 index 00000000..62abc78e --- /dev/null +++ b/src/components/table/SortIndicator.tsx @@ -0,0 +1,28 @@ +import type { SortDirection } from '@tanstack/react-table' + +import ArrowRightIcon from '../icons/ArrowRightIcon' + +export function SortIndicator({ + direction = false, +}: { + direction: false | SortDirection +}) { + switch (direction) { + case 'asc': + return ( + + ) + case 'desc': + return ( + + ) + case false: + return null + } +} diff --git a/src/components/table/T.tsx b/src/components/table/T.tsx new file mode 100644 index 00000000..0219630e --- /dev/null +++ b/src/components/table/T.tsx @@ -0,0 +1,13 @@ +import styled from 'styled-components' + +export const T = styled.table<{ $gridTemplateColumns: string }>( + ({ theme, $gridTemplateColumns }) => ({ + gridTemplateColumns: $gridTemplateColumns, + borderSpacing: 0, + display: 'grid', + borderCollapse: 'collapse', + minWidth: '100%', + width: '100%', + ...theme.partials.text.body2LooseLineHeight, + }) +) diff --git a/src/components/Table.tsx b/src/components/table/Table.tsx similarity index 57% rename from src/components/Table.tsx rename to src/components/table/Table.tsx index ff4dcffa..c6c4c67b 100644 --- a/src/components/Table.tsx +++ b/src/components/table/Table.tsx @@ -1,10 +1,7 @@ import { Div, type DivProps } from 'honorable' import { - type CSSProperties, - type ComponentProps, Fragment, type MouseEvent, - type MutableRefObject, type Ref, forwardRef, useCallback, @@ -17,7 +14,6 @@ import type { ColumnDef, FilterFn, Row, - SortDirection, TableOptions, } from '@tanstack/react-table' import { @@ -31,24 +27,32 @@ import { import { rankItem } from '@tanstack/match-sorter-utils' import type { VirtualItem } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual' -import styled, { useTheme } from 'styled-components' +import { useTheme } from 'styled-components' import { isEmpty, isNil } from 'lodash-es' -import usePrevious from '../hooks/usePrevious' -import { InfoOutlineIcon, Tooltip } from '../index' - -import Button from './Button' -import CaretUpIcon from './icons/CaretUpIcon' -import ArrowRightIcon from './icons/ArrowRightIcon' -import { FillLevelProvider } from './contexts/FillLevelContext' -import EmptyState, { type EmptyStateProps } from './EmptyState' -import { Spinner } from './Spinner' +import { type FillLevel, InfoOutlineIcon, Tooltip } from '../../index' +import Button from '../Button' +import CaretUpIcon from '../icons/CaretUpIcon' +import EmptyState, { type EmptyStateProps } from '../EmptyState' +import { Spinner } from '../Spinner' + +import { tableFillLevelToBg, tableFillLevelToBorderColor } from './colors' +import { FillerRows } from './FillerRows' +import { useIsScrolling, useOnVirtualSliceChange } from './hooks' +import { SortIndicator } from './SortIndicator' +import { T } from './T' +import { Tbody } from './Tbody' +import { Td, TdExpand, TdLoading } from './Td' +import { Th } from './Th' +import { Thead } from './Thead' +import { Tr } from './Tr' export type TableProps = DivProps & { data: any[] columns: any[] hideHeader?: boolean padCells?: boolean + fillLevel?: TableFillLevel rowBg?: 'base' | 'raised' | 'stripes' highlightedRowId?: string getRowCanExpand?: any @@ -68,13 +72,12 @@ export type TableProps = DivProps & { hasNextPage?: boolean fetchNextPage?: () => void isFetchingNextPage?: boolean - onVirtualSliceChange?: (slice: { - start: VirtualItem | undefined - end: VirtualItem | undefined - }) => void + onVirtualSliceChange?: (slice: VirtualSlice) => void } -type VirtualSlice = { +export type TableFillLevel = Exclude + +export type VirtualSlice = { start: VirtualItem | undefined end: VirtualItem | undefined } @@ -95,241 +98,6 @@ function getGridTemplateCols(columnDefs: ColumnDef[] = []): string { .join(' ') } -const T = styled.table<{ $gridTemplateColumns: string }>( - ({ theme, $gridTemplateColumns }) => ({ - gridTemplateColumns: $gridTemplateColumns, - backgroundColor: theme.colors['fill-one'], - borderSpacing: 0, - display: 'grid', - borderCollapse: 'collapse', - minWidth: '100%', - width: '100%', - ...theme.partials.text.body2LooseLineHeight, - }) -) - -const TheadUnstyled = forwardRef< - HTMLTableSectionElement, - ComponentProps<'thead'> ->((props, ref) => ( - - - -)) - -const Thead = styled(TheadUnstyled)(({ theme }) => ({ - display: 'contents', - position: 'sticky', - top: 0, - zIndex: 3, - backgroundColor: theme.colors['fill-two'], -})) - -const TbodyUnstyled = forwardRef< - HTMLTableSectionElement, - ComponentProps<'tbody'> ->((props, ref) => ( - - - -)) - -const Tbody = styled(TbodyUnstyled)(({ theme }) => ({ - display: 'contents', - backgroundColor: theme.colors['fill-one'], -})) - -const Tr = styled.tr<{ - $highlighted?: boolean - $selected?: boolean - $selectable?: boolean - $clickable?: boolean - $raised?: boolean -}>( - ({ - theme, - $clickable: clickable = false, - $raised: raised = false, - $selectable: selectable = false, - $selected: selected = false, - $highlighted: highlighted = false, - }) => ({ - display: 'contents', - backgroundColor: highlighted - ? theme.colors['fill-two'] - : selected - ? theme.colors['fill-zero-hover'] - : raised || (selectable && !selected) - ? theme.colors['fill-zero-selected'] - : theme.colors['fill-zero'], - - ...(clickable && { - cursor: 'pointer', - - // highlight when hovered, but don't highlight if a child button is hovered - '&:not(:has(button:hover)):hover': { - backgroundColor: selectable - ? selected - ? theme.colors['fill-zero-hover'] - : theme.colors['fill-zero-selected'] - : theme.colors['fill-zero-hover'], - }, - }), - }) -) - -const Th = styled.th<{ - $stickyColumn: boolean - $highlight?: boolean - $cursor?: CSSProperties['cursor'] - $hideHeader?: boolean -}>( - ({ - theme, - $stickyColumn: stickyColumn, - $highlight: highlight, - $cursor: cursor, - $hideHeader: hideHeader, - }) => ({ - padding: 0, - position: 'sticky', - top: 0, - zIndex: 4, - '.thOuterWrap': { - alignItems: 'center', - display: hideHeader ? 'none' : 'flex', - position: 'relative', - backgroundColor: highlight - ? theme.colors['fill-two'] - : theme.colors['fill-one'], - zIndex: 4, - borderBottom: theme.borders.default, - color: theme.colors.text, - height: 48, - minHeight: 48, - whiteSpace: 'nowrap', - padding: '0 12px', - textAlign: 'left', - ...(cursor ? { cursor } : {}), - '.thSortIndicatorWrap': { - display: 'flex', - gap: theme.spacing.xsmall, - }, - }, - '&:last-child': { - /* Hackery to hide unpredictable visible gap between columns */ - zIndex: 3, - '&::before': { - content: '""', - position: 'absolute', - top: 0, - right: 0, - bottom: 0, - width: 10000, - backgroundColor: theme.colors['fill-two'], - borderBottom: hideHeader ? 'none' : theme.borders.default, - }, - }, - '&:first-child': { - ...(stickyColumn - ? { - backgroundColor: 'inherit', - position: 'sticky', - left: 0, - zIndex: 5, - '.thOuterWrap': { - boxShadow: theme.boxShadows.slight, - zIndex: 5, - }, - } - : {}), - }, - }) -) - -// TODO: Set vertical align to top for tall cells (~3 lines of text or more). See ENG-683. -const Td = styled.td<{ - $firstRow?: boolean - $loose?: boolean - $padCells?: boolean - $stickyColumn: boolean - $highlight?: boolean - $truncateColumn: boolean - $center?: boolean -}>( - ({ - theme, - $firstRow: firstRow, - $loose: loose, - $padCells: padCells, - $stickyColumn: stickyColumn, - $highlight: highlight, - $truncateColumn: truncateColumn = false, - $center: center, - }) => ({ - ...theme.partials.text.body2LooseLineHeight, - display: 'flex', - flexDirection: 'column', - justifyContent: 'center', - alignItems: center ? 'center' : 'flex-start', - height: 'auto', - minHeight: 52, - - backgroundColor: highlight ? theme.colors['fill-two'] : 'inherit', - borderTop: firstRow ? '' : theme.borders.default, - color: theme.colors['text-light'], - - padding: padCells ? (loose ? '16px 12px' : '8px 12px') : 0, - '&:first-child': stickyColumn - ? { - boxShadow: theme.boxShadows.slight, - position: 'sticky', - left: 0, - zIndex: 1, - } - : {}, - ...(truncateColumn - ? { - '*': { - width: '100%', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - } - : {}), - }) -) - -const TdExpand = styled.td(({ theme }) => ({ - '&:last-child': { - gridColumn: '2 / -1', - }, - backgroundColor: 'inherit', - color: theme.colors['text-light'], - height: 'auto', - minHeight: 52, - padding: '16px 12px', -})) - -const TdLoading = styled(Td)(({ theme }) => ({ - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gridColumn: '1 / -1', - textAlign: 'center', - gap: theme.spacing.xsmall, - color: theme.colors['text-xlight'], - minHeight: theme.spacing.large * 2 + theme.spacing.xlarge, -})) - function isRow(row: Row | VirtualItem): row is Row { return typeof (row as Row).getVisibleCells === 'function' } @@ -354,175 +122,6 @@ const defaultGlobalFilterFn: FilterFn = ( return itemRank.passed } -const sortDirToIcon = { - asc: ( - - ), - desc: ( - - ), -} - -function SortIndicator({ - direction = false, -}: { - direction: false | SortDirection -}) { - if (!direction) return null - - return sortDirToIcon[direction] -} - -function FillerRow({ - columns, - height, - index, - stickyColumn, - selectable, - ...props -}: { - columns: unknown[] - height: number - index: number - stickyColumn: boolean - selectable?: boolean -}) { - return ( - - - - ) -} - -function FillerRows({ - rows, - height, - position, - ...props -}: { - rows: Row[] | VirtualItem[] - columns: unknown[] - height: number - position: 'top' | 'bottom' - stickyColumn: boolean - clickable?: boolean - selectable?: boolean -}) { - return ( - <> - - - - ) -} - -function useIsScrolling( - ref: MutableRefObject, - { - onIsScrollingChange: onScrollingChange, - restDelay = 350, - }: { onIsScrollingChange: (isScrolling: boolean) => void; restDelay?: number } -) { - const [isScrolling, setIsScrolling] = useState(false) - const timeout = useRef(null) - - useEffect(() => { - onScrollingChange?.(isScrolling) - }, [isScrolling, onScrollingChange]) - - useEffect(() => { - if (ref.current) { - const el = ref.current - - const scrollHandler = () => { - setIsScrolling(true) - window.clearTimeout(timeout.current) - timeout.current = window.setTimeout(() => { - setIsScrolling(false) - }, restDelay) - } - - el.addEventListener('scroll', scrollHandler, { passive: true }) - - return () => { - el.removeEventListener('scroll', scrollHandler) - } - } - }, [ref, restDelay]) -} - -function useOnVirtualSliceChange({ - virtualRows, - virtualizeRows, - onVirtualSliceChange, -}: { - virtualRows: VirtualItem[] - virtualizeRows: boolean - onVirtualSliceChange: (slice: VirtualSlice) => void -}) { - const sliceStartRow = virtualRows[0] - const sliceEndRow: VirtualItem = virtualRows[virtualRows.length - 1] - const prevSliceStartRow = usePrevious(virtualRows[0]) - const prevSliceEndRow = usePrevious(virtualRows[virtualRows.length - 1]) - - useEffect(() => { - if ( - virtualizeRows && - (prevSliceEndRow !== sliceEndRow || prevSliceStartRow !== sliceStartRow) - ) { - onVirtualSliceChange?.({ start: sliceStartRow, end: sliceEndRow }) - } - }, [ - sliceStartRow, - sliceEndRow, - virtualizeRows, - onVirtualSliceChange, - prevSliceEndRow, - prevSliceStartRow, - ]) -} - function TableRef( { data, @@ -532,6 +131,7 @@ function TableRef( renderExpanded, loose = false, padCells = true, + fillLevel = 0, rowBg = 'stripes', stickyColumn = false, scrollTopMargin = 500, @@ -693,8 +293,10 @@ function TableRef( ref={forwardRef} >
{headerGroups.map((headerGroup) => ( - + {headerGroup.headers.map((header) => ( )} {rows.map((maybeRow) => { @@ -783,6 +390,7 @@ function TableRef( onRowClick?.(e, row)} + $fillLevel={fillLevel} $raised={raised} $highlighted={row?.id === highlightedRowId} $selectable={row?.getCanSelect() ?? false} @@ -797,6 +405,7 @@ function TableRef( {isNil(row) && isLoaderRow ? ( ( {row?.getIsExpanded() && ( - + {renderExpanded({ row })} @@ -848,6 +461,7 @@ function TableRef( height={paddingBottom} position="bottom" stickyColumn={stickyColumn} + fillLevel={fillLevel} /> )} @@ -855,7 +469,6 @@ function TableRef( {isEmpty(rows) && ( )} diff --git a/src/components/table/Tbody.tsx b/src/components/table/Tbody.tsx new file mode 100644 index 00000000..68080808 --- /dev/null +++ b/src/components/table/Tbody.tsx @@ -0,0 +1,20 @@ +import { type ComponentProps, forwardRef } from 'react' +import styled from 'styled-components' + +import { FillLevelProvider } from '../contexts/FillLevelContext' + +const TbodyUnstyled = forwardRef< + HTMLTableSectionElement, + ComponentProps<'tbody'> +>((props, ref) => ( + + + +)) + +export const Tbody = styled(TbodyUnstyled)(() => ({ + display: 'contents', +})) diff --git a/src/components/table/Td.tsx b/src/components/table/Td.tsx new file mode 100644 index 00000000..928503b4 --- /dev/null +++ b/src/components/table/Td.tsx @@ -0,0 +1,90 @@ +import styled from 'styled-components' + +import { + tableFillLevelToBorder, + tableFillLevelToHighlightedCellBg, +} from './colors' +import { type TableFillLevel } from './Table' + +export const Td = styled.td<{ + $fillLevel: TableFillLevel + $firstRow?: boolean + $loose?: boolean + $padCells?: boolean + $stickyColumn: boolean + $highlight?: boolean + $truncateColumn: boolean + $center?: boolean +}>( + ({ + theme, + $fillLevel: fillLevel, + $firstRow: firstRow, + $loose: loose, + $padCells: padCells, + $stickyColumn: stickyColumn, + $highlight: highlight, + $truncateColumn: truncateColumn = false, + $center: center, + }) => ({ + ...theme.partials.text.body2LooseLineHeight, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + alignItems: center ? 'center' : 'flex-start', + height: 'auto', + minHeight: 52, + + backgroundColor: highlight + ? theme.colors[tableFillLevelToHighlightedCellBg[fillLevel]] + : 'inherit', + borderTop: + firstRow || highlight + ? '' + : theme.borders[tableFillLevelToBorder[fillLevel]], + color: theme.colors['text-light'], + + padding: padCells ? (loose ? '16px 12px' : '8px 12px') : 0, + '&:first-child': stickyColumn + ? { + boxShadow: theme.boxShadows.slight, + position: 'sticky', + left: 0, + zIndex: 1, + } + : {}, + ...(truncateColumn + ? { + '*': { + width: '100%', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + } + : {}), + }) +) + +export const TdExpand = styled.td(({ theme }) => ({ + '&:last-child': { + gridColumn: '2 / -1', + }, + backgroundColor: 'inherit', + color: theme.colors['text-light'], + height: 'auto', + minHeight: 52, + padding: '16px 12px', +})) + +export const TdLoading = styled(Td)(({ theme }) => ({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gridColumn: '1 / -1', + textAlign: 'center', + gap: theme.spacing.xsmall, + color: theme.colors['text-xlight'], + minHeight: theme.spacing.large * 2 + theme.spacing.xlarge, +})) diff --git a/src/components/table/Th.tsx b/src/components/table/Th.tsx new file mode 100644 index 00000000..b9dc1d49 --- /dev/null +++ b/src/components/table/Th.tsx @@ -0,0 +1,78 @@ +import type { CSSProperties } from 'react' +import styled from 'styled-components' + +import { tableFillLevelToBorder, tableHeaderColor } from './colors' +import { type TableFillLevel } from './Table' + +export const Th = styled.th<{ + $fillLevel: TableFillLevel + $stickyColumn: boolean + $highlight?: boolean + $cursor?: CSSProperties['cursor'] + $hideHeader?: boolean +}>( + ({ + theme, + $fillLevel: fillLevel, + $stickyColumn: stickyColumn, + $highlight: highlight, + $cursor: cursor, + $hideHeader: hideHeader, + }) => ({ + padding: 0, + position: 'sticky', + top: 0, + zIndex: 4, + '.thOuterWrap': { + alignItems: 'center', + display: hideHeader ? 'none' : 'flex', + position: 'relative', + backgroundColor: theme.colors[tableHeaderColor(fillLevel, highlight)], + zIndex: 4, + borderBottom: highlight + ? undefined + : theme.borders[tableFillLevelToBorder[fillLevel]], + color: theme.colors.text, + height: 48, + minHeight: 48, + whiteSpace: 'nowrap', + padding: '0 12px', + textAlign: 'left', + ...(cursor ? { cursor } : {}), + '.thSortIndicatorWrap': { + display: 'flex', + gap: theme.spacing.xsmall, + }, + }, + '&:last-child': { + /* Hackery to hide unpredictable visible gap between columns */ + zIndex: 3, + '&::before': { + content: '""', + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: 10000, + backgroundColor: theme.colors[tableHeaderColor(fillLevel, false)], + borderBottom: hideHeader + ? 'none' + : theme.borders[tableFillLevelToBorder[fillLevel]], + }, + }, + '&:first-child': { + ...(stickyColumn + ? { + backgroundColor: 'inherit', + position: 'sticky', + left: 0, + zIndex: 5, + '.thOuterWrap': { + boxShadow: theme.boxShadows.slight, + zIndex: 5, + }, + } + : {}), + }, + }) +) diff --git a/src/components/table/Thead.tsx b/src/components/table/Thead.tsx new file mode 100644 index 00000000..94b41bf5 --- /dev/null +++ b/src/components/table/Thead.tsx @@ -0,0 +1,23 @@ +import { type ComponentProps, forwardRef } from 'react' +import styled from 'styled-components' + +import { FillLevelProvider } from '../contexts/FillLevelContext' + +const TheadUnstyled = forwardRef< + HTMLTableSectionElement, + ComponentProps<'thead'> +>((props, ref) => ( + + + +)) + +export const Thead = styled(TheadUnstyled)(() => ({ + display: 'contents', + position: 'sticky', + top: 0, + zIndex: 3, +})) diff --git a/src/components/table/Tr.tsx b/src/components/table/Tr.tsx new file mode 100644 index 00000000..b93d9718 --- /dev/null +++ b/src/components/table/Tr.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components' + +import { tableCellColor, tableCellHoverColor } from './colors' +import { type TableFillLevel } from './Table' + +export const Tr = styled.tr<{ + $fillLevel: TableFillLevel + $highlighted?: boolean + $selected?: boolean + $selectable?: boolean + $clickable?: boolean + $raised?: boolean +}>( + ({ + theme, + $clickable: clickable = false, + $raised: raised = false, + $selectable: selectable = false, + $selected: selected = false, + $highlighted: highlighted = false, + $fillLevel: fillLevel, + }) => ({ + display: 'contents', + backgroundColor: + theme.colors[ + tableCellColor(fillLevel, highlighted, raised, selectable, selected) + ], + + ...(clickable && { + cursor: 'pointer', + + // highlight when hovered, but don't highlight if a child button is hovered + '&:not(:has(button:hover)):hover': { + backgroundColor: + theme.colors[tableCellHoverColor(fillLevel, selectable, selected)], + }, + }), + }) +) diff --git a/src/components/table/colors.ts b/src/components/table/colors.ts new file mode 100644 index 00000000..879bbbd6 --- /dev/null +++ b/src/components/table/colors.ts @@ -0,0 +1,89 @@ +import { type TableFillLevel } from './Table' + +export const tableFillLevelToBorder = { + 0: 'fill-two', + 1: 'fill-three', + 2: 'fill-three', +} as const satisfies Record + +export const tableFillLevelToBorderColor = { + 0: 'border-fill-two', + 1: 'border-fill-three', + 2: 'border-fill-three', +} as const satisfies Record + +export const tableFillLevelToBg = { + 0: 'fill-zero', + 1: 'fill-one', + 2: 'fill-two', +} as const satisfies Record + +const tableFillLevelToHeaderBg = { + 0: 'fill-one', + 1: 'fill-two', + 2: 'fill-three', +} as const satisfies Record + +const tableFillLevelToCellBg = { + 0: 'fill-zero', + 1: 'fill-one', + 2: 'fill-two', +} as const satisfies Record + +const tableFillLevelToRaisedCellBg = { + 0: 'fill-zero-selected', + 1: 'fill-one-selected', + 2: 'fill-two-selected', +} as const satisfies Record + +const tableFillLevelToSelectedCellBg = { + 0: 'fill-zero-hover', + 1: 'fill-one-hover', + 2: 'fill-two-hover', +} as const satisfies Record + +export const tableFillLevelToHighlightedCellBg = { + 0: 'fill-two', + 1: 'fill-three', + 2: 'fill-three', +} as const satisfies Record + +const tableFillLevelToHoverCellBg = { + 0: 'fill-zero-hover', + 1: 'fill-one-hover', + 2: 'fill-two-hover', +} as const satisfies Record + +export const tableHeaderColor = ( + fillLevel: TableFillLevel, + highlighted: boolean +) => + highlighted + ? tableFillLevelToHighlightedCellBg[fillLevel] + : tableFillLevelToHeaderBg[fillLevel] + +export const tableCellColor = ( + fillLevel: TableFillLevel, + highlighted: boolean, + raised: boolean, + selectable: boolean, + selected: boolean +) => + highlighted + ? tableFillLevelToHighlightedCellBg[fillLevel] + : selected + ? tableFillLevelToSelectedCellBg[fillLevel] + : raised || (selectable && !selected) + ? tableFillLevelToRaisedCellBg[fillLevel] + : tableFillLevelToCellBg[fillLevel] + +export const tableCellHoverColor = ( + fillLevel: TableFillLevel, + selectable: boolean, + selected: boolean +) => + selectable + ? selected + ? tableFillLevelToSelectedCellBg[fillLevel] + : tableFillLevelToRaisedCellBg[fillLevel] + : tableFillLevelToHoverCellBg[fillLevel] diff --git a/src/components/table/hooks.ts b/src/components/table/hooks.ts new file mode 100644 index 00000000..d6480215 --- /dev/null +++ b/src/components/table/hooks.ts @@ -0,0 +1,72 @@ +import type { VirtualItem } from '@tanstack/react-virtual' +import { type MutableRefObject, useEffect, useRef, useState } from 'react' + +import usePrevious from '../../hooks/usePrevious' + +import { type VirtualSlice } from './Table' + +export function useIsScrolling( + ref: MutableRefObject, + { + onIsScrollingChange: onScrollingChange, + restDelay = 350, + }: { onIsScrollingChange: (isScrolling: boolean) => void; restDelay?: number } +) { + const [isScrolling, setIsScrolling] = useState(false) + const timeout = useRef(null) + + useEffect(() => { + onScrollingChange?.(isScrolling) + }, [isScrolling, onScrollingChange]) + + useEffect(() => { + if (ref.current) { + const el = ref.current + + const scrollHandler = () => { + setIsScrolling(true) + window.clearTimeout(timeout.current) + timeout.current = window.setTimeout(() => { + setIsScrolling(false) + }, restDelay) + } + + el.addEventListener('scroll', scrollHandler, { passive: true }) + + return () => { + el.removeEventListener('scroll', scrollHandler) + } + } + }, [ref, restDelay]) +} + +export function useOnVirtualSliceChange({ + virtualRows, + virtualizeRows, + onVirtualSliceChange, +}: { + virtualRows: VirtualItem[] + virtualizeRows: boolean + onVirtualSliceChange: (slice: VirtualSlice) => void +}) { + const sliceStartRow = virtualRows[0] + const sliceEndRow: VirtualItem = virtualRows[virtualRows.length - 1] + const prevSliceStartRow = usePrevious(virtualRows[0]) + const prevSliceEndRow = usePrevious(virtualRows[virtualRows.length - 1]) + + useEffect(() => { + if ( + virtualizeRows && + (prevSliceEndRow !== sliceEndRow || prevSliceStartRow !== sliceStartRow) + ) { + onVirtualSliceChange?.({ start: sliceStartRow, end: sliceEndRow }) + } + }, [ + sliceStartRow, + sliceEndRow, + virtualizeRows, + onVirtualSliceChange, + prevSliceEndRow, + prevSliceStartRow, + ]) +} diff --git a/src/index.ts b/src/index.ts index 331c5e23..81ffb56b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ export { default as Tab } from './components/Tab' export type { TabListStateProps, TabBaseProps } from './components/TabList' export { TabList } from './components/TabList' export { default as TabPanel } from './components/TabPanel' -export { default as Table } from './components/Table' +export { default as Table } from './components/table/Table' export { default as TipCarousel } from './components/TipCarousel' export { type ValidationResponse, diff --git a/src/stories/Table.stories.tsx b/src/stories/Table.stories.tsx index 8332fb28..d1438494 100644 --- a/src/stories/Table.stories.tsx +++ b/src/stories/Table.stories.tsx @@ -359,14 +359,25 @@ const extremeLengthData = Array(200) export const Default = Template.bind({}) Default.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData, columns, } +export const Empty = Template.bind({}) +Empty.args = { + fillLevel: 0, + width: '900px', + height: '400px', + data: [], + columns, +} + export const Highlighted = Template.bind({}) Highlighted.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData, @@ -396,6 +407,7 @@ Highlighted.args = { export const VirtualizedRows = Template.bind({}) VirtualizedRows.args = { + fillLevel: 0, virtualizeRows: true, width: '900px', height: '400px', @@ -405,6 +417,7 @@ VirtualizedRows.args = { export const PagedData = PagedTemplate.bind({}) PagedData.args = { + fillLevel: 0, pageSize: 30, width: '900px', height: '400px', @@ -415,6 +428,7 @@ PagedData.args = { export const Loose = Template.bind({}) Loose.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData, @@ -425,6 +439,7 @@ Loose.args = { export const Clickable = Template.bind({}) Clickable.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData, @@ -435,6 +450,7 @@ Clickable.args = { export const StickyColumn = Template.bind({}) StickyColumn.args = { + fillLevel: 0, width: '400px', height: '400px', data: repeatedData, @@ -445,6 +461,7 @@ StickyColumn.args = { export const Expandable = Template.bind({}) Expandable.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData, @@ -457,6 +474,7 @@ Expandable.args = { export const FilterableAndSortable = FilterableTemplate.bind({}) FilterableAndSortable.args = { + fillLevel: 0, virtualizeRows: true, emptyStateProps: { message: 'No results match your query', @@ -470,6 +488,7 @@ FilterableAndSortable.args = { export const Selectable = SelectableTemplate.bind({}) Selectable.args = { + fillLevel: 0, width: '900px', height: '400px', data: repeatedData,