diff --git a/src/table/internal.tsx b/src/table/internal.tsx index ef04ea3d87..04c56341ee 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -17,7 +17,7 @@ import { useRowEvents } from './use-row-events'; import { focusMarkers, useFocusMove, useSelection } from './use-selection'; import { fireCancelableEvent, fireNonCancelableEvent } from '../internal/events'; import { isDevelopment } from '../internal/is-development'; -import { ColumnWidthDefinition, ColumnWidthsProvider, DEFAULT_COLUMN_WIDTH } from './use-column-widths'; +import { ColumnWidthDefinition, DEFAULT_COLUMN_WIDTH, useColumnWidths } from './use-column-widths'; import { useScrollSync } from '../internal/hooks/use-scroll-sync'; import { ResizeTracker } from './resizer'; import styles from './styles.css.js'; @@ -176,6 +176,11 @@ const InternalTable = React.forwardRef( stickyColumnsLast: stickyColumns?.last || 0, }); + const columnWidths = useColumnWidths({ + visibleColumns: visibleColumnWidthsWithSelection, + resizableColumns, + }); + const theadProps: TheadProps = { containerWidth, selectionType, @@ -201,6 +206,7 @@ const InternalTable = React.forwardRef( singleSelectionHeaderAriaLabel: ariaLabels?.selectionGroupLabel, stripedRows, stickyState, + columnWidths, selectionColumnId, }; @@ -237,242 +243,239 @@ const InternalTable = React.forwardRef( (toolsHeaderWrapper?.current as HTMLDivElement | null)?.getBoundingClientRect().height ?? 0; return ( - - - {hasHeader && ( + + {hasHeader && ( +
-
- -
-
- )} - {stickyHeader && ( - - )} - - } - disableHeaderPaddings={true} - disableContentPaddings={true} - variant={toContainerVariant(computedVariant)} - __disableFooterPaddings={true} - __disableFooterDivider={true} - __disableStickyMobile={false} - footer={ - hasFooter ? ( -
-
- {footer && {footer}} - {hasFooterPagination &&
{pagination}
} +
- ) : null - } - __stickyHeader={stickyHeader} - __mobileStickyOffset={toolsHeaderHeight} - __stickyOffset={stickyHeaderVerticalOffset} - {...focusMarkers.root} - > -
- {!!renderAriaLive && !!firstIndex && ( - - {renderAriaLive({ totalItemsCount, firstIndex, lastIndex: firstIndex + items.length - 1 })} - )} - - stickyHeaderRef.current?.setFocus(component)} - {...theadProps} + {stickyHeader && ( + - - {loading || items.length === 0 ? ( - - + ); + }) + )} + +
+ } + disableHeaderPaddings={true} + disableContentPaddings={true} + variant={toContainerVariant(computedVariant)} + __disableFooterPaddings={true} + __disableFooterDivider={true} + __disableStickyMobile={false} + footer={ + hasFooter ? ( +
+
+ {footer && {footer}} + {hasFooterPagination &&
{pagination}
} +
+
+ ) : null + } + __stickyHeader={stickyHeader} + __mobileStickyOffset={toolsHeaderHeight} + __stickyOffset={stickyHeaderVerticalOffset} + {...focusMarkers.root} + > +
+ {!!renderAriaLive && !!firstIndex && ( + + {renderAriaLive({ totalItemsCount, firstIndex, lastIndex: firstIndex + items.length - 1 })} + + )} + + stickyHeaderRef.current?.setFocus(component)} + {...theadProps} + /> + + {loading || items.length === 0 ? ( + + - - ) : ( - items.map((item, rowIndex) => { - const firstVisible = rowIndex === 0; - const lastVisible = rowIndex === items.length - 1; - const isEven = rowIndex % 2 === 0; - const isSelected = !!selectionType && isItemSelected(item); - const isPrevSelected = !!selectionType && !firstVisible && isItemSelected(items[rowIndex - 1]); - const isNextSelected = !!selectionType && !lastVisible && isItemSelected(items[rowIndex + 1]); - return ( - { - // When an element inside table row receives focus we want to adjust the scroll. - // However, that behaviour is unwanted when the focus is received as result of a click - // as it causes the click to never reach the target element. - if (!currentTarget.contains(getMouseDownTarget())) { - stickyHeaderRef.current?.scrollToRow(currentTarget); - } - }} - {...focusMarkers.item} - onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, item)} - onContextMenu={onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, item)} - aria-rowindex={firstIndex ? firstIndex + rowIndex + 1 : undefined} - > - {selectionType !== undefined && ( - + {loadingText} + + ) : ( +
{empty}
+ )} + + +
+ ) : ( + items.map((item, rowIndex) => { + const firstVisible = rowIndex === 0; + const lastVisible = rowIndex === items.length - 1; + const isEven = rowIndex % 2 === 0; + const isSelected = !!selectionType && isItemSelected(item); + const isPrevSelected = !!selectionType && !firstVisible && isItemSelected(items[rowIndex - 1]); + const isNextSelected = !!selectionType && !lastVisible && isItemSelected(items[rowIndex + 1]); + return ( + { + // When an element inside table row receives focus we want to adjust the scroll. + // However, that behaviour is unwanted when the focus is received as result of a click + // as it causes the click to never reach the target element. + if (!currentTarget.contains(getMouseDownTarget())) { + stickyHeaderRef.current?.scrollToRow(currentTarget); + } + }} + {...focusMarkers.item} + onClick={onRowClickHandler && onRowClickHandler.bind(null, rowIndex, item)} + onContextMenu={onRowContextMenuHandler && onRowContextMenuHandler.bind(null, rowIndex, item)} + aria-rowindex={firstIndex ? firstIndex + rowIndex + 1 : undefined} + > + {selectionType !== undefined && ( + + + + )} + {visibleColumnDefinitions.map((column, colIndex) => { + const isEditing = + !!currentEditCell && currentEditCell[0] === rowIndex && currentEditCell[1] === colIndex; + const successfulEdit = + !!lastSuccessfulEditCell && + lastSuccessfulEditCell[0] === rowIndex && + lastSuccessfulEditCell[1] === colIndex; + const isEditable = !!column.editConfig && !currentEditLoading; + return ( + { + setLastSuccessfulEditCell(null); + setCurrentEditCell([rowIndex, colIndex]); + }} + onEditEnd={editCancelled => { + const eventCancelled = fireCancelableEvent(onEditCancel, {}); + if (!eventCancelled) { + setCurrentEditCell(null); + if (!editCancelled) { + setLastSuccessfulEditCell([rowIndex, colIndex]); + } + } + }} + submitEdit={wrapWithInlineLoadingState(submitEdit)} hasFooter={hasFooter} + stripedRows={stripedRows} + isEvenRow={isEven} + columnId={column.id ?? colIndex} stickyState={stickyState} - columnId={selectionColumnId} - > - - - )} - {visibleColumnDefinitions.map((column, colIndex) => { - const isEditing = - !!currentEditCell && currentEditCell[0] === rowIndex && currentEditCell[1] === colIndex; - const successfulEdit = - !!lastSuccessfulEditCell && - lastSuccessfulEditCell[0] === rowIndex && - lastSuccessfulEditCell[1] === colIndex; - const isEditable = !!column.editConfig && !currentEditLoading; - return ( - { - setLastSuccessfulEditCell(null); - setCurrentEditCell([rowIndex, colIndex]); - }} - onEditEnd={editCancelled => { - const eventCancelled = fireCancelableEvent(onEditCancel, {}); - if (!eventCancelled) { - setCurrentEditCell(null); - if (!editCancelled) { - setLastSuccessfulEditCell([rowIndex, colIndex]); - } - } - }} - submitEdit={wrapWithInlineLoadingState(submitEdit)} - hasFooter={hasFooter} - stripedRows={stripedRows} - isEvenRow={isEven} - columnId={column.id ?? colIndex} - stickyState={stickyState} - isVisualRefresh={isVisualRefresh} - /> - ); - })} - - ); - }) - )} - -
+
-
- {loading ? ( - - {loadingText} - - ) : ( -
{empty}
- )} -
-
- {resizableColumns && } -
- - - + isVisualRefresh={isVisualRefresh} + /> + ); + })} +
+ {resizableColumns && } +
+ + ); } ) as TableForwardRefType; diff --git a/src/table/sticky-columns/use-sticky-columns.ts b/src/table/sticky-columns/use-sticky-columns.ts index fdc540c080..6043ddec5f 100644 --- a/src/table/sticky-columns/use-sticky-columns.ts +++ b/src/table/sticky-columns/use-sticky-columns.ts @@ -259,7 +259,7 @@ interface UpdateCellStylesProps { stickyColumnsLast: number; } -export default class StickyColumnsStore extends AsyncStore { +class StickyColumnsStore extends AsyncStore { private cellOffsets = new Map(); private stickyWidthLeft = 0; private stickyWidthRight = 0; diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 7396be282d..20065d18e8 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -8,13 +8,14 @@ import { focusMarkers, SelectionProps } from './use-selection'; import { fireNonCancelableEvent, NonCancelableEventHandler } from '../internal/events'; import { getColumnKey, getStickyClassNames } from './utils'; import { TableHeaderCell } from './header-cell'; -import { useColumnWidths } from './use-column-widths'; import { useVisualRefresh } from '../internal/hooks/use-visual-mode'; import styles from './styles.css.js'; import cellStyles from './header-cell/styles.css.js'; import headerCellStyles from './header-cell/styles.css.js'; import ScreenreaderOnly from '../internal/components/screenreader-only'; import { StickyColumnsModel, useStickyCellStyles } from './sticky-columns'; +import { useSelector } from '../area-chart/async-store'; +import { ColumnWidthsModel } from './use-column-widths'; export type InteractiveComponent = | { type: 'selection' } @@ -41,6 +42,7 @@ export interface TheadProps { singleSelectionHeaderAriaLabel?: string; stripedRows?: boolean; stickyState: StickyColumnsModel; + columnWidths: ColumnWidthsModel; selectionColumnId: PropertyKey; focusedComponent?: InteractiveComponent | null; onFocusedComponentChange?: (element: InteractiveComponent | null) => void; @@ -68,6 +70,7 @@ const Thead = React.forwardRef( hidden = false, stuck = false, stickyState, + columnWidths: columnWidthsStore, selectionColumnId, focusedComponent, onFocusedComponentChange, @@ -91,7 +94,8 @@ const Thead = React.forwardRef( isVisualRefresh && styles['is-visual-refresh'] ); - const { columnWidths, totalWidth, updateColumn, setCell } = useColumnWidths(); + const columnWidths = useSelector(columnWidthsStore, s => s.columnWidths); + const totalWidth = useSelector(columnWidthsStore, s => s.totalWidth); const stickyStyles = useStickyCellStyles({ stickyColumns: stickyState, @@ -163,13 +167,13 @@ const Thead = React.forwardRef( hidden={hidden} colIndex={colIndex} columnId={columnId} - updateColumn={updateColumn} + updateColumn={columnWidthsStore.updateColumnWidth} onResizeFinish={() => onResizeFinish(columnWidths)} resizableColumns={resizableColumns} onClick={detail => fireNonCancelableEvent(onSortingChange, detail)} isEditable={!!column.editConfig} stickyState={stickyState} - cellRef={node => setCell(columnId, node)} + cellRef={node => columnWidthsStore.setCell(columnId, node)} /> ); })} diff --git a/src/table/use-column-widths.ts b/src/table/use-column-widths.ts new file mode 100644 index 0000000000..a4fe7e0264 --- /dev/null +++ b/src/table/use-column-widths.ts @@ -0,0 +1,138 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useMemo } from 'react'; +import AsyncStore from '../area-chart/async-store'; + +export const DEFAULT_COLUMN_WIDTH = 120; + +export interface ColumnWidthDefinition { + id: PropertyKey; + minWidth?: string | number; + width?: string | number; +} + +interface ColumnWidthsState { + visibleColumns: readonly ColumnWidthDefinition[]; + columnWidths: Record; + totalWidth: number; +} + +interface ColumnWidthsProps { + visibleColumns: readonly ColumnWidthDefinition[]; + resizableColumns: boolean | undefined; +} + +export interface ColumnWidthsModel extends AsyncStore { + updateColumnWidth(columnId: PropertyKey, newWidth: number): void; + setCell(columnId: PropertyKey, node: null | HTMLElement): void; +} + +export function useColumnWidths({ visibleColumns, resizableColumns }: ColumnWidthsProps): ColumnWidthsModel { + const store = useMemo(() => new ColumnWidthsStore(), []); + + // The widths of the dynamically added columns (after the first render) if not set explicitly + // will default to the DEFAULT_COLUMN_WIDTH. + useEffect(() => { + if (!resizableColumns) { + return; + } + store.syncWidths(visibleColumns); + }, [store, resizableColumns, visibleColumns]); + + // Read the actual column widths after the first render to employ the browser defaults for + // those columns without explicit width. + useEffect(() => { + if (!resizableColumns) { + return; + } + store.initWidths(visibleColumns); + // This code is intended to run only at the first render and should not re-run when table props change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return store; +} + +class ColumnWidthsStore extends AsyncStore { + private cells: Record = {}; + private lastVisible: null | (PropertyKey | undefined)[] = null; + + constructor() { + super({ visibleColumns: [], columnWidths: {}, totalWidth: 0 }); + } + + initWidths = (visibleColumns: readonly ColumnWidthDefinition[]) => { + const getCell = (columnId: PropertyKey): null | HTMLElement => this.cells[columnId] ?? null; + const columnWidths = readWidths(visibleColumns, getCell); + const totalWidth = getTotalWidth(visibleColumns, columnWidths); + this.set(() => ({ visibleColumns, columnWidths, totalWidth })); + }; + + syncWidths = (visibleColumns: readonly ColumnWidthDefinition[]) => { + const updates: Record = {}; + if (this.lastVisible) { + for (let index = 0; index < visibleColumns.length; index++) { + const column = visibleColumns[index]; + if (!this.get().columnWidths[column.id] && this.lastVisible.indexOf(column.id) === -1) { + updates[column.id] = (column.width as number) || DEFAULT_COLUMN_WIDTH; + } + } + if (Object.keys(updates).length > 0) { + this.set(prev => { + const columnWidths = { ...prev.columnWidths, ...updates }; + const totalWidth = getTotalWidth(visibleColumns, columnWidths); + return { visibleColumns, columnWidths, totalWidth }; + }); + } + this.lastVisible = visibleColumns.map(column => column.id); + } + }; + + updateColumnWidth = (columnId: PropertyKey, newWidth: number) => { + this.set(state => { + const column = state.visibleColumns.find(column => column.id === columnId); + const minWidth = typeof column?.minWidth === 'number' ? column.minWidth : DEFAULT_COLUMN_WIDTH; + newWidth = Math.max(newWidth, minWidth); + if (state.columnWidths[columnId] === newWidth) { + return state; + } + const columnWidths = { ...state.columnWidths, [columnId]: newWidth }; + const totalWidth = getTotalWidth(state.visibleColumns, columnWidths); + return { ...state, columnWidths, totalWidth }; + }); + }; + + setCell = (columnId: PropertyKey, node: null | HTMLElement) => { + if (node) { + this.cells[columnId] = node; + } else { + delete this.cells[columnId]; + } + }; +} + +function readWidths( + visibleColumns: readonly ColumnWidthDefinition[], + getCell: (columnId: PropertyKey) => null | HTMLElement +) { + const result: Record = {}; + for (let index = 0; index < visibleColumns.length; index++) { + const column = visibleColumns[index]; + let width = (column.width as number) || 0; + const minWidth = (column.minWidth as number) || width || DEFAULT_COLUMN_WIDTH; + if ( + !width && // read width from the DOM if it is missing in the config + index !== visibleColumns.length - 1 // skip reading for the last column, because it expands to fully fit the container + ) { + const colEl = getCell(column.id); + width = colEl?.getBoundingClientRect().width ?? DEFAULT_COLUMN_WIDTH; + } + result[column.id] = Math.max(width, minWidth); + } + return result; +} + +function getTotalWidth(visibleColumns: readonly ColumnWidthDefinition[], columnWidths: Record) { + return visibleColumns.reduce((total, column) => total + (columnWidths[column.id] || DEFAULT_COLUMN_WIDTH), 0); +} diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx deleted file mode 100644 index b79760fb53..0000000000 --- a/src/table/use-column-widths.tsx +++ /dev/null @@ -1,134 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 -import React, { useEffect, useRef, useState, createContext, useContext } from 'react'; - -export const DEFAULT_COLUMN_WIDTH = 120; - -export interface ColumnWidthDefinition { - id: PropertyKey; - minWidth?: string | number; - width?: string | number; -} - -function readWidths( - getCell: (columnId: PropertyKey) => null | HTMLElement, - visibleColumns: readonly ColumnWidthDefinition[] -) { - const result: Record = {}; - for (let index = 0; index < visibleColumns.length; index++) { - const column = visibleColumns[index]; - let width = (column.width as number) || 0; - const minWidth = (column.minWidth as number) || width || DEFAULT_COLUMN_WIDTH; - if ( - !width && // read width from the DOM if it is missing in the config - index !== visibleColumns.length - 1 // skip reading for the last column, because it expands to fully fit the container - ) { - const colEl = getCell(column.id); - width = colEl?.getBoundingClientRect().width ?? DEFAULT_COLUMN_WIDTH; - } - result[column.id] = Math.max(width, minWidth); - } - return result; -} - -function updateWidths( - visibleColumns: readonly ColumnWidthDefinition[], - oldWidths: Record, - newWidth: number, - columnId: PropertyKey -) { - const column = visibleColumns.find(column => column.id === columnId); - const minWidth = typeof column?.minWidth === 'number' ? column.minWidth : DEFAULT_COLUMN_WIDTH; - newWidth = Math.max(newWidth, minWidth); - if (oldWidths[columnId] === newWidth) { - return oldWidths; - } - return { ...oldWidths, [columnId]: newWidth }; -} - -interface WidthsContext { - totalWidth: number; - columnWidths: Record; - updateColumn: (columnId: PropertyKey, newWidth: number) => void; - setCell: (columnId: PropertyKey, node: null | HTMLElement) => void; -} - -const WidthsContext = createContext({ - totalWidth: 0, - columnWidths: {}, - updateColumn: () => {}, - setCell: () => {}, -}); - -interface WidthProviderProps { - visibleColumns: readonly ColumnWidthDefinition[]; - resizableColumns: boolean | undefined; - children: React.ReactNode; -} - -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, children }: WidthProviderProps) { - const visibleColumnsRef = useRef<(PropertyKey | undefined)[] | null>(null); - const [columnWidths, setColumnWidths] = useState>({}); - - const cellsRef = useRef>({}); - const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current[columnId] ?? null; - const setCell = (columnId: PropertyKey, node: null | HTMLElement) => { - if (node) { - cellsRef.current[columnId] = node; - } else { - delete cellsRef.current[columnId]; - } - }; - - // The widths of the dynamically added columns (after the first render) if not set explicitly - // will default to the DEFAULT_COLUMN_WIDTH. - useEffect(() => { - if (!resizableColumns) { - return; - } - const updates: Record = {}; - const lastVisible = visibleColumnsRef.current; - if (lastVisible) { - for (let index = 0; index < visibleColumns.length; index++) { - const column = visibleColumns[index]; - if (!columnWidths[column.id] && lastVisible.indexOf(column.id) === -1) { - updates[column.id] = (column.width as number) || DEFAULT_COLUMN_WIDTH; - } - } - if (Object.keys(updates).length > 0) { - setColumnWidths(columnWidths => ({ ...columnWidths, ...updates })); - } - } - visibleColumnsRef.current = visibleColumns.map(column => column.id); - }, [columnWidths, resizableColumns, visibleColumns]); - - // Read the actual column widths after the first render to employ the browser defaults for - // those columns without explicit width. - useEffect(() => { - if (!resizableColumns) { - return; - } - setColumnWidths(() => readWidths(getCell, visibleColumns)); - // This code is intended to run only at the first render and should not re-run when table props change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - function updateColumn(columnId: PropertyKey, newWidth: number) { - setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths, newWidth, columnId)); - } - - const totalWidth = visibleColumns.reduce( - (total, column) => total + (columnWidths[column.id] || DEFAULT_COLUMN_WIDTH), - 0 - ); - - return ( - - {children} - - ); -} - -export function useColumnWidths() { - return useContext(WidthsContext); -}