diff --git a/src/table/__tests__/empty-state.test.tsx b/src/table/__tests__/empty-state.test.tsx index 98348e8846..09e481e36a 100644 --- a/src/table/__tests__/empty-state.test.tsx +++ b/src/table/__tests__/empty-state.test.tsx @@ -27,10 +27,9 @@ jest.mock('../../../lib/components/table/sticky-columns', () => ({ })); const mockStickyStateModel = { - isEnabled: false, store: jest.fn(), style: { - wrapper: '', + wrapper: undefined, }, refs: { table: jest.fn(), diff --git a/src/table/internal.tsx b/src/table/internal.tsx index 7503707d1c..1a214c9c22 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -292,6 +292,7 @@ const InternalTable = React.forwardRef( [styles['has-footer']]: hasFooter, [styles['has-header']]: hasHeader, })} + style={stickyState.style.wrapper} onScroll={handleScroll} {...wrapperProps} > diff --git a/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx b/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx index 4ac8178dfc..43d751e4ab 100644 --- a/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx +++ b/src/table/sticky-columns/__tests__/use-sticky-columns.test.tsx @@ -32,7 +32,7 @@ function createMockTable( return { wrapper, table, cells }; } -test('isEnabled is false, wrapper styles is empty and wrapper listener is not attached when feature is off', () => { +test('wrapper styles is empty and wrapper listener is not attached when feature is off', () => { const tableWrapper = document.createElement('div'); const addTableWrapperOnScrollSpy = jest.spyOn(tableWrapper, 'addEventListener'); const { result } = renderHook(() => @@ -40,12 +40,11 @@ test('isEnabled is false, wrapper styles is empty and wrapper listener is not at ); result.current.refs.wrapper(tableWrapper); - expect(result.current.isEnabled).toBe(false); expect(result.current.style.wrapper).not.toBeDefined(); expect(addTableWrapperOnScrollSpy).not.toHaveBeenCalled(); }); -test('isEnabled is true, wrapper styles is not empty and wrapper listener is attached when feature is on', () => { +test('wrapper styles is not empty and wrapper listener is attached when feature is on', () => { const tableWrapper = document.createElement('div'); const addTableWrapperOnScrollSpy = jest.spyOn(tableWrapper, 'addEventListener'); const { result } = renderHook(() => @@ -53,7 +52,6 @@ test('isEnabled is true, wrapper styles is not empty and wrapper listener is att ); result.current.refs.wrapper(tableWrapper); - expect(result.current.isEnabled).toBe(true); expect(result.current.style.wrapper).toEqual({ scrollPaddingLeft: 0, scrollPaddingRight: 0 }); expect(addTableWrapperOnScrollSpy).toHaveBeenCalledWith('scroll', expect.any(Function)); }); diff --git a/src/table/sticky-columns/index.ts b/src/table/sticky-columns/index.ts index 5452a44a30..ab09629b39 100644 --- a/src/table/sticky-columns/index.ts +++ b/src/table/sticky-columns/index.ts @@ -1,9 +1,5 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -export { - useStickyColumns, - useStickyCellStyles, - StickyColumnsModel, - StickyColumnsCellState, -} from './use-sticky-columns'; +export { StickyColumnsCellState } from './interfaces'; +export { useStickyColumns, useStickyCellStyles, StickyColumnsModel } from './use-sticky-columns'; diff --git a/src/table/sticky-columns/interfaces.ts b/src/table/sticky-columns/interfaces.ts new file mode 100644 index 0000000000..140e65e819 --- /dev/null +++ b/src/table/sticky-columns/interfaces.ts @@ -0,0 +1,33 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface StickyColumnsProps { + visibleColumns: readonly PropertyKey[]; + stickyColumnsFirst: number; + stickyColumnsLast: number; +} + +export interface StickyColumnsState { + cellState: Record; + wrapperState: StickyColumnsWrapperState; +} + +// Cell state is used to apply respective styles and offsets to sticky cells. +export interface StickyColumnsCellState { + padLeft: boolean; + lastLeft: boolean; + lastRight: boolean; + offset: { left?: number; right?: number }; +} + +// Scroll padding is applied to table's wrapper so that the table scrolls when focus goes behind sticky column. +export interface StickyColumnsWrapperState { + scrollPaddingLeft: number; + scrollPaddingRight: number; +} + +export interface CellOffsets { + offsets: Map; + stickyWidthLeft: number; + stickyWidthRight: number; +} diff --git a/src/table/sticky-columns/use-sticky-columns.ts b/src/table/sticky-columns/use-sticky-columns.ts index fdc540c080..4955613f43 100644 --- a/src/table/sticky-columns/use-sticky-columns.ts +++ b/src/table/sticky-columns/use-sticky-columns.ts @@ -6,19 +6,20 @@ import AsyncStore from '../../area-chart/async-store'; import { useStableEventHandler } from '../../internal/hooks/use-stable-event-handler'; import { useResizeObserver } from '../../internal/hooks/container-queries'; import clsx from 'clsx'; +import { + CellOffsets, + StickyColumnsCellState, + StickyColumnsProps, + StickyColumnsState, + StickyColumnsWrapperState, +} from './interfaces'; +import { isCellStatesEqual, isWrapperStatesEqual, updateCellOffsets } from './utils'; // We allow the table to have a minimum of 148px of available space besides the sum of the widths of the sticky columns // This value is an UX recommendation and is approximately 1/3 of our smallest breakpoint (465px) const MINIMUM_SCROLLABLE_SPACE = 148; -interface StickyColumnsProps { - visibleColumns: readonly PropertyKey[]; - stickyColumnsFirst: number; - stickyColumnsLast: number; -} - export interface StickyColumnsModel { - isEnabled: boolean; store: StickyColumnsStore; style: { wrapper?: React.CSSProperties; @@ -30,25 +31,6 @@ export interface StickyColumnsModel { }; } -export interface StickyColumnsState { - cellState: Record; - wrapperState: StickyColumnsWrapperState; -} - -// Cell state is used to apply respective styles and offsets to sticky cells. -export interface StickyColumnsCellState { - padLeft: boolean; - lastLeft: boolean; - lastRight: boolean; - offset: { left?: number; right?: number }; -} - -// Scroll padding is applied to table's wrapper so that the table scrolls when focus goes behind sticky column. -export interface StickyColumnsWrapperState { - scrollPaddingLeft: number; - scrollPaddingRight: number; -} - export function useStickyColumns({ visibleColumns, stickyColumnsFirst, @@ -142,7 +124,6 @@ export function useStickyColumns({ }, []); return { - isEnabled: hasStickyColumns, store, style: { // Provide wrapper styles as props so that a re-render won't cause invalidation. @@ -169,7 +150,6 @@ export function useStickyCellStyles({ columnId, getClassName, }: UseStickyCellStylesProps): StickyCellStyles { - const cellRef = useRef(null) as React.MutableRefObject; const setCell = stickyColumns.refs.cell; // unsubscribeRef to hold the function to unsubscribe from the store's updates @@ -177,15 +157,14 @@ export function useStickyCellStyles({ // refCallback updates the cell ref and sets up the store subscription const refCallback = useCallback( - node => { + cellElement => { if (unsubscribeRef.current) { // Unsubscribe before we do any updates to avoid leaving any subscriptions hanging unsubscribeRef.current(); } // Update cellRef and the store's state to point to the new DOM node - cellRef.current = node; - setCell(columnId, node); + setCell(columnId, cellElement); // Update cell styles imperatively to avoid unnecessary re-renders. const selector = (state: StickyColumnsState) => state.cellState[columnId]; @@ -196,7 +175,6 @@ export function useStickyCellStyles({ } const className = getClassName(state); - const cellElement = cellRef.current; if (cellElement) { Object.keys(className).forEach(key => { if (className[key]) { @@ -212,7 +190,7 @@ export function useStickyCellStyles({ // If the node is not null (i.e., the table cell is being mounted or updated, not unmounted), // set up a new subscription to the store's updates - if (node) { + if (cellElement) { unsubscribeRef.current = stickyColumns.store.subscribe(selector, (newState, prevState) => { updateCellStyles(selector(newState), selector(prevState)); }); @@ -233,23 +211,6 @@ export function useStickyCellStyles({ }; } -function isCellStatesEqual(s1: null | StickyColumnsCellState, s2: null | StickyColumnsCellState): boolean { - if (s1 && s2) { - return ( - s1.padLeft === s2.padLeft && - s1.lastLeft === s2.lastLeft && - s1.lastRight === s2.lastRight && - s1.offset.left === s2.offset.left && - s1.offset.right === s2.offset.right - ); - } - return s1 === s2; -} - -function isWrapperStatesEqual(s1: StickyColumnsWrapperState, s2: StickyColumnsWrapperState): boolean { - return s1.scrollPaddingLeft === s2.scrollPaddingLeft && s1.scrollPaddingRight === s2.scrollPaddingRight; -} - interface UpdateCellStylesProps { wrapper: HTMLElement; table: HTMLElement; @@ -260,9 +221,11 @@ interface UpdateCellStylesProps { } export default class StickyColumnsStore extends AsyncStore { - private cellOffsets = new Map(); - private stickyWidthLeft = 0; - private stickyWidthRight = 0; + private cellOffsets: CellOffsets = { + offsets: new Map(), + stickyWidthLeft: 0, + stickyWidthRight: 0, + }; private isStuckToTheLeft = false; private isStuckToTheRight = false; private padLeft = false; @@ -273,14 +236,17 @@ export default class StickyColumnsStore extends AsyncStore { public updateCellStyles(props: UpdateCellStylesProps) { const hasStickyColumns = props.stickyColumnsFirst + props.stickyColumnsLast > 0; - const hadStickyColumns = this.cellOffsets.size > 0; + const hadStickyColumns = this.cellOffsets.offsets.size > 0; if (hasStickyColumns || hadStickyColumns) { this.updateScroll(props); this.updateCellOffsets(props); this.set(() => ({ cellState: this.generateCellStyles(props), - wrapperState: { scrollPaddingLeft: this.stickyWidthLeft, scrollPaddingRight: this.stickyWidthRight }, + wrapperState: { + scrollPaddingLeft: this.cellOffsets.stickyWidthLeft, + scrollPaddingRight: this.cellOffsets.stickyWidthRight, + }, })); } } @@ -321,8 +287,8 @@ export default class StickyColumnsStore extends AsyncStore { // Determine the offset of the sticky column using the `cellOffsets` state object const isFirstColumn = index === 0; - const stickyColumnOffsetLeft = this.cellOffsets.get(columnId)?.first ?? 0; - const stickyColumnOffsetRight = this.cellOffsets.get(columnId)?.last ?? 0; + const stickyColumnOffsetLeft = this.cellOffsets.offsets.get(columnId)?.first ?? 0; + const stickyColumnOffsetRight = this.cellOffsets.offsets.get(columnId)?.last ?? 0; acc[columnId] = { padLeft: isFirstColumn && this.padLeft, @@ -338,31 +304,7 @@ export default class StickyColumnsStore extends AsyncStore { }; private updateCellOffsets = (props: UpdateCellStylesProps): void => { - const firstColumnsWidths: number[] = []; - for (let i = 0; i < props.visibleColumns.length; i++) { - const element = props.cells[props.visibleColumns[i]]; - const cellWidth = element.getBoundingClientRect().width ?? 0; - firstColumnsWidths[i] = (firstColumnsWidths[i - 1] ?? 0) + cellWidth; - } - - const lastColumnsWidths: number[] = []; - for (let i = props.visibleColumns.length - 1; i >= 0; i--) { - const element = props.cells[props.visibleColumns[i]]; - const cellWidth = element.getBoundingClientRect().width ?? 0; - lastColumnsWidths[i] = (lastColumnsWidths[i + 1] ?? 0) + cellWidth; - } - lastColumnsWidths.reverse(); - - this.stickyWidthLeft = firstColumnsWidths[props.stickyColumnsFirst - 1] ?? 0; - this.stickyWidthRight = lastColumnsWidths[props.stickyColumnsLast - 1] ?? 0; - this.cellOffsets = props.visibleColumns.reduce( - (map, columnId, columnIndex) => - map.set(columnId, { - first: firstColumnsWidths[columnIndex - 1] ?? 0, - last: lastColumnsWidths[props.visibleColumns.length - 1 - columnIndex - 1] ?? 0, - }), - new Map() - ); + this.cellOffsets = updateCellOffsets(props.cells, props); }; private isEnabled = (props: UpdateCellStylesProps): boolean => { @@ -378,7 +320,7 @@ export default class StickyColumnsStore extends AsyncStore { return false; } - const totalStickySpace = this.stickyWidthLeft + this.stickyWidthRight; + const totalStickySpace = this.cellOffsets.stickyWidthLeft + this.cellOffsets.stickyWidthRight; const tablePaddingLeft = parseFloat(getComputedStyle(props.table).paddingLeft) || 0; const tablePaddingRight = parseFloat(getComputedStyle(props.table).paddingRight) || 0; const hasEnoughScrollableSpace = diff --git a/src/table/sticky-columns/utils.ts b/src/table/sticky-columns/utils.ts new file mode 100644 index 0000000000..c734eae9fc --- /dev/null +++ b/src/table/sticky-columns/utils.ts @@ -0,0 +1,52 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { CellOffsets, StickyColumnsCellState, StickyColumnsProps, StickyColumnsWrapperState } from './interfaces'; + +export function isCellStatesEqual(s1: null | StickyColumnsCellState, s2: null | StickyColumnsCellState): boolean { + if (s1 && s2) { + return ( + s1.padLeft === s2.padLeft && + s1.lastLeft === s2.lastLeft && + s1.lastRight === s2.lastRight && + s1.offset.left === s2.offset.left && + s1.offset.right === s2.offset.right + ); + } + return s1 === s2; +} + +export function isWrapperStatesEqual(s1: StickyColumnsWrapperState, s2: StickyColumnsWrapperState): boolean { + return s1.scrollPaddingLeft === s2.scrollPaddingLeft && s1.scrollPaddingRight === s2.scrollPaddingRight; +} + +export function updateCellOffsets(cells: Record, props: StickyColumnsProps): CellOffsets { + const totalColumns = props.visibleColumns.length; + + const firstColumnsWidths: number[] = []; + for (let i = 0; i < Math.min(totalColumns, props.stickyColumnsFirst); i++) { + const element = cells[props.visibleColumns[i]]; + const cellWidth = element.getBoundingClientRect().width ?? 0; + firstColumnsWidths[i] = (firstColumnsWidths[i - 1] ?? 0) + cellWidth; + } + + const lastColumnsWidths: number[] = []; + for (let i = 0; i < Math.min(totalColumns, props.stickyColumnsLast); i++) { + const element = cells[props.visibleColumns[totalColumns - 1 - i]]; + const cellWidth = element.getBoundingClientRect().width ?? 0; + lastColumnsWidths[i] = (lastColumnsWidths[i - 1] ?? 0) + cellWidth; + } + + const stickyWidthLeft = firstColumnsWidths[props.stickyColumnsFirst - 1] ?? 0; + const stickyWidthRight = lastColumnsWidths[props.stickyColumnsLast - 1] ?? 0; + const offsets = props.visibleColumns.reduce( + (map, columnId, columnIndex) => + map.set(columnId, { + first: firstColumnsWidths[columnIndex - 1] ?? 0, + last: lastColumnsWidths[totalColumns - 1 - columnIndex - 1] ?? 0, + }), + new Map() + ); + + return { offsets, stickyWidthLeft, stickyWidthRight }; +}