UNPKG

@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
// 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