@awsui/components-react
Version:
On July 19th, 2022, we launched [Cloudscape Design System](https://cloudscape.design). Cloudscape is an evolution of AWS-UI. It consists of user interface guidelines, front-end components, design resources, and development tools for building intuitive, en
241 lines • 12.3 kB
JavaScript
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useMemo, useRef } from 'react';
import clsx from 'clsx';
import { useResizeObserver, useStableCallback } from '@awsui/component-toolkit/internal';
import { getLogicalBoundingClientRect, getScrollInlineStart } from '@awsui/component-toolkit/internal';
import AsyncStore from '../../area-chart/async-store';
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;
export function useStickyColumns({ visibleColumns, stickyColumnsFirst, stickyColumnsLast, }) {
const store = useMemo(() => new StickyColumnsStore(), []);
const wrapperRef = useRef(null);
const tableRef = useRef(null);
const cellsRef = useRef(new Map());
const hasStickyColumns = stickyColumnsFirst + stickyColumnsLast > 0;
const updateStickyStyles = useStableCallback(() => {
if (wrapperRef.current && tableRef.current) {
store.updateCellStyles({
wrapper: wrapperRef.current,
table: tableRef.current,
cells: cellsRef.current,
visibleColumns,
stickyColumnsFirst,
stickyColumnsLast,
});
}
});
useResizeObserver(wrapperRef, updateStickyStyles);
useResizeObserver(tableRef, updateStickyStyles);
useEffect(() => {
if (wrapperRef.current && tableRef.current) {
store.updateCellStyles({
wrapper: wrapperRef.current,
table: tableRef.current,
cells: cellsRef.current,
visibleColumns,
stickyColumnsFirst,
stickyColumnsLast,
});
}
}, [store, stickyColumnsFirst, stickyColumnsLast, visibleColumns]);
// Update wrapper styles imperatively to avoid unnecessary re-renders.
useEffect(() => {
if (!hasStickyColumns) {
return;
}
const selector = (state) => state.wrapperState;
const updateWrapperStyles = (state, prev) => {
if (isWrapperStatesEqual(state, prev)) {
return;
}
if (wrapperRef.current) {
wrapperRef.current.style.scrollPaddingInlineStart = state.scrollPaddingInlineStart + 'px';
wrapperRef.current.style.scrollPaddingInlineEnd = state.scrollPaddingInlineEnd + 'px';
}
};
const unsubscribe = store.subscribe(selector, (newState, prevState) => updateWrapperStyles(selector(newState), selector(prevState)));
return unsubscribe;
}, [store, hasStickyColumns]);
const setWrapper = useCallback((node) => {
if (wrapperRef.current) {
wrapperRef.current.removeEventListener('scroll', updateStickyStyles);
}
if (node && hasStickyColumns) {
node.addEventListener('scroll', updateStickyStyles);
}
wrapperRef.current = node;
}, [hasStickyColumns, updateStickyStyles]);
const setTable = useCallback((node) => {
tableRef.current = node;
}, []);
const setCell = useCallback((columnId, node) => {
if (node) {
cellsRef.current.set(columnId, node);
}
else {
cellsRef.current.delete(columnId);
}
}, []);
return {
store,
style: {
// Provide wrapper styles as props so that a re-render won't cause invalidation.
wrapper: hasStickyColumns ? { ...store.get().wrapperState } : undefined,
},
refs: { wrapper: setWrapper, table: setTable, cell: setCell },
};
}
export function useStickyCellStyles({ stickyColumns, columnId, getClassName, }) {
var _a;
const setCell = stickyColumns.refs.cell;
// unsubscribeRef to hold the function to unsubscribe from the store's updates
const unsubscribeRef = useRef(null);
// refCallback updates the cell ref and sets up the store subscription
const refCallback = useCallback((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
setCell(columnId, cellElement);
// Update cell styles imperatively to avoid unnecessary re-renders.
const selector = (state) => { var _a; return (_a = state.cellState.get(columnId)) !== null && _a !== void 0 ? _a : null; };
const updateCellStyles = (state, prev) => {
if (isCellStatesEqual(state, prev)) {
return;
}
const className = getClassName(state);
if (cellElement) {
Object.keys(className).forEach(key => {
if (className[key]) {
cellElement.classList.add(key);
}
else {
cellElement.classList.remove(key);
}
});
cellElement.style.insetInlineStart =
(state === null || state === void 0 ? void 0 : state.offset.insetInlineStart) !== undefined ? `${state.offset.insetInlineStart}px` : '';
cellElement.style.insetInlineEnd =
(state === null || state === void 0 ? void 0 : state.offset.insetInlineEnd) !== undefined ? `${state.offset.insetInlineEnd}px` : '';
}
};
// 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 (cellElement) {
unsubscribeRef.current = stickyColumns.store.subscribe(selector, (newState, prevState) => {
updateCellStyles(selector(newState), selector(prevState));
});
}
},
// getClassName is expected to be pure
// eslint-disable-next-line react-hooks/exhaustive-deps
[columnId, setCell, stickyColumns.store]);
// Provide cell styles as props so that a re-render won't cause invalidation.
const cellStyles = stickyColumns.store.get().cellState.get(columnId);
return {
ref: refCallback,
className: cellStyles ? clsx(getClassName(cellStyles)) : undefined,
style: (_a = cellStyles === null || cellStyles === void 0 ? void 0 : cellStyles.offset) !== null && _a !== void 0 ? _a : undefined,
};
}
class StickyColumnsStore extends AsyncStore {
constructor() {
super({ cellState: new Map(), wrapperState: { scrollPaddingInlineStart: 0, scrollPaddingInlineEnd: 0 } });
this.cellOffsets = {
offsets: new Map(),
stickyWidthInlineStart: 0,
stickyWidthInlineEnd: 0,
};
this.isStuckToTheInlineStart = false;
this.isStuckToTheInlineEnd = false;
this.padInlineStart = false;
this.generateCellStyles = (props) => {
const isEnabled = this.isEnabled(props);
const lastLeftStickyColumnIndex = props.stickyColumnsFirst - 1;
const lastRightStickyColumnIndex = props.visibleColumns.length - props.stickyColumnsLast;
return props.visibleColumns.reduce((acc, columnId, index) => {
var _a, _b, _c, _d;
let stickySide = 'non-sticky';
if (index < props.stickyColumnsFirst) {
stickySide = 'inline-start';
}
else if (index >= props.visibleColumns.length - props.stickyColumnsLast) {
stickySide = 'inline-end';
}
if (!isEnabled || stickySide === 'non-sticky') {
return acc;
}
// Determine the offset of the sticky column using the `cellOffsets` state object
const isFirstColumn = index === 0;
const stickyColumnOffsetLeft = (_b = (_a = this.cellOffsets.offsets.get(columnId)) === null || _a === void 0 ? void 0 : _a.first) !== null && _b !== void 0 ? _b : 0;
const stickyColumnOffsetRight = (_d = (_c = this.cellOffsets.offsets.get(columnId)) === null || _c === void 0 ? void 0 : _c.last) !== null && _d !== void 0 ? _d : 0;
acc.set(columnId, {
padInlineStart: isFirstColumn && this.padInlineStart,
lastInsetInlineStart: this.isStuckToTheInlineStart && lastLeftStickyColumnIndex === index,
lastInsetInlineEnd: this.isStuckToTheInlineEnd && lastRightStickyColumnIndex === index,
offset: {
insetInlineStart: stickySide === 'inline-start' ? stickyColumnOffsetLeft : undefined,
insetInlineEnd: stickySide === 'inline-end' ? stickyColumnOffsetRight : undefined,
},
});
return acc;
}, new Map());
};
this.updateCellOffsets = (props) => {
this.cellOffsets = updateCellOffsets(props.cells, props);
};
this.isEnabled = (props) => {
const noStickyColumns = props.stickyColumnsFirst + props.stickyColumnsLast === 0;
if (noStickyColumns) {
return false;
}
const wrapperWidth = getLogicalBoundingClientRect(props.wrapper).inlineSize;
const tableWidth = getLogicalBoundingClientRect(props.table).inlineSize;
const isWrapperScrollable = tableWidth > wrapperWidth;
if (!isWrapperScrollable) {
return false;
}
const totalStickySpace = this.cellOffsets.stickyWidthInlineStart + this.cellOffsets.stickyWidthInlineEnd;
const tablePaddingLeft = parseFloat(getComputedStyle(props.table).paddingLeft) || 0;
const tablePaddingRight = parseFloat(getComputedStyle(props.table).paddingRight) || 0;
const hasEnoughScrollableSpace = totalStickySpace + MINIMUM_SCROLLABLE_SPACE + tablePaddingLeft + tablePaddingRight < wrapperWidth;
if (!hasEnoughScrollableSpace) {
return false;
}
return true;
};
}
updateCellStyles(props) {
const hasStickyColumns = props.stickyColumnsFirst + props.stickyColumnsLast > 0;
const hadStickyColumns = this.cellOffsets.offsets.size > 0;
if (hasStickyColumns || hadStickyColumns) {
this.updateScroll(props);
this.updateCellOffsets(props);
this.set(() => ({
cellState: this.generateCellStyles(props),
wrapperState: {
scrollPaddingInlineStart: this.cellOffsets.stickyWidthInlineStart,
scrollPaddingInlineEnd: this.cellOffsets.stickyWidthInlineEnd,
},
}));
}
}
updateScroll(props) {
const wrapperScrollInlineStart = getScrollInlineStart(props.wrapper);
const wrapperScrollWidth = props.wrapper.scrollWidth;
const wrapperClientWidth = props.wrapper.clientWidth;
const tablePaddingInlineStart = parseFloat(getComputedStyle(props.table).paddingInlineStart) || 0;
const tablePaddingInlineEnd = parseFloat(getComputedStyle(props.table).paddingInlineEnd) || 0;
this.isStuckToTheInlineStart = wrapperScrollInlineStart > tablePaddingInlineStart;
// Math.ceil() is used here to address an edge-case in certain browsers, where they return non-integer wrapperScrollInlineStart values
// which are lower than expected (sub-pixel difference), resulting in the table always being in the "stuck to the right" state
this.isStuckToTheInlineEnd =
Math.ceil(wrapperScrollInlineStart) < wrapperScrollWidth - wrapperClientWidth - tablePaddingInlineEnd;
this.padInlineStart = tablePaddingInlineStart !== 0 && this.isStuckToTheInlineStart;
}
}
//# sourceMappingURL=use-sticky-columns.js.map