diff --git a/src/anchor-navigation/use-scroll-spy.tsx b/src/anchor-navigation/use-scroll-spy.tsx index b129138e9b..8a432f46d1 100644 --- a/src/anchor-navigation/use-scroll-spy.tsx +++ b/src/anchor-navigation/use-scroll-spy.tsx @@ -37,7 +37,7 @@ export default function useScrollSpy({ }, [hrefs]); // Get the bounding rectangle of an element by href - const getRectByHref = useCallback(href => { + const getRectByHref = useCallback((href: string) => { return document.getElementById(href.slice(1))?.getBoundingClientRect(); }, []); diff --git a/src/table/__integ__/resizable-columns.test.ts b/src/table/__integ__/resizable-columns.test.ts index 2b00cb67a3..40ba2927f2 100644 --- a/src/table/__integ__/resizable-columns.test.ts +++ b/src/table/__integ__/resizable-columns.test.ts @@ -60,6 +60,20 @@ class TablePage extends BasePageObject { return element.getAttribute('style'); } + getFirstTableHeaderWidths() { + return this.browser.execute(() => { + const tables = document.querySelectorAll('table'); + return Array.from(tables[0].querySelectorAll('th')).map(el => el.offsetWidth); + }); + } + + getLastTableHeaderWidths() { + return this.browser.execute(() => { + const tables = document.querySelectorAll('table'); + return Array.from(tables[tables.length - 1].querySelectorAll('th')).map(el => el.offsetWidth); + }); + } + async getColumnMinWidth(columnIndex: number) { const columnSelector = tableWrapper // use internal CSS-selector to always receive the real table header and not a sticky copy @@ -177,13 +191,13 @@ describe.each([true, false])('StickyHeader=%s', sticky => { }) ); - test( - 'should render "width: auto" for the last on big screens and explicit value on small', + test.each([1680, 620])('sticky and real column headers must have identical widths for screen width %s', width => setupStickyTest(async page => { - await expect(page.getColumnStyle(4)).resolves.toContain('width: auto;'); - await page.setWindowSize({ ...defaultScreen, width: 620 }); - await expect(page.getColumnStyle(4)).resolves.toContain('width: 120px;'); - }) + await page.setWindowSize({ ...defaultScreen, width }); + const stickyHeaderWidths = await page.getFirstTableHeaderWidths(); + const realHeaderWidths = await page.getLastTableHeaderWidths(); + expect(stickyHeaderWidths).toEqual(realHeaderWidths); + })() ); // The page width of 620px is an empirical value defined for the respective test page in VR diff --git a/src/table/__tests__/columns-width.test.tsx b/src/table/__tests__/columns-width.test.tsx index 0c85c3c0fd..8d746fb4a8 100644 --- a/src/table/__tests__/columns-width.test.tsx +++ b/src/table/__tests__/columns-width.test.tsx @@ -212,11 +212,10 @@ describe('with stickyHeader=true', () => { { minWidth: '100px', width: '', maxWidth: '' }, { minWidth: '', width: '', maxWidth: '300px' }, ]); - // in JSDOM, there is no layout, so copied width is "0px" and no value is "" expect(extractSize(fakeHeader)).toEqual([ - { minWidth: '', width: '0px', maxWidth: '' }, - { minWidth: '', width: '0px', maxWidth: '' }, - { minWidth: '', width: '0px', maxWidth: '' }, + { minWidth: '', width: '200px', maxWidth: '' }, + { minWidth: '', width: '', maxWidth: '' }, + { minWidth: '', width: '', maxWidth: '' }, ]); }); }); diff --git a/src/table/__tests__/resizable-columns.test.tsx b/src/table/__tests__/resizable-columns.test.tsx index 24c80df0d6..79ebf30bd2 100644 --- a/src/table/__tests__/resizable-columns.test.tsx +++ b/src/table/__tests__/resizable-columns.test.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import * as React from 'react'; import times from 'lodash/times'; +import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; import { render, screen } from '@testing-library/react'; import createWrapper, { TableWrapper } from '../../../lib/components/test-utils/dom'; import Table, { TableProps } from '../../../lib/components/table'; @@ -19,6 +20,11 @@ jest.mock('../../../lib/components/internal/utils/scrollable-containers', () => }), })); +jest.mock('@cloudscape-design/component-toolkit/internal', () => ({ + ...jest.requireActual('@cloudscape-design/component-toolkit/internal'), + useResizeObserver: jest.fn().mockImplementation((_target, cb) => cb({ contentBoxWidth: 0 })), +})); + interface Item { id: number; description: string; @@ -431,3 +437,14 @@ describe('column header content', () => { expect(getResizeHandle(1)).toHaveAttribute('aria-roledescription', 'resize button'); }); }); + +test('should set last column width to "auto" when container width exceeds total column width', () => { + const totalColumnsWidth = 150 + 300; + jest + .mocked(useResizeObserver) + .mockImplementation((_target, cb) => cb({ contentBoxWidth: totalColumnsWidth + 1 } as any)); + + const { wrapper } = renderTable(); + + expect(wrapper.findColumnHeaders().map(w => w.getElement().style.width)).toEqual(['150px', 'auto']); +}); diff --git a/src/table/column-widths-utils.ts b/src/table/column-widths-utils.ts index 5149c47809..87a5ebc44d 100644 --- a/src/table/column-widths-utils.ts +++ b/src/table/column-widths-utils.ts @@ -11,6 +11,25 @@ export function checkColumnWidths(columnDefinitions: ReadonlyArray, name: 'width' | 'minWidth') { const value = column[name]; if (typeof value !== 'number' && typeof value !== 'undefined') { diff --git a/src/table/internal.tsx b/src/table/internal.tsx index ba8644382b..3a7d192aa8 100644 --- a/src/table/internal.tsx +++ b/src/table/internal.tsx @@ -114,7 +114,8 @@ const InternalTable = React.forwardRef( const isMobile = useMobile(); const [containerWidth, wrapperMeasureRef] = useContainerQuery(rect => rect.contentBoxWidth); - const wrapperRefObject = useRef(null); + const wrapperMeasureRefObject = useRef(null); + const wrapperMeasureMergedRef = useMergeRefs(wrapperMeasureRef, wrapperMeasureRefObject); const [tableWidth, tableMeasureRef] = useContainerQuery(rect => rect.contentBoxWidth); const tableRefObject = useRef(null); @@ -134,6 +135,7 @@ const InternalTable = React.forwardRef( [cancelEdit] ); + const wrapperRefObject = useRef(null); const handleScroll = useScrollSync([wrapperRefObject, scrollbarRef, secondaryWrapperRef]); const { moveFocusDown, moveFocusUp, moveFocus } = useSelectionFocusMove(selectionType, items.length); @@ -205,7 +207,6 @@ const InternalTable = React.forwardRef( const tableRole = hasEditableCells ? 'grid-default' : 'table'; const theadProps: TheadProps = { - containerWidth, selectionType, getSelectAllProps, columnDefinitions: visibleColumnDefinitions, @@ -257,7 +258,11 @@ const InternalTable = React.forwardRef( return ( - + -
+
{!!renderAriaLive && !!firstIndex && ( diff --git a/src/table/sticky-columns/use-sticky-columns.ts b/src/table/sticky-columns/use-sticky-columns.ts index a220a82d73..cf3d558040 100644 --- a/src/table/sticky-columns/use-sticky-columns.ts +++ b/src/table/sticky-columns/use-sticky-columns.ts @@ -156,7 +156,7 @@ export function useStickyCellStyles({ // refCallback updates the cell ref and sets up the store subscription const refCallback = useCallback( - cellElement => { + (cellElement: null | HTMLElement) => { if (unsubscribeRef.current) { // Unsubscribe before we do any updates to avoid leaving any subscriptions hanging unsubscribeRef.current(); diff --git a/src/table/thead.tsx b/src/table/thead.tsx index 9e7b7321af..dd627c56d0 100644 --- a/src/table/thead.tsx +++ b/src/table/thead.tsx @@ -18,7 +18,6 @@ import { TableThElement } from './header-cell/th-element'; import { findUpUntil } from '@cloudscape-design/component-toolkit/dom'; export interface TheadProps { - containerWidth: null | number; selectionType: TableProps.SelectionType | undefined; columnDefinitions: ReadonlyArray>; sortingColumn: TableProps.SortingColumn | undefined; @@ -47,7 +46,6 @@ export interface TheadProps { const Thead = React.forwardRef( ( { - containerWidth, selectionType, getSelectAllProps, columnDefinitions, @@ -91,7 +89,7 @@ const Thead = React.forwardRef( isVisualRefresh && styles['is-visual-refresh'] ); - const { columnWidths, totalWidth, updateColumn, setCell } = useColumnWidths(); + const { getColumnStyles, columnWidths, updateColumn, setCell } = useColumnWidths(); return (
@@ -133,27 +131,11 @@ const Thead = React.forwardRef( {columnDefinitions.map((column, colIndex) => { const columnId = getColumnKey(column, colIndex); - - let widthOverride; - if (resizableColumns) { - if (columnWidths) { - // use stateful value if available - widthOverride = columnWidths[columnId]; - } - if (colIndex === columnDefinitions.length - 1 && containerWidth && containerWidth > totalWidth) { - // let the last column grow and fill the container width - widthOverride = 'auto'; - } - } return ( fireNonCancelableEvent(onSortingChange, detail)} isEditable={!!column.editConfig} stickyState={stickyState} - cellRef={node => setCell(columnId, node)} + cellRef={node => setCell(sticky, columnId, node)} tableRole={tableRole} resizerRoleDescription={resizerRoleDescription} /> diff --git a/src/table/use-column-widths.tsx b/src/table/use-column-widths.tsx index b79760fb53..e25063fdae 100644 --- a/src/table/use-column-widths.tsx +++ b/src/table/use-column-widths.tsx @@ -1,12 +1,15 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { useResizeObserver, useStableCallback } from '@cloudscape-design/component-toolkit/internal'; import React, { useEffect, useRef, useState, createContext, useContext } from 'react'; +import { setElementWidths } from './column-widths-utils'; export const DEFAULT_COLUMN_WIDTH = 120; export interface ColumnWidthDefinition { id: PropertyKey; minWidth?: string | number; + maxWidth?: string | number; width?: string | number; } @@ -47,14 +50,14 @@ function updateWidths( } interface WidthsContext { - totalWidth: number; + getColumnStyles(sticky: boolean, columnId: PropertyKey): React.CSSProperties; columnWidths: Record; updateColumn: (columnId: PropertyKey, newWidth: number) => void; - setCell: (columnId: PropertyKey, node: null | HTMLElement) => void; + setCell: (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => void; } const WidthsContext = createContext({ - totalWidth: 0, + getColumnStyles: () => ({}), columnWidths: {}, updateColumn: () => {}, setCell: () => {}, @@ -63,26 +66,76 @@ const WidthsContext = createContext({ interface WidthProviderProps { visibleColumns: readonly ColumnWidthDefinition[]; resizableColumns: boolean | undefined; + containerRef: React.RefObject; children: React.ReactNode; } -export function ColumnWidthsProvider({ visibleColumns, resizableColumns, children }: WidthProviderProps) { - const visibleColumnsRef = useRef<(PropertyKey | undefined)[] | null>(null); - const [columnWidths, setColumnWidths] = useState>({}); +export function ColumnWidthsProvider({ visibleColumns, resizableColumns, containerRef, children }: WidthProviderProps) { + const visibleColumnsRef = useRef(null); + const containerWidthRef = useRef(0); + const [columnWidths, setColumnWidths] = useState>(null); const cellsRef = useRef>({}); + const stickyCellsRef = useRef>({}); const getCell = (columnId: PropertyKey): null | HTMLElement => cellsRef.current[columnId] ?? null; - const setCell = (columnId: PropertyKey, node: null | HTMLElement) => { + const setCell = (sticky: boolean, columnId: PropertyKey, node: null | HTMLElement) => { + const ref = sticky ? stickyCellsRef : cellsRef; if (node) { - cellsRef.current[columnId] = node; + ref.current[columnId] = node; } else { - delete cellsRef.current[columnId]; + delete ref.current[columnId]; } }; + const getColumnStyles = (sticky: boolean, columnId: PropertyKey): React.CSSProperties => { + const column = visibleColumns.find(column => column.id === columnId); + if (!column) { + return {}; + } + + if (sticky) { + return { width: cellsRef.current[column.id]?.offsetWidth || (columnWidths?.[column.id] ?? column.width) }; + } + + if (resizableColumns && columnWidths) { + const isLastColumn = column.id === visibleColumns[visibleColumns.length - 1]?.id; + const totalWidth = visibleColumns.reduce((sum, { id }) => sum + (columnWidths[id] || DEFAULT_COLUMN_WIDTH), 0); + if (isLastColumn && containerWidthRef.current > totalWidth) { + return { width: 'auto', minWidth: column?.minWidth }; + } else { + return { width: columnWidths[column.id], minWidth: column?.minWidth }; + } + } + return { + width: column.width, + minWidth: column.minWidth, + maxWidth: !resizableColumns ? column.maxWidth : undefined, + }; + }; + + // Imperatively sets width style for a cell avoiding React state. + // This allows setting the style as soon container's size change is observed. + const updateColumnWidths = useStableCallback(() => { + for (const column of visibleColumns) { + setElementWidths(cellsRef.current[column.id], getColumnStyles(false, column.id)); + } + // Sticky column widths must be synchronized once all real column widths are assigned. + for (const id of Object.keys(stickyCellsRef.current)) { + setElementWidths(stickyCellsRef.current[id], getColumnStyles(true, id)); + } + }); + + // Observes container size and requests an update to the last cell width as it depends on the container's width. + useResizeObserver(containerRef, ({ contentBoxWidth: containerWidth }) => { + containerWidthRef.current = containerWidth; + updateColumnWidths(); + }); + // The widths of the dynamically added columns (after the first render) if not set explicitly // will default to the DEFAULT_COLUMN_WIDTH. useEffect(() => { + updateColumnWidths(); + if (!resizableColumns) { return; } @@ -91,7 +144,7 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, childre if (lastVisible) { for (let index = 0; index < visibleColumns.length; index++) { const column = visibleColumns[index]; - if (!columnWidths[column.id] && lastVisible.indexOf(column.id) === -1) { + if (!columnWidths?.[column.id] && lastVisible.indexOf(column.id) === -1) { updates[column.id] = (column.width as number) || DEFAULT_COLUMN_WIDTH; } } @@ -100,7 +153,7 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, childre } } visibleColumnsRef.current = visibleColumns.map(column => column.id); - }, [columnWidths, resizableColumns, visibleColumns]); + }, [columnWidths, resizableColumns, visibleColumns, updateColumnWidths]); // Read the actual column widths after the first render to employ the browser defaults for // those columns without explicit width. @@ -114,16 +167,11 @@ export function ColumnWidthsProvider({ visibleColumns, resizableColumns, childre }, []); function updateColumn(columnId: PropertyKey, newWidth: number) { - setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths, newWidth, columnId)); + setColumnWidths(columnWidths => updateWidths(visibleColumns, columnWidths ?? {}, newWidth, columnId)); } - const totalWidth = visibleColumns.reduce( - (total, column) => total + (columnWidths[column.id] || DEFAULT_COLUMN_WIDTH), - 0 - ); - return ( - + {children} ); diff --git a/src/table/use-sticky-header.ts b/src/table/use-sticky-header.ts index 48bb59d5d5..bafa77fc75 100644 --- a/src/table/use-sticky-header.ts +++ b/src/table/use-sticky-header.ts @@ -5,19 +5,6 @@ import stickyScrolling, { calculateScrollingOffset, scrollUpBy } from './sticky- import { useMobile } from '../internal/hooks/use-mobile'; import { useResizeObserver } from '@cloudscape-design/component-toolkit/internal'; -function syncSizes(from: HTMLElement, to: HTMLElement) { - const fromCells = Array.prototype.slice.apply(from.children); - const toCells = Array.prototype.slice.apply(to.children); - for (let i = 0; i < fromCells.length; i++) { - let width = fromCells[i].style.width; - // use auto if it is set by resizable columns or real size otherwise - if (width !== 'auto') { - width = `${fromCells[i].offsetWidth}px`; - } - toCells[i].style.width = width; - } -} - export const useStickyHeader = ( tableRef: RefObject, theadRef: RefObject, @@ -35,8 +22,6 @@ export const useStickyHeader = ( secondaryTableRef.current && tableWrapperRef.current ) { - syncSizes(theadRef.current, secondaryTheadRef.current); - // Using the tableRef offsetWidth instead of the theadRef because in VR // the tableRef adds extra padding to the table and by default the theadRef will have a width // without the padding and will make the sticky header width incorrect.