Skip to content

Commit

Permalink
refactor: Sticky columns utils (#1400)
Browse files Browse the repository at this point in the history
  • Loading branch information
pan-kot authored Aug 3, 2023
1 parent f6fe295 commit 9134265
Show file tree
Hide file tree
Showing 7 changed files with 116 additions and 95 deletions.
3 changes: 1 addition & 2 deletions src/table/__tests__/empty-state.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
1 change: 1 addition & 0 deletions src/table/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ const InternalTable = React.forwardRef(
[styles['has-footer']]: hasFooter,
[styles['has-header']]: hasHeader,
})}
style={stickyState.style.wrapper}
onScroll={handleScroll}
{...wrapperProps}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,28 +32,26 @@ 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(() =>
useStickyColumns({ visibleColumns: [], stickyColumnsFirst: 0, stickyColumnsLast: 0 })
);
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(() =>
useStickyColumns({ visibleColumns: [], stickyColumnsFirst: 1, stickyColumnsLast: 0 })
);
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));
});
Expand Down
8 changes: 2 additions & 6 deletions src/table/sticky-columns/index.ts
Original file line number Diff line number Diff line change
@@ -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';
33 changes: 33 additions & 0 deletions src/table/sticky-columns/interfaces.ts
Original file line number Diff line number Diff line change
@@ -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<PropertyKey, null | StickyColumnsCellState>;
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<PropertyKey, { first: number; last: number }>;
stickyWidthLeft: number;
stickyWidthRight: number;
}
108 changes: 25 additions & 83 deletions src/table/sticky-columns/use-sticky-columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,25 +31,6 @@ export interface StickyColumnsModel {
};
}

export interface StickyColumnsState {
cellState: Record<PropertyKey, null | StickyColumnsCellState>;
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,
Expand Down Expand Up @@ -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.
Expand All @@ -169,23 +150,21 @@ export function useStickyCellStyles({
columnId,
getClassName,
}: UseStickyCellStylesProps): StickyCellStyles {
const cellRef = useRef<HTMLElement>(null) as React.MutableRefObject<HTMLElement>;
const setCell = stickyColumns.refs.cell;

// unsubscribeRef to hold the function to unsubscribe from the store's updates
const unsubscribeRef = useRef<null | (() => void)>(null);

// 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];
Expand All @@ -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]) {
Expand All @@ -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));
});
Expand All @@ -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;
Expand All @@ -260,9 +221,11 @@ interface UpdateCellStylesProps {
}

export default class StickyColumnsStore extends AsyncStore<StickyColumnsState> {
private cellOffsets = new Map<PropertyKey, { first: number; last: number }>();
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;
Expand All @@ -273,14 +236,17 @@ export default class StickyColumnsStore extends AsyncStore<StickyColumnsState> {

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,
},
}));
}
}
Expand Down Expand Up @@ -321,8 +287,8 @@ export default class StickyColumnsStore extends AsyncStore<StickyColumnsState> {

// 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,
Expand All @@ -338,31 +304,7 @@ export default class StickyColumnsStore extends AsyncStore<StickyColumnsState> {
};

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 => {
Expand All @@ -378,7 +320,7 @@ export default class StickyColumnsStore extends AsyncStore<StickyColumnsState> {
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 =
Expand Down
52 changes: 52 additions & 0 deletions src/table/sticky-columns/utils.ts
Original file line number Diff line number Diff line change
@@ -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<PropertyKey, HTMLElement>, 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 };
}

0 comments on commit 9134265

Please sign in to comment.