Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions packages/react-table/src/components/Table/Th.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -109,7 +110,7 @@ const ThBase: React.FunctionComponent<ThProps> = ({

const [showTooltip, setShowTooltip] = useState(false);
const [truncated, setTruncated] = useState(false);
const cellRef = innerRef ? innerRef : createRef();
const cellRef = useRef<HTMLElement>(null);
const onMouseEnter = (event: any) => {
if (event.target.offsetWidth < event.target.scrollWidth) {
!showTooltip && setShowTooltip(true);
Expand Down Expand Up @@ -202,12 +203,33 @@ const ThBase: React.FunctionComponent<ThProps> = ({
...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<HTMLElement | null>).current.offsetWidth <
(cellRef as React.RefObject<HTMLElement | null>).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 = (
<MergedComponent
Expand Down
154 changes: 153 additions & 1 deletion packages/react-table/src/components/Table/__tests__/Th.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { render, screen } from '@testing-library/react';
import { createRef, useState } from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Th } from '../Th';

test('Does not render with aria-label by default', () => {
Expand Down Expand Up @@ -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(
<Th data-testid="header">
<span>Heading</span>
</Th>
);
expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0');
offsetWidth.mockClear();
scrollWidth.mockClear();

rerender(
<Th data-testid="updated-header">
<span>Heading</span>
</Th>
);

expect(offsetWidth).not.toHaveBeenCalled();
expect(scrollWidth).not.toHaveBeenCalled();
});

test('updates keyboard focusability when the label changes', async () => {
const { rerender } = render(<Th>Long heading</Th>);
expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0');

scrollWidth.mockReturnValue(100);
rerender(<Th>Short</Th>);
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 <button onClick={() => setLabel('Short')}>{label}</button>;
};
render(
<Th>
<Heading />
</Th>
);
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(<Th>Heading</Th>);
expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '-1');

scrollWidth.mockReturnValue(200);
rerender(<Th info={{ tooltip: 'More information' }}>Heading</Th>);

await waitFor(() => expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0'));
});

test('updates keyboard focusability when the rendered layout changes', async () => {
const { rerender } = render(<Th modifier="nowrap">Heading</Th>);
expect(screen.getByRole('columnheader')).toHaveAttribute('tabindex', '0');

scrollWidth.mockReturnValue(100);
rerender(<Th modifier="wrap">Heading</Th>);

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(<Th ref={callbackRef}>Heading</Th>);
const header = screen.getByRole('columnheader');
expect(callbackRef).toHaveBeenCalledTimes(1);
expect(callbackRef).toHaveBeenLastCalledWith(header);
callbackRef.mockClear();

rerender(
<Th ref={callbackRef} data-testid="updated-header">
Heading
</Th>
);
expect(callbackRef).not.toHaveBeenCalled();

rerender(
<Th ref={callbackRef} component="td">
Heading
</Th>
);
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(<Th>Heading</Th>);
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<HTMLTableHeaderCellElement>();
const callbackRef = jest.fn();
const { rerender, unmount } = render(<Th ref={objectRef}>Heading</Th>);
expect(objectRef.current).toBe(screen.getByRole('columnheader'));
rerender(<Th ref={callbackRef}>Heading</Th>);
expect(objectRef.current).toBeNull();
expect(callbackRef).toHaveBeenCalledWith(screen.getByRole('columnheader'));
unmount();
expect(callbackRef).toHaveBeenLastCalledWith(null);
});
});