diff --git a/packages/react-table/src/components/Table/Th.tsx b/packages/react-table/src/components/Table/Th.tsx index 8bbfb788117..adf013579c5 100644 --- a/packages/react-table/src/components/Table/Th.tsx +++ b/packages/react-table/src/components/Table/Th.tsx @@ -1,4 +1,4 @@ -import { createRef, forwardRef, useEffect, useState, useContext } from 'react'; +import { forwardRef, useEffect, useState, useContext, useRef, useImperativeHandle } from 'react'; import { css } from '@patternfly/react-styles'; import styles from '@patternfly/react-styles/css/components/Table/table'; import scrollStyles from '@patternfly/react-styles/css/components/Table/table-scrollable'; @@ -8,6 +8,7 @@ import { ThInfoType, ThSelectType, ThExpandType, ThSortType, formatterValueType import { mergeProps } from './base/merge-props'; import { IVisibility } from './utils/decorators/classNames'; import { Tooltip, TooltipProps } from '@patternfly/react-core/dist/esm/components/Tooltip'; +import { getResizeObserver } from '@patternfly/react-core/dist/esm/helpers/resizeObserver'; import { BaseCellProps, TableContext } from './Table'; import { IFormatterValueType, IColumn } from './TableTypes'; import cssStickyCellMinWidth from '@patternfly/react-tokens/dist/esm/c_table__sticky_cell_MinWidth'; @@ -109,7 +110,7 @@ const ThBase: React.FunctionComponent = ({ const [showTooltip, setShowTooltip] = useState(false); const [truncated, setTruncated] = useState(false); - const cellRef = innerRef ? innerRef : createRef(); + const cellRef = useRef(null); const onMouseEnter = (event: any) => { if (event.target.offsetWidth < event.target.scrollWidth) { !showTooltip && setShowTooltip(true); @@ -202,12 +203,33 @@ const ThBase: React.FunctionComponent = ({ ...mergedProps } = merged; + // Reconnect the forwarded ref only when React replaces the header element. + // eslint-disable-next-line react-hooks/exhaustive-deps + useImperativeHandle(innerRef, () => cellRef.current, [MergedComponent]); + useEffect(() => { - setTruncated( - (cellRef as React.RefObject).current.offsetWidth < - (cellRef as React.RefObject).current.scrollWidth - ); - }, [cellRef]); + const cell = cellRef.current; + if (!cell) { + return; + } + + const updateTruncated = () => setTruncated(cell.offsetWidth < cell.scrollWidth); + updateTruncated(); + const unobserveResize = getResizeObserver(cell, updateTruncated, true); + // Content can overflow a fixed-width cell without changing its observed size. + const contentObserver = new MutationObserver(updateTruncated); + contentObserver.observe(cell, { + childList: true, + characterData: true, + subtree: true, + attributes: true, + attributeFilter: ['class', 'style'] + }); + return () => { + unobserveResize(); + contentObserver.disconnect(); + }; + }, [MergedComponent]); const cell = ( { @@ -77,3 +78,154 @@ test('Renders checked checkbox when isSelected is true and isIndeterminate is fa expect(checkbox).toBeChecked(); expect(checkbox.indeterminate).toBe(false); }); + +describe('truncated headers', () => { + let offsetWidth: jest.SpyInstance; + let scrollWidth: jest.SpyInstance; + + beforeEach(() => { + offsetWidth = jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(100); + scrollWidth = jest.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(200); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('does not remeasure unchanged JSX when unrelated props change', () => { + const { rerender } = render( + + Heading + + ); + expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0'); + offsetWidth.mockClear(); + scrollWidth.mockClear(); + + rerender( + + Heading + + ); + + expect(offsetWidth).not.toHaveBeenCalled(); + expect(scrollWidth).not.toHaveBeenCalled(); + }); + + test('updates keyboard focusability when the label changes', async () => { + const { rerender } = render(Long heading); + expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0'); + + scrollWidth.mockReturnValue(100); + rerender(Short); + await waitFor(() => expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '-1')); + }); + + test('updates keyboard focusability when a child changes its own content', async () => { + const Heading = () => { + const [label, setLabel] = useState('Long heading'); + return ; + }; + render( + + + + ); + expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0'); + + scrollWidth.mockReturnValue(100); + fireEvent.click(screen.getByRole('button')); + + await waitFor(() => expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '-1')); + }); + + test('updates keyboard focusability when an info control is added', async () => { + scrollWidth.mockReturnValue(100); + const { rerender } = render(Heading); + expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '-1'); + + scrollWidth.mockReturnValue(200); + rerender(Heading); + + await waitFor(() => expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0')); + }); + + test('updates keyboard focusability when the rendered layout changes', async () => { + const { rerender } = render(Heading); + expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0'); + + scrollWidth.mockReturnValue(100); + rerender(Heading); + + await waitFor(() => expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '-1')); + }); + + test('keeps the same callback ref attached until the header element changes', () => { + const callbackRef = jest.fn(); + const { rerender, unmount } = render(Heading); + const header = screen.getByRole('columnheader'); + expect(callbackRef).toHaveBeenCalledTimes(1); + expect(callbackRef).toHaveBeenLastCalledWith(header); + callbackRef.mockClear(); + + rerender( + + Heading + + ); + expect(callbackRef).not.toHaveBeenCalled(); + + rerender( + + Heading + + ); + expect(callbackRef).toHaveBeenNthCalledWith(1, null); + expect(callbackRef).toHaveBeenNthCalledWith(2, screen.getByRole('cell')); + unmount(); + expect(callbackRef).toHaveBeenLastCalledWith(null); + }); + + test('updates keyboard focusability when the cell is resized and cleans up the observer', () => { + const disconnectContent = jest.spyOn(MutationObserver.prototype, 'disconnect'); + let onResize: ResizeObserverCallback; + const observe = jest.fn(); + const unobserve = jest.fn(); + const previousObserver = window.ResizeObserver; + window.ResizeObserver = jest.fn().mockImplementation((callback) => { + onResize = callback; + return { observe, unobserve }; + }); + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + callback(0); + return 0; + }); + + try { + const { unmount } = render(Heading); + const header = screen.getByRole('columnheader'); + expect(observe).toHaveBeenCalledWith(header); + expect(header).toHaveAttribute('tabindex', '0'); + offsetWidth.mockReturnValue(300); + act(() => onResize([{ target: header } as ResizeObserverEntry], {} as ResizeObserver)); + expect(header).toHaveAttribute('tabindex', '-1'); + unmount(); + expect(unobserve).toHaveBeenCalledWith(header); + expect(disconnectContent).toHaveBeenCalled(); + } finally { + window.ResizeObserver = previousObserver; + } + }); + + test('forwards object and callback refs to the header', () => { + const objectRef = createRef(); + const callbackRef = jest.fn(); + const { rerender, unmount } = render(Heading); + expect(objectRef.current).toBe(screen.getByRole('columnheader')); + rerender(Heading); + expect(objectRef.current).toBeNull(); + expect(callbackRef).toHaveBeenCalledWith(screen.getByRole('columnheader')); + unmount(); + expect(callbackRef).toHaveBeenLastCalledWith(null); + }); +});