fix(table): improve conditional formatting text contrast (#38705)

This commit is contained in:
João Pedro Alves Barbosa
2026-03-22 18:59:15 -03:00
committed by GitHub
parent 361afff798
commit 02ffb52f4a
14 changed files with 1698 additions and 82 deletions

View File

@@ -16,21 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
import { Children, isValidElement, type ReactElement } from 'react';
import {
getTextColorForBackground,
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import {
getCellColor,
TableRenderer,
} from '../../src/react-pivottable/TableRenderers';
import type { PivotData } from '../../src/react-pivottable/utilities';
let tableRenderer: TableRenderer;
let mockGetAggregatedData: jest.Mock;
let mockSortAndCacheData: jest.Mock;
type RenderColHeaderRowSettings = Parameters<
TableRenderer['renderColHeaderRow']
>[2];
type RenderTableRowSettings = Parameters<TableRenderer['renderTableRow']>[2];
type TableRendererStateStub = TableRenderer['state'];
type CachedBasePivotSettings = NonNullable<
TableRenderer['cachedBasePivotSettings']
>;
const columnIndex = 0;
const visibleColKeys = [['col1'], ['col2']];
const pivotData = {
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
},
} as any;
const maxRowIndex = 2;
const mockProps = {
@@ -56,6 +67,97 @@ const mockProps = {
colPartialOnTop: false,
};
const toPivotData = (value: Partial<PivotData>): PivotData =>
value as unknown as PivotData;
const toCachedBasePivotSettings = (
value: Partial<CachedBasePivotSettings>,
): CachedBasePivotSettings => value as unknown as CachedBasePivotSettings;
const createActiveHeaderTableOptions = (
activeHeaderBackgroundColor: string,
overrides: Record<string, unknown> = {},
): TableRenderer['props']['tableOptions'] =>
({
...overrides,
activeHeaderBackgroundColor,
}) as unknown as TableRenderer['props']['tableOptions'];
const createPivotDataStub = (
aggregatorValue = 200,
isSubtotal = false,
): PivotData =>
toPivotData({
getAggregator: () => ({
push: jest.fn(),
value: () => aggregatorValue,
format: (value: number) => String(value),
isSubtotal,
}),
});
const pivotData = toPivotData({
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
},
});
const createColHeaderRowSettings = (
overrides: Partial<RenderColHeaderRowSettings> = {},
): RenderColHeaderRowSettings =>
({
rowAttrs: [],
colAttrs: ['region'],
visibleColKeys: [['EMEA']],
colAttrSpans: [[1]],
colKeys: [['EMEA']],
colSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
rowSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
maxColVisible: 1,
maxRowVisible: 0,
pivotData: createPivotDataStub(),
namesMapping: {},
allowRenderHtml: false,
arrowExpanded: null,
arrowCollapsed: null,
rowTotals: false,
colTotals: false,
...overrides,
}) as RenderColHeaderRowSettings;
const createTableRowSettings = (
overrides: Partial<RenderTableRowSettings> = {},
): RenderTableRowSettings =>
({
rowAttrs: ['metric'],
colAttrs: [],
rowAttrSpans: [[1]],
visibleColKeys: [[]],
pivotData: createPivotDataStub(),
rowTotals: false,
rowSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
arrowExpanded: null,
arrowCollapsed: null,
cellCallbacks: {},
rowTotalCallbacks: {},
namesMapping: {},
allowRenderHtml: false,
...overrides,
}) as RenderTableRowSettings;
beforeEach(() => {
tableRenderer = new TableRenderer(mockProps);
@@ -65,24 +167,24 @@ beforeEach(() => {
tableRenderer.getAggregatedData = mockGetAggregatedData;
tableRenderer.sortAndCacheData = mockSortAndCacheData;
tableRenderer.cachedBasePivotSettings = {
pivotData: {
tableRenderer.cachedBasePivotSettings = toCachedBasePivotSettings({
pivotData: toPivotData({
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
colEnabled: false,
colPartialOnTop: false,
},
},
}),
rowKeys: [['A'], ['B'], ['C']],
} as any;
});
tableRenderer.state = {
sortingOrder: [],
activeSortColumn: null,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
});
const mockGroups = {
@@ -103,13 +205,15 @@ const mockGroups = {
},
};
const createMockPivotData = (rowData: Record<string, number>) =>
({
const createMockPivotData = (rowData: Record<string, number>): PivotData =>
toPivotData({
rowKeys: Object.keys(rowData).map(key => key.split('.')),
getAggregator: (rowKey: string[], colName: string) => ({
getAggregator: (rowKey: string[]) => ({
push: jest.fn(),
value: () => rowData[rowKey.join('.')],
format: (value: number) => String(value),
}),
}) as unknown as PivotData;
});
test('should set initial ascending sort when no active sort column', () => {
mockGetAggregatedData.mockReturnValue({
@@ -211,15 +315,15 @@ test('should check second call in sequence', () => {
activeSortColumn: 0,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
tableRenderer.state = {
sortingOrder: ['asc' as never],
activeSortColumn: 0 as any,
activeSortColumn: 0,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
expect(mockSortAndCacheData).toHaveBeenCalledTimes(2);
@@ -324,22 +428,23 @@ test('should sort hierarchical data in ascending order', () => {
test('should calculate groups from pivot data', () => {
tableRenderer = new TableRenderer(mockProps);
const mockAggregator = (value: number) => ({
push: jest.fn(),
value: () => value,
format: jest.fn(),
isSubtotal: false,
});
const mockPivotData = {
const mockPivotData = toPivotData({
rowKeys: [['A'], ['B'], ['C']],
getAggregator: jest
.fn()
.mockReturnValueOnce(mockAggregator(30))
.mockReturnValueOnce(mockAggregator(10))
.mockReturnValueOnce(mockAggregator(20)),
};
});
const result = tableRenderer.getAggregatedData(
mockPivotData as any,
mockPivotData,
['col1'],
false,
);
@@ -589,3 +694,359 @@ test('values from the 3rd level of the hierarchy with a subtotal at the to
},
});
});
test('getCellColor derives readable text from the winning background', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('getCellColor keeps explicit text color over adaptive contrast', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#ace1c40d' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(172, 225, 196)',
});
});
test('getCellColor treats legacy toTextColor formatters as text color', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
{
column: 'revenue',
toTextColor: true,
getColorFromValue: (value: unknown) =>
value === 200 ? '#ace1c40d' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(172, 225, 196)',
});
});
test('getCellColor ignores cell-bar rules when resolving text color', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.CELL_BAR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#11111199' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow keeps subtotal background and readable text in sync', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings({
pivotData: createPivotDataStub(200, true),
}),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const valueCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtVal',
) as ReactElement;
expect(valueCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
fontWeight: 'bold',
});
});
test('renderColAttrsHeader applies readable text color to formatted headers', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'region',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'EMEA' ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtColLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('renderColAttrsHeader uses active header surface for adaptive contrast', () => {
const activeHeaderBackgroundColor = '#102a43';
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, {
highlightedHeaderCells: {
region: ['EMEA'],
},
cellColorFormatters: {
metric: [
{
column: 'region',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'EMEA' ? 'rgba(0, 0, 0, 0.4)' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
}),
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child =>
isValidElement(child) && child.props.className === 'pvtColLabel active',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0.4)',
color: getTextColorForBackground(
{ backgroundColor: 'rgba(0, 0, 0, 0.4)' },
activeHeaderBackgroundColor,
),
});
});
test('renderColHeaderRow preserves default header text color without formatting', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: { metric: [] },
cellBackgroundColor: '#ffffff',
cellTextColor: '#ff00aa',
},
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtColLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow preserves default row-header text color without formatting', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: { metric: [] },
cellBackgroundColor: '#ffffff',
cellTextColor: '#ff00aa',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtRowLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow applies readable text color to formatted row headers', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'metric',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'revenue' ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtRowLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('renderTableRow uses active header surface for adaptive contrast', () => {
const activeHeaderBackgroundColor = '#102a43';
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, {
highlightedHeaderCells: {
metric: ['revenue'],
},
cellColorFormatters: {
metric: [
{
column: 'metric',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'revenue' ? 'rgba(0, 0, 0, 0.4)' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
}),
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child =>
isValidElement(child) && child.props.className === 'pvtRowLabel active',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0.4)',
color: getTextColorForBackground(
{ backgroundColor: 'rgba(0, 0, 0, 0.4)' },
activeHeaderBackgroundColor,
),
});
});