From 86d48a5d9a7f6c264912dccc533848f0804860b0 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 11 Sep 2024 11:29:06 -0700 Subject: [PATCH 01/13] [setup] Create new `copy/` dir in services and move existing `copy_to_clipboard` there --- .../eui/src/services/{ => copy}/copy_to_clipboard.ts | 0 packages/eui/src/services/copy/index.ts | 9 +++++++++ packages/eui/src/services/index.ts | 2 +- 3 files changed, 10 insertions(+), 1 deletion(-) rename packages/eui/src/services/{ => copy}/copy_to_clipboard.ts (100%) create mode 100644 packages/eui/src/services/copy/index.ts diff --git a/packages/eui/src/services/copy_to_clipboard.ts b/packages/eui/src/services/copy/copy_to_clipboard.ts similarity index 100% rename from packages/eui/src/services/copy_to_clipboard.ts rename to packages/eui/src/services/copy/copy_to_clipboard.ts diff --git a/packages/eui/src/services/copy/index.ts b/packages/eui/src/services/copy/index.ts new file mode 100644 index 00000000000..e5277aaafbd --- /dev/null +++ b/packages/eui/src/services/copy/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { copyToClipboard } from './copy_to_clipboard'; diff --git a/packages/eui/src/services/index.ts b/packages/eui/src/services/index.ts index a8501828e8c..20a9091c7f5 100644 --- a/packages/eui/src/services/index.ts +++ b/packages/eui/src/services/index.ts @@ -66,7 +66,7 @@ export type { HSV } from './color'; export { useColorPickerState, useColorStopsState } from './color_picker'; export type { EuiSetColorMethod } from './color_picker'; export * from './console'; -export { copyToClipboard } from './copy_to_clipboard'; +export * from './copy'; export * from './emotion'; export * from './findElement'; export { From dc8759164e14aac0bc3b5ed4e843e757d533dab5 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 12 Sep 2024 09:40:25 -0700 Subject: [PATCH 02/13] Create copy service --- packages/eui/src/services/copy/index.ts | 4 + .../src/services/copy/tabular_copy.test.tsx | 91 +++++++++++++++ .../services/copy/tabular_copy.testenv.tsx | 21 ++++ .../eui/src/services/copy/tabular_copy.tsx | 108 ++++++++++++++++++ 4 files changed, 224 insertions(+) create mode 100644 packages/eui/src/services/copy/tabular_copy.test.tsx create mode 100644 packages/eui/src/services/copy/tabular_copy.testenv.tsx create mode 100644 packages/eui/src/services/copy/tabular_copy.tsx diff --git a/packages/eui/src/services/copy/index.ts b/packages/eui/src/services/copy/index.ts index e5277aaafbd..257fdb12e4c 100644 --- a/packages/eui/src/services/copy/index.ts +++ b/packages/eui/src/services/copy/index.ts @@ -7,3 +7,7 @@ */ export { copyToClipboard } from './copy_to_clipboard'; +export { + tabularCopyMarkers, + OverrideCopiedTabularContent, +} from './tabular_copy'; diff --git a/packages/eui/src/services/copy/tabular_copy.test.tsx b/packages/eui/src/services/copy/tabular_copy.test.tsx new file mode 100644 index 00000000000..1914d2c669e --- /dev/null +++ b/packages/eui/src/services/copy/tabular_copy.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { onTabularCopy, CHARS } from './tabular_copy'; + +describe('onTabularCopy', () => { + const mockSetData = jest.fn(); + const mockEvent = { + clipboardData: { setData: mockSetData }, + preventDefault: () => {}, + } as unknown as ClipboardEvent; + + const mockSelectedText = jest.fn(() => ''); + Object.defineProperty(window, 'getSelection', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + toString: mockSelectedText, + })), + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedText.mockReturnValue(''); + }); + + it('does nothing if no special copy characters are in the clipboard', () => { + mockSelectedText.mockReturnValue('hello\nworld\t'); + onTabularCopy(mockEvent); + expect(mockSetData).not.toHaveBeenCalled(); + }); + + it('strips all newlines and replaces the newline character with our newlines', () => { + mockSelectedText.mockReturnValue('hello\nworld\r\nand↡goodbye world'); + onTabularCopy(mockEvent); + expect(mockSetData).toHaveBeenCalledWith( + 'text/plain', + 'helloworldand\ngoodbye world' + ); + }); + + it('strips all horizontal tabs and replaces the tab character with our tabs', () => { + mockSelectedText.mockReturnValue('hello\tworld↦goodbye\tworld'); + onTabularCopy(mockEvent); + expect(mockSetData).toHaveBeenCalledWith( + 'text/plain', + 'helloworld\tgoodbyeworld' + ); + }); + + it('strips out any text between the no-copy characters', () => { + mockSelectedText.mockReturnValue( + `${CHARS.NO_COPY_BOUND}some of${CHARS.NO_COPY_BOUND} this text should ${CHARS.NO_COPY_BOUND}not${CHARS.NO_COPY_BOUND} appear` + ); + onTabularCopy(mockEvent); + expect(mockSetData).toHaveBeenCalledWith( + 'text/plain', + ' this text should appear' + ); + }); + + it('does not clean text outside of specified bounds', () => { + mockSelectedText.mockReturnValue(` +this +is +not +cleaned +${CHARS.TABULAR_CONTENT_BOUND}↡this\r\nis\ncleaned${CHARS.TABULAR_CONTENT_BOUND} +also\tnot\tcleaned +${CHARS.TABULAR_CONTENT_BOUND}↦also\tcleaned${CHARS.TABULAR_CONTENT_BOUND} +`); + onTabularCopy(mockEvent); + expect(mockSetData).toHaveBeenCalledWith( + 'text/plain', + ` +this +is +not +cleaned + +thisiscleaned +also not cleaned + alsocleaned +` + ); + }); +}); diff --git a/packages/eui/src/services/copy/tabular_copy.testenv.tsx b/packages/eui/src/services/copy/tabular_copy.testenv.tsx new file mode 100644 index 00000000000..b56b5e267da --- /dev/null +++ b/packages/eui/src/services/copy/tabular_copy.testenv.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { PropsWithChildren } from 'react'; + +// Don't render these characters in Jest snapshots. I don't want to deal with the Kibana tests 🫠 +export const tabularCopyMarkers = { + hiddenTab: <>, + hiddenNewline: <>, + hiddenWrapperBoundary: <>, + ariaHiddenNoCopyBoundary: <>, +}; + +// Don't bother initializing in Kibana Jest either +export const OverrideCopiedTabularContent = ({ children }: PropsWithChildren) => + children; diff --git a/packages/eui/src/services/copy/tabular_copy.tsx b/packages/eui/src/services/copy/tabular_copy.tsx new file mode 100644 index 00000000000..0b8bb2b4659 --- /dev/null +++ b/packages/eui/src/services/copy/tabular_copy.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { PropsWithChildren, useEffect } from 'react'; + +/** + * Clipboard text cleaning logic + */ + +// Special visually hidden unicode characters that we use to manually clean content +// and force our own newlines/horizontal tabs +export const CHARS = { + NEWLINE: '↡', + TAB: '↦', + TABULAR_CONTENT_BOUND: '⁣', // U+2063 - invisible separator + NO_COPY_BOUND: '⁒', // U+2062 - invisible times +}; +// This regex finds all content between two bound characters +const noCopyBoundsRegex = new RegExp( + `${CHARS.NO_COPY_BOUND}[^${CHARS.NO_COPY_BOUND}]*${CHARS.NO_COPY_BOUND}`, + 'gs' +); + +const hasCharsToReplace = (text: string) => { + for (const char of Object.values(CHARS)) { + if (text.indexOf(char) >= 0) return true; + } + return false; +}; + +// Strip all existing newlines and replace our special hidden characters +// with the desired spacing needed to paste cleanly into a spreadsheet +export const onTabularCopy = (event: ClipboardEvent | React.ClipboardEvent) => { + if (!event.clipboardData) return; + + const selectedText = window.getSelection()?.toString(); + if (!selectedText || !hasCharsToReplace(selectedText)) return; + + const amendedText = selectedText + .split(CHARS.TABULAR_CONTENT_BOUND) + .map((text) => { + return hasCharsToReplace(text) + ? text + .replace(/\r?\n/g, '') // remove all other newlines generated by content or block display + .replaceAll(CHARS.NEWLINE, '\n') // insert newline for each table/grid row + .replace(/\t/g, '') // remove tabs generated by content or automatically by elements + .replaceAll(CHARS.TAB, '\u0009') // insert horizontal tab for each table/grid cell + .replace(noCopyBoundsRegex, '') // remove text that should not be copied (e.g. screen reader instructions) + : text; + }) + .join(''); + + event.clipboardData.setData('text/plain', amendedText); + event.preventDefault(); +}; + +/** + * JSX utils for rendering the hidden marker characters + */ + +const VisuallyHide = ({ children }: PropsWithChildren) => ( + // Hides the characters to both sighted user and screen readers + // Sadly, we can't use `hidden` as that hides the chars from the clipboard as well + + {children} + +); + +export const tabularCopyMarkers = { + hiddenTab: {CHARS.TAB}, + hiddenNewline: {CHARS.NEWLINE}, + hiddenWrapperBoundary: ( + {CHARS.TABULAR_CONTENT_BOUND} + ), + // Should be used within existing , ideally to avoid generating extra DOM + ariaHiddenNoCopyBoundary: {CHARS.NO_COPY_BOUND}, +}; + +/** + * Wrapper setup around table/grid tabular content we want to override/clean up on copy + */ + +export const OverrideCopiedTabularContent = ({ + children, +}: PropsWithChildren) => { + useEffect(() => { + // Chrome and webkit browsers work perfectly when passing `onTabularCopy` to a React + // `onCopy` prop, but sadly Firefox does not if copying more than just the table/grid + // (e.g. Ctrl+A). So we have to set up a global window event listener + window.addEventListener('copy', onTabularCopy); + // Note: Since onCopy is static, we don't have to worry about duplicate + // event listeners - it's automatically handled by the browser. See: + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Multiple_identical_event_listeners + }, []); + + return ( + <> + {tabularCopyMarkers.hiddenWrapperBoundary} + {children} + {tabularCopyMarkers.hiddenWrapperBoundary} + + ); +}; From 1b617a8f17d541d2443494f7f7f025c4ac9ccd04 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Thu, 12 Sep 2024 09:54:22 -0700 Subject: [PATCH 03/13] [EuiDataGrid] Set up copy markers for row cells --- .../__snapshots__/data_grid.test.tsx.snap | 266 ++++++++++++++++++ .../data_grid_body_custom.test.tsx.snap | 28 ++ .../data_grid_body_virtualized.test.tsx.snap | 14 + .../data_grid_cell.test.tsx.snap | 7 + .../datagrid/body/cell/data_grid_cell.tsx | 26 +- .../body/footer/data_grid_footer_row.test.tsx | 14 + .../datagrid/data_grid.stories.utils.tsx | 14 +- .../eui/src/components/datagrid/data_grid.tsx | 70 ++--- 8 files changed, 400 insertions(+), 39 deletions(-) diff --git a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 84fd9d9b42c..673b201b7cd 100644 --- a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -582,6 +582,13 @@ exports[`EuiDataGrid rendering renders additional toolbar controls 1`] = ` role="grid" tabindex="0" > +
0, A
+
0, B
+
1, A
+
1, B
+
2, A
+
2, B
+ +
0
+
0, A
+
0, B
+
0
+
1
+
1, A
+
1, B
+
1
+
2
+
2, A
+
2, B
+
2
+ +
0, A
+
0, B
+
1, A
+
1, B
+
2, A
+
2, B
+ +
0, A
+
0, B
+
1, A
+
1, B
+
2, A
+
2, B
+ +
world
+
lorem
+
ipsum
+ diff --git a/packages/eui/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap b/packages/eui/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap index e1a09ff8136..b3e149b25fb 100644 --- a/packages/eui/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap +++ b/packages/eui/src/components/datagrid/body/__snapshots__/data_grid_body_virtualized.test.tsx.snap @@ -118,6 +118,13 @@ exports[`EuiDataGridBodyVirtualized renders 1`] = ` cell content +
+ diff --git a/packages/eui/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap b/packages/eui/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap index 3020ffe3438..4b54914f447 100644 --- a/packages/eui/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap +++ b/packages/eui/src/components/datagrid/body/cell/__snapshots__/data_grid_cell.test.tsx.snap @@ -65,5 +65,12 @@ exports[`EuiDataGridCell renders 1`] = ` + `; diff --git a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx index 7aee6c986a7..6a1d5c3b5c7 100644 --- a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx +++ b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx @@ -24,7 +24,11 @@ import React, { import { createPortal } from 'react-dom'; import { IS_JEST_ENVIRONMENT } from '../../../../utils'; -import { keys, useEuiMemoizedStyles } from '../../../../services'; +import { + keys, + tabularCopyMarkers, + useEuiMemoizedStyles, +} from '../../../../services'; import { EuiScreenReaderOnly } from '../../../accessibility'; import { useEuiI18n } from '../../../i18n'; import { EuiTextBlockTruncate } from '../../../text_truncate'; @@ -545,6 +549,12 @@ export class EuiDataGridCell extends Component< className ); + // classNames set by EuiDataGridCellWrapper + const isControlColumn = cellClasses.includes( + 'euiDataGridRowCell--controlColumn' + ); + const isLastColumn = cellClasses.includes('euiDataGridRowCell--lastColumn'); + const ariaRowIndex = pagination ? visibleRowIndex + 1 + pagination.pageSize * pagination.pageIndex : visibleRowIndex + 1; @@ -616,14 +626,16 @@ export class EuiDataGridCell extends Component< setCellContentsRef={this.setCellContentsRef} rowHeight={rowHeight} rowHeightUtils={rowHeightUtils} - isControlColumn={cellClasses.includes( - 'euiDataGridRowCell--controlColumn' - )} + isControlColumn={isControlColumn} rowIndex={rowIndex} colIndex={colIndex} /> + {isLastColumn + ? tabularCopyMarkers.hiddenNewline + : tabularCopyMarkers.hiddenTab} + {this.state.isFocused && ( -

{` - ${cellPosition}${canExpandCell ? `. ${enterKeyPrompt}` : ''}`}

+

+ {tabularCopyMarkers.ariaHiddenNoCopyBoundary} + {` - ${cellPosition}${canExpandCell ? `. ${enterKeyPrompt}` : ''}`} + {tabularCopyMarkers.ariaHiddenNoCopyBoundary} +

); }); diff --git a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx index d84d83c640c..ca879e2f938 100644 --- a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx +++ b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx @@ -51,6 +51,13 @@ describe('EuiDataGridFooterRow', () => { >
+
{ >
+
`); diff --git a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx index f4c2d5b4da6..bb73981b057 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx @@ -224,7 +224,12 @@ export const defaultStorybookArgs = { Trailing actions ), - rowCellRender: () => , + rowCellRender: () => ( + + ), }, ], leadingControlColumns: [ @@ -236,7 +241,12 @@ export const defaultStorybookArgs = { Leading actions ), - rowCellRender: () => , + rowCellRender: () => ( + + ), }, ], // setup for easier testing/QA diff --git a/packages/eui/src/components/datagrid/data_grid.tsx b/packages/eui/src/components/datagrid/data_grid.tsx index 117b95457bb..078fc580d42 100644 --- a/packages/eui/src/components/datagrid/data_grid.tsx +++ b/packages/eui/src/components/datagrid/data_grid.tsx @@ -19,7 +19,11 @@ import { VariableSizeGrid as Grid, GridOnItemsRenderedProps, } from 'react-window'; -import { useGeneratedHtmlId, useEuiMemoizedStyles } from '../../services'; +import { + useGeneratedHtmlId, + useEuiMemoizedStyles, + OverrideCopiedTabularContent, +} from '../../services'; import { useEuiTablePaginationDefaults } from '../table/table_pagination'; import { EuiFocusTrap } from '../focus_trap'; import { EuiI18n, useEuiI18n } from '../i18n'; @@ -494,37 +498,39 @@ export const EuiDataGrid = memo( {...wrappingDivFocusProps} // re: above jsx-a11y - tabIndex is handled by these props, but the linter isn't smart enough to know that {...gridAriaProps} > - + + + {showPagination && props['aria-labelledby'] && (
+
leading heading +
+
+
trailing heading +
+
+
+
+
+
+
+
+
columnWidths, defaultColumnWidth, setColumnWidth, + visibleColCount, setVisibleColumns, switchColumnPos, sorting, diff --git a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap index 6fc8ed0541a..89f7e3ec306 100644 --- a/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap +++ b/packages/eui/src/components/datagrid/body/header/__snapshots__/data_grid_header_cell.test.tsx.snap @@ -42,5 +42,12 @@ exports[`EuiDataGridHeaderCell renders 1`] = ` />
+ `; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx index 087e56a4274..231d5c01553 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_control_header_cell.test.tsx @@ -14,6 +14,7 @@ import { EuiDataGridControlHeaderCell } from './data_grid_control_header_cell'; describe('EuiDataGridControlHeaderCell', () => { const props = { index: 0, + visibleColCount: 1, controlColumn: { id: 'someControlColumn', headerCellRender: () => + `); }); @@ -87,6 +95,13 @@ describe('EuiDataGridHeaderCellWrapper', () => { tabindex="0" > No column actions + `); }); diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx index 132a7f6c99e..63877240d6a 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_cell_wrapper.tsx @@ -17,7 +17,11 @@ import React, { import classnames from 'classnames'; import { FocusableElement } from 'tabbable'; -import { keys, useEuiMemoizedStyles } from '../../../../services'; +import { + keys, + tabularCopyMarkers, + useEuiMemoizedStyles, +} from '../../../../services'; import { EuiDataGridHeaderCellWrapperProps } from '../../data_grid_types'; import { DataGridFocusContext } from '../../utils/focus'; import { HandleInteractiveChildren } from '../cell/focus_utils'; @@ -33,6 +37,7 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< > = ({ id, index, + visibleColCount, width, className, children, @@ -90,6 +95,8 @@ export const EuiDataGridHeaderCellWrapper: FunctionComponent< [hasActionsPopover, openActionsPopover, renderFocusTrap, headerEl] ); + const isLastColumn = index === visibleColCount - 1; + return (
{typeof children === 'function' ? children(renderFocusTrap) : children} + {isLastColumn + ? tabularCopyMarkers.hiddenNewline + : tabularCopyMarkers.hiddenTab}
); }; diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx index 1b1048abdf6..febc72e85f4 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.test.tsx @@ -18,6 +18,7 @@ describe('EuiDataGridHeaderRow', () => { schema: {}, schemaDetectors: [], setColumnWidth: jest.fn(), + visibleColCount: 0, setVisibleColumns: jest.fn(), switchColumnPos: jest.fn(), gridStyles: { header: 'shade' as const }, @@ -43,6 +44,7 @@ describe('EuiDataGridHeaderRow', () => { schema={{ someColumn: { columnType: 'string' } }} columnWidths={{ someColumn: 30 }} defaultColumnWidth={20} + visibleColCount={1} /> ); expect(container.firstChild).toMatchInlineSnapshot(` @@ -92,6 +94,13 @@ describe('EuiDataGridHeaderRow', () => { /> + `); diff --git a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.tsx b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.tsx index 8efbde58646..d3468c8d46d 100644 --- a/packages/eui/src/components/datagrid/body/header/data_grid_header_row.tsx +++ b/packages/eui/src/components/datagrid/body/header/data_grid_header_row.tsx @@ -29,6 +29,7 @@ const EuiDataGridHeaderRow = memo( columnWidths, defaultColumnWidth, setColumnWidth, + visibleColCount, setVisibleColumns, switchColumnPos, sorting, @@ -57,6 +58,7 @@ const EuiDataGridHeaderRow = memo( ))} @@ -69,6 +71,7 @@ const EuiDataGridHeaderRow = memo( columnWidths={columnWidths} defaultColumnWidth={defaultColumnWidth} setColumnWidth={setColumnWidth} + visibleColCount={visibleColCount} setVisibleColumns={setVisibleColumns} switchColumnPos={switchColumnPos} sorting={sorting} @@ -80,6 +83,7 @@ const EuiDataGridHeaderRow = memo( ))} diff --git a/packages/eui/src/components/datagrid/data_grid_types.ts b/packages/eui/src/components/datagrid/data_grid_types.ts index 64419e06d29..1a192f5cb04 100644 --- a/packages/eui/src/components/datagrid/data_grid_types.ts +++ b/packages/eui/src/components/datagrid/data_grid_types.ts @@ -140,6 +140,7 @@ export interface EuiDataGridHeaderRowPropsSpecificProps { schemaDetectors: EuiDataGridSchemaDetector[]; defaultColumnWidth?: number | null; setColumnWidth: (columnId: string, width: number) => void; + visibleColCount: number; setVisibleColumns: (columnId: string[]) => void; switchColumnPos: (colFromId: string, colToId: string) => void; gridStyles: EuiDataGridStyle; @@ -160,6 +161,7 @@ export interface EuiDataGridHeaderCellProps export interface EuiDataGridControlHeaderCellProps { index: number; + visibleColCount: number; controlColumn: EuiDataGridControlColumn; } @@ -167,6 +169,7 @@ export interface EuiDataGridHeaderCellWrapperProps { children: ReactNode | ((renderFocusTrap: boolean) => ReactNode); id: string; index: number; + visibleColCount: number; width?: number | null; className?: string; 'aria-label'?: AriaAttributes['aria-label']; From 8f1fd8b9c9bb275ee8e400c84062402b08c405d5 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 22:39:45 -0700 Subject: [PATCH 05/13] [EuiDataGrid] Fix footer rows not appending last/first column classes - and as such not appending the correct copy markers --- .../datagrid/body/data_grid_body_custom.tsx | 1 + .../body/data_grid_body_virtualized.tsx | 1 + .../body/footer/data_grid_footer_row.test.tsx | 7 +-- .../body/footer/data_grid_footer_row.tsx | 45 +++++++++++++------ .../components/datagrid/data_grid_types.ts | 1 + 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/packages/eui/src/components/datagrid/body/data_grid_body_custom.tsx b/packages/eui/src/components/datagrid/body/data_grid_body_custom.tsx index 231c42edad7..877f45956f1 100644 --- a/packages/eui/src/components/datagrid/body/data_grid_body_custom.tsx +++ b/packages/eui/src/components/datagrid/body/data_grid_body_custom.tsx @@ -113,6 +113,7 @@ export const EuiDataGridBodyCustomRender: FunctionComponent< renderCellPopover, rowIndex: visibleRows.visibleRowCount, visibleRowIndex: visibleRows.visibleRowCount, + visibleColCount, interactiveCellId, leadingControlColumns, trailingControlColumns, diff --git a/packages/eui/src/components/datagrid/body/data_grid_body_virtualized.tsx b/packages/eui/src/components/datagrid/body/data_grid_body_virtualized.tsx index 23a8e577e0a..f5743c60f3f 100644 --- a/packages/eui/src/components/datagrid/body/data_grid_body_virtualized.tsx +++ b/packages/eui/src/components/datagrid/body/data_grid_body_virtualized.tsx @@ -208,6 +208,7 @@ export const EuiDataGridBodyVirtualized: FunctionComponent renderCellPopover, rowIndex: visibleRowCount, visibleRowIndex: visibleRowCount, + visibleColCount, interactiveCellId, leadingControlColumns, trailingControlColumns, diff --git a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx index ca879e2f938..ab57130e6f1 100644 --- a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx +++ b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.test.tsx @@ -17,6 +17,7 @@ describe('EuiDataGridFooterRow', () => { leadingControlColumns: [], trailingControlColumns: [], columns: [{ id: 'someColumn' }, { id: 'someColumnWithoutSchema' }], + visibleColCount: 2, schema: { someColumn: { columnType: 'string' } }, columnWidths: { someColumn: 30 }, renderCellValue: () =>
, @@ -35,7 +36,7 @@ describe('EuiDataGridFooterRow', () => { >
{
{ class="euiScreenReaderOnly" data-tabular-copy-marker="true" > - ↦ + ↡
diff --git a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.tsx b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.tsx index 30b01cfcf02..4b3999ef462 100644 --- a/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.tsx +++ b/packages/eui/src/components/datagrid/body/footer/data_grid_footer_row.tsx @@ -6,8 +6,8 @@ * Side Public License, v 1. */ -import React, { forwardRef, memo, useContext } from 'react'; -import classnames from 'classnames'; +import React, { forwardRef, memo, useCallback, useContext } from 'react'; +import classNames from 'classnames'; import { useEuiMemoizedStyles } from '../../../../services'; import { EuiDataGridFooterRowProps } from '../../data_grid_types'; @@ -33,6 +33,7 @@ const EuiDataGridFooterRow = memo( interactiveCellId, 'data-test-subj': _dataTestSubj, visibleRowIndex = rowIndex, + visibleColCount, gridStyles, ...rest }, @@ -47,12 +48,26 @@ const EuiDataGridFooterRow = memo( : styles[gridStyles.footer!], ]; - const classes = classnames('euiDataGridFooter', className); - const dataTestSubj = classnames( + const classes = classNames('euiDataGridFooter', className); + const dataTestSubj = classNames( 'dataGridRow', 'dataGridFooterRow', _dataTestSubj ); + const getCellClasses = useCallback( + (columnIndex: number, classes?: string) => { + return classNames( + 'euiDataGridFooterCell', + { + 'euiDataGridRowCell--firstColumn': columnIndex === 0, + 'euiDataGridRowCell--lastColumn': + columnIndex === visibleColCount - 1, + }, + classes + ); + }, + [visibleColCount] + ); const popoverContext = useContext(DataGridCellPopoverContext); const sharedCellProps = { @@ -83,10 +98,12 @@ const EuiDataGridFooterRow = memo( width={width} renderCellValue={footerCellRender ?? renderEmpty} isExpandable={false} - className={classnames( - 'euiDataGridFooterCell', - 'euiDataGridRowCell--controlColumn', - footerCellProps?.className + className={getCellClasses( + i, + classNames( + 'euiDataGridRowCell--controlColumn', + footerCellProps?.className + ) )} /> ) @@ -107,7 +124,7 @@ const EuiDataGridFooterRow = memo( renderCellValue={renderCellValue} renderCellPopover={renderCellPopover} isExpandable={true} - className="euiDataGridFooterCell" + className={getCellClasses(columnPosition)} /> ); })} @@ -126,10 +143,12 @@ const EuiDataGridFooterRow = memo( width={width} renderCellValue={footerCellRender ?? renderEmpty} isExpandable={false} - className={classnames( - 'euiDataGridFooterCell', - 'euiDataGridRowCell--controlColumn', - footerCellProps?.className + className={getCellClasses( + colIndex, + classNames( + 'euiDataGridRowCell--controlColumn', + footerCellProps?.className + ) )} /> ); diff --git a/packages/eui/src/components/datagrid/data_grid_types.ts b/packages/eui/src/components/datagrid/data_grid_types.ts index 1a192f5cb04..3055100ca0b 100644 --- a/packages/eui/src/components/datagrid/data_grid_types.ts +++ b/packages/eui/src/components/datagrid/data_grid_types.ts @@ -190,6 +190,7 @@ export type EuiDataGridFooterRowProps = CommonProps & renderCellPopover?: EuiDataGridCellProps['renderCellPopover']; interactiveCellId: EuiDataGridCellProps['interactiveCellId']; visibleRowIndex?: number; + visibleColCount: number; gridStyles: EuiDataGridStyle; }; From 8a3f6e1c1c1696976b7d2a7c3fcd74579e9db150 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 20:56:34 -0700 Subject: [PATCH 06/13] [EuiBasicTable] Set up copy markers for table cells - requires adding an `append` prop to various cell components, for rendering this hidden text outside of the cell content wrapper --- .../__snapshots__/basic_table.test.tsx.snap | 229 +++++++++++++++++- .../in_memory_table.test.tsx.snap | 89 ++++++- .../basic_table/basic_table.test.tsx | 3 +- .../components/basic_table/basic_table.tsx | 93 ++++--- .../components/table/table_header_cell.tsx | 9 + .../table/table_header_cell_checkbox.tsx | 6 +- .../src/components/table/table_row_cell.tsx | 9 + .../table/table_row_cell_checkbox.tsx | 7 +- 8 files changed, 403 insertions(+), 42 deletions(-) diff --git a/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap b/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap index 09520756ab1..f8e8e6957c6 100644 --- a/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap +++ b/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap @@ -20,6 +20,13 @@ exports[`EuiBasicTable actions custom item actions 1`] = ` + `; @@ -30,6 +37,13 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` class="euiBasicTable testClass1 testClass2 emotion-euiTestCss" data-test-subj="test subject string" > + @@ -82,6 +114,13 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` name1 + + +
+ > + + +
+
+ `; @@ -127,6 +187,13 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin
+ @@ -294,6 +407,13 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin + + + @@ -663,6 +881,13 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin
+ > + + +
+ + + + +
NAME1 + + + +
NAME2 + + + +
NAME3 + + + +
+
+ @@ -218,6 +243,13 @@ exports[`EuiInMemoryTable empty array 1`] = `
+ > + + +
+
+
`; @@ -227,6 +259,13 @@ exports[`EuiInMemoryTable with items 1`] = ` class="euiBasicTable testClass1 testClass2 emotion-euiTestCss" data-test-subj="test subject string" > + @@ -279,6 +336,13 @@ exports[`EuiInMemoryTable with items 1`] = ` name1 + + +
+ > + + +
+
+
`; diff --git a/packages/eui/src/components/basic_table/basic_table.test.tsx b/packages/eui/src/components/basic_table/basic_table.test.tsx index 59b81251feb..11eb369995a 100644 --- a/packages/eui/src/components/basic_table/basic_table.test.tsx +++ b/packages/eui/src/components/basic_table/basic_table.test.tsx @@ -348,7 +348,8 @@ describe('EuiBasicTable', () => { expect(getByTestSubject('tableHeaderSortButton')).toBeTruthy(); expect( - container.querySelector('[aria-sort="ascending"]')?.textContent + container.querySelector('[aria-sort="ascending"] .euiTableCellContent') + ?.textContent ).toEqual('Name'); }); diff --git a/packages/eui/src/components/basic_table/basic_table.tsx b/packages/eui/src/components/basic_table/basic_table.tsx index 3dcc15b3abe..df05ca0946c 100644 --- a/packages/eui/src/components/basic_table/basic_table.tsx +++ b/packages/eui/src/components/basic_table/basic_table.tsx @@ -26,6 +26,8 @@ import { RIGHT_ALIGNMENT, SortDirection, RenderWithEuiTheme, + OverrideCopiedTabularContent, + tabularCopyMarkers, } from '../../services'; import { CommonProps } from '../common'; import { isFunction } from '../../services/predicate'; @@ -547,18 +549,20 @@ export class EuiBasicTable extends Component< {this.renderSelectAll(true)} {this.renderTableMobileSort()} - - {this.renderTableCaption()} - {this.renderTableHead()} - {this.renderTableBody()} - {this.renderTableFooter()} - + + + {this.renderTableCaption()} + {this.renderTableHead()} + {this.renderTableBody()} + {this.renderTableFooter()} + + ); } @@ -664,7 +668,9 @@ export class EuiBasicTable extends Component< return ( + {tabularCopyMarkers.ariaHiddenNoCopyBoundary} {captionElement} + {tabularCopyMarkers.ariaHiddenNoCopyBoundary} ); @@ -733,7 +739,10 @@ export class EuiBasicTable extends Component< if (selection) { headers.push( - + {this.renderSelectAll(false)} ); @@ -754,15 +763,21 @@ export class EuiBasicTable extends Component< const columnAlign = align || this.getAlignForDataType(dataType); + const sharedProps = { + width, + description, + mobileOptions, + align: columnAlign, + append: this.renderCopyChar(index), + }; + // actions column if ((column as EuiTableActionsColumnType).actions) { headers.push( {name} @@ -785,14 +800,11 @@ export class EuiBasicTable extends Component< } headers.push( {name} @@ -829,12 +841,9 @@ export class EuiBasicTable extends Component< } headers.push( {name} @@ -1056,7 +1065,11 @@ export class EuiBasicTable extends Component< isExpandedRow={true} hasSelection={!!selection} > - + {itemIdToExpandedRowMap![itemId]} @@ -1115,7 +1128,7 @@ export class EuiBasicTable extends Component< } }; return [ - + extends Component< align="right" textOnly={false} hasActions={hasCustomActions ? 'custom' : true} + append={this.renderCopyChar(columnIndex)} > extends Component< const value = get(item, field as string); const content = contentRenderer(value, item); - return this.renderItemCell(item, column, key, content, setScopeRow); + return this.renderItemCell( + item, + column, + columnIndex, + key, + content, + setScopeRow + ); } renderItemComputedCell( @@ -1236,12 +1257,13 @@ export class EuiBasicTable extends Component< const contentRenderer = render || this.getRendererForDataType(); const content = contentRenderer(item); - return this.renderItemCell(item, column, key, content, false); + return this.renderItemCell(item, column, columnIndex, key, content, false); } renderItemCell( item: T, column: EuiBasicTableColumn, + columnIndex: number, key: string | number, content: ReactNode, setScopeRow: boolean @@ -1277,19 +1299,26 @@ export class EuiBasicTable extends Component< setScopeRow={setScopeRow} mobileOptions={{ ...mobileOptions, - render: - mobileOptions && mobileOptions.render && mobileOptions.render(item), - header: - mobileOptions && mobileOptions.header === false ? false : name, + render: mobileOptions?.render?.(item), + header: mobileOptions?.header ?? name, }} {...cellProps} {...rest} + append={this.renderCopyChar(columnIndex)} > {content} ); } + renderCopyChar = (columnIndex: number) => { + const isLastColumn = columnIndex === this.props.columns.length - 1; + + return isLastColumn + ? tabularCopyMarkers.hiddenNewline + : tabularCopyMarkers.hiddenTab; + }; + resolveColumnSortDirection = (column: EuiBasicTableColumn) => { const { sorting } = this.props; const { sortable, field, name } = column as EuiTableFieldDataColumnType; diff --git a/packages/eui/src/components/table/table_header_cell.tsx b/packages/eui/src/components/table/table_header_cell.tsx index b5d507c7c92..5c2e2a520d6 100644 --- a/packages/eui/src/components/table/table_header_cell.tsx +++ b/packages/eui/src/components/table/table_header_cell.tsx @@ -10,6 +10,7 @@ import React, { FunctionComponent, HTMLAttributes, ThHTMLAttributes, + ReactNode, } from 'react'; import classNames from 'classnames'; @@ -47,6 +48,12 @@ export type EuiTableHeaderCellProps = CommonProps & * Shows the sort indicator but removes the button */ readOnly?: boolean; + /** + * Content rendered outside the visible cell content wrapper. Useful for, e.g. screen reader text. + * + * Used by EuiBasicTable to render hidden copy markers + */ + append?: ReactNode; }; const CellContents = ({ @@ -128,6 +135,7 @@ export const EuiTableHeaderCell: FunctionComponent = ({ style, readOnly, description, + append, ...rest }) => { const styles = useEuiMemoizedStyles(euiTableHeaderFooterCellStyles); @@ -186,6 +194,7 @@ export const EuiTableHeaderCell: FunctionComponent = ({ ) : ( )} + {append} ); }; diff --git a/packages/eui/src/components/table/table_header_cell_checkbox.tsx b/packages/eui/src/components/table/table_header_cell_checkbox.tsx index 4bec76aa064..cf1075df7a6 100644 --- a/packages/eui/src/components/table/table_header_cell_checkbox.tsx +++ b/packages/eui/src/components/table/table_header_cell_checkbox.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FunctionComponent, ThHTMLAttributes } from 'react'; +import React, { FunctionComponent, ThHTMLAttributes, ReactNode } from 'react'; import classNames from 'classnames'; import { useEuiMemoizedStyles } from '../../services'; @@ -22,13 +22,14 @@ export type EuiTableHeaderCellCheckboxScope = export interface EuiTableHeaderCellCheckboxProps { width?: string | number; scope?: EuiTableHeaderCellCheckboxScope; + append?: ReactNode; } export const EuiTableHeaderCellCheckbox: FunctionComponent< CommonProps & ThHTMLAttributes & EuiTableHeaderCellCheckboxProps -> = ({ children, className, scope = 'col', style, width, ...rest }) => { +> = ({ children, className, scope = 'col', style, width, append, ...rest }) => { const classes = classNames('euiTableHeaderCellCheckbox', className); const styles = useEuiMemoizedStyles(euiTableCellCheckboxStyles); const inlineStyles = resolveWidthAsStyle(style, width); @@ -42,6 +43,7 @@ export const EuiTableHeaderCellCheckbox: FunctionComponent< {...rest} >
{children}
+ {append} ); }; diff --git a/packages/eui/src/components/table/table_row_cell.tsx b/packages/eui/src/components/table/table_row_cell.tsx index 7e52261ce94..09c6a6103f4 100644 --- a/packages/eui/src/components/table/table_row_cell.tsx +++ b/packages/eui/src/components/table/table_row_cell.tsx @@ -105,6 +105,12 @@ export interface EuiTableRowCellProps extends EuiTableRowCellSharedPropsShape { * See #EuiTableRowCellMobileOptionsShape */ mobileOptions?: EuiTableRowCellMobileOptionsShape; + /** + * Content rendered outside the visible cell content wrapper. Useful for, e.g. screen reader text. + * + * Used by EuiBasicTable to render hidden copy markers + */ + append?: ReactNode; } type Props = CommonProps & @@ -125,6 +131,7 @@ export const EuiTableRowCell: FunctionComponent = ({ width, valign = 'middle', mobileOptions, + append, ...rest }) => { const isResponsive = useEuiTableIsResponsive(); @@ -196,6 +203,7 @@ export const EuiTableRowCell: FunctionComponent = ({ > {mobileOptions?.render || children} + {append} ); } @@ -209,6 +217,7 @@ export const EuiTableRowCell: FunctionComponent = ({ {children} + {append} ); } diff --git a/packages/eui/src/components/table/table_row_cell_checkbox.tsx b/packages/eui/src/components/table/table_row_cell_checkbox.tsx index e4f80a5ddde..b55f04a1bdd 100644 --- a/packages/eui/src/components/table/table_row_cell_checkbox.tsx +++ b/packages/eui/src/components/table/table_row_cell_checkbox.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { FunctionComponent, TdHTMLAttributes } from 'react'; +import React, { FunctionComponent, TdHTMLAttributes, ReactNode } from 'react'; import classNames from 'classnames'; import { useEuiMemoizedStyles } from '../../services'; @@ -16,8 +16,8 @@ import { useEuiTableIsResponsive } from './mobile/responsive_context'; import { euiTableCellCheckboxStyles } from './table_cells_shared.styles'; export const EuiTableRowCellCheckbox: FunctionComponent< - CommonProps & TdHTMLAttributes -> = ({ children, className, ...rest }) => { + CommonProps & TdHTMLAttributes & { append?: ReactNode } +> = ({ children, className, append, ...rest }) => { const isResponsive = useEuiTableIsResponsive(); const styles = useEuiMemoizedStyles(euiTableCellCheckboxStyles); @@ -31,6 +31,7 @@ export const EuiTableRowCellCheckbox: FunctionComponent< return (
{children}
+ {append} ); }; From 44f9338bfd9c13254da19260eb8a5b67942acf06 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 22:40:03 -0700 Subject: [PATCH 07/13] Add E2E Cypress tests for checking final copied content - my god this was a StackOverflow journey --- packages/eui/cypress/support/component.tsx | 1 + .../cypress/support/copy/select_and_copy.tsx | 49 +++++++++++++++++++ packages/eui/cypress/support/index.d.ts | 7 +++ .../basic_table/basic_table.spec.tsx | 49 +++++++++++++++++++ .../components/datagrid/data_grid.spec.tsx | 17 +++++++ 5 files changed, 123 insertions(+) create mode 100644 packages/eui/cypress/support/copy/select_and_copy.tsx create mode 100644 packages/eui/src/components/basic_table/basic_table.spec.tsx diff --git a/packages/eui/cypress/support/component.tsx b/packages/eui/cypress/support/component.tsx index 9ddd5aef01b..32a7ee9de52 100644 --- a/packages/eui/cypress/support/component.tsx +++ b/packages/eui/cypress/support/component.tsx @@ -20,6 +20,7 @@ import 'cypress-real-events'; import './a11y/checkAxe'; import './keyboard/repeatRealPress'; +import './copy/select_and_copy'; import './setup/mount'; import './setup/realMount'; diff --git a/packages/eui/cypress/support/copy/select_and_copy.tsx b/packages/eui/cypress/support/copy/select_and_copy.tsx new file mode 100644 index 00000000000..38e2f64af33 --- /dev/null +++ b/packages/eui/cypress/support/copy/select_and_copy.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +const selectAndCopy = (selectorToCopy: string) => { + // Force Chrome devtools to allow reading from the clipboard + cy.wrap( + Cypress.automation('remote:debugger:protocol', { + command: 'Browser.grantPermissions', + params: { + permissions: ['clipboardReadWrite', 'clipboardSanitizedWrite'], + origin: window.location.origin, + }, + }) + ); + + // For some annoying reason, mocking selection+copying throws a bunch + // of document errors from the code coverage reporter - just ignore them + Cypress.on('uncaught:exception', (err) => { + console.log(err.message); + if ( + err.message.includes( + "Cannot read properties of null (reading 'document')" + ) + ) { + return false; + } + }); + + cy.get(selectorToCopy).then(($el) => { + const el = $el[0]; + const document = el.ownerDocument; + const range = document.createRange(); + range.selectNodeContents(el); + document.getSelection()!.removeAllRanges(); + document.getSelection()!.addRange(range); + }); + + return cy.window().then((window) => { + document.execCommand('copy'); + return window.navigator.clipboard.readText(); + }); +}; + +Cypress.Commands.add('selectAndCopy', selectAndCopy); diff --git a/packages/eui/cypress/support/index.d.ts b/packages/eui/cypress/support/index.d.ts index 120cbb74de4..be6cf56bcc4 100644 --- a/packages/eui/cypress/support/index.d.ts +++ b/packages/eui/cypress/support/index.d.ts @@ -49,6 +49,13 @@ declare global { count?: number, options?: RealPressOptions ): void; + + /** + * Select an element's content and copy it to the browser clipboard + * @param selectorToCopy e.g. '.euiDataGrid__content' + * @returns a chainable .then((string) => { doSomethingWith(string); }) + */ + selectAndCopy(selectorToCopy: string): Chainable; } } } diff --git a/packages/eui/src/components/basic_table/basic_table.spec.tsx b/packages/eui/src/components/basic_table/basic_table.spec.tsx new file mode 100644 index 00000000000..0ca7e07e512 --- /dev/null +++ b/packages/eui/src/components/basic_table/basic_table.spec.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/// +/// +/// + +import React from 'react'; +import { EuiHealth } from '../../components'; +import { EuiBasicTable, EuiBasicTableColumn } from './index'; + +describe('EuiBasicTable', () => { + const columns: Array> = [ + { field: 'a', name: 'A', render: () => 'Hello' }, + { field: 'b', name: 'B', render: () => 'World' }, + { + field: 'c', + name: 'Status', + render: () => Online, + }, + ]; + + describe('copying tabular content', () => { + it('renders one newline per-row and renders horizontal tab characters between cells', () => { + cy.realMount( + + ); + + cy.selectAndCopy('.euiTable').then((copiedText) => { + expect(copiedText).to.eq( + `A\tB\t\Status +Hello\tWorld\tOnline +` + ); + }); + }); + }); +}); diff --git a/packages/eui/src/components/datagrid/data_grid.spec.tsx b/packages/eui/src/components/datagrid/data_grid.spec.tsx index 2d307c72192..919b55010a1 100644 --- a/packages/eui/src/components/datagrid/data_grid.spec.tsx +++ b/packages/eui/src/components/datagrid/data_grid.spec.tsx @@ -720,6 +720,23 @@ describe('EuiDataGrid', () => { }); }); }); + + describe('copying tabular content', () => { + it('renders one newline per-row and renders horizontal tab characters between cells', () => { + cy.realMount(); + + cy.selectAndCopy('.euiDataGrid__content').then((copiedText) => { + expect(copiedText).to.eq( + `First\tSecond +a, 0\tb, 0 +a, 1\tb, 1 +a, 2\tb, 2 +a, footer\tb, footer +` + ); + }); + }); + }); }); function getGridData() { From 7089f50481b898c7ddead9b3151537b1d1b1d30d Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 22:59:34 -0700 Subject: [PATCH 08/13] Fix failing Cypress test due to new copy marker DOM - we should be more specific with selectors anyway --- packages/eui/src/components/datagrid/data_grid.spec.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/eui/src/components/datagrid/data_grid.spec.tsx b/packages/eui/src/components/datagrid/data_grid.spec.tsx index 919b55010a1..0a144e330ec 100644 --- a/packages/eui/src/components/datagrid/data_grid.spec.tsx +++ b/packages/eui/src/components/datagrid/data_grid.spec.tsx @@ -111,8 +111,7 @@ describe('EuiDataGrid', () => { const virtualizedContainer = cy .get('[data-test-subj=euiDataGridBody]') - .children() - .first(); + .find('.euiDataGrid__virtualized'); // make sure the horizontal scrollbar is present virtualizedContainer.then(([outerContainer]) => { From fedb57da5f65b3368f9f412989820f74cb32a7bc Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 21:05:12 -0700 Subject: [PATCH 09/13] Fix bizarre typescript complaint works just fine in actual browsers, but typescript really wants document for whatever reason see https://stackoverflow.com/questions/74809554/cant-get-paste-event-to-work-in-typescript :| --- packages/eui/src/services/copy/tabular_copy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eui/src/services/copy/tabular_copy.tsx b/packages/eui/src/services/copy/tabular_copy.tsx index 0b8bb2b4659..354b72ef870 100644 --- a/packages/eui/src/services/copy/tabular_copy.tsx +++ b/packages/eui/src/services/copy/tabular_copy.tsx @@ -92,7 +92,7 @@ export const OverrideCopiedTabularContent = ({ // Chrome and webkit browsers work perfectly when passing `onTabularCopy` to a React // `onCopy` prop, but sadly Firefox does not if copying more than just the table/grid // (e.g. Ctrl+A). So we have to set up a global window event listener - window.addEventListener('copy', onTabularCopy); + window.document.addEventListener('copy', onTabularCopy); // Note: Since onCopy is static, we don't have to worry about duplicate // event listeners - it's automatically handled by the browser. See: // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Multiple_identical_event_listeners From 729f1929179deb1133fb29a0131a752daa6328c8 Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Mon, 16 Sep 2024 21:09:09 -0700 Subject: [PATCH 10/13] changelog --- packages/eui/changelogs/upcoming/8019.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/eui/changelogs/upcoming/8019.md diff --git a/packages/eui/changelogs/upcoming/8019.md b/packages/eui/changelogs/upcoming/8019.md new file mode 100644 index 00000000000..4a093c17035 --- /dev/null +++ b/packages/eui/changelogs/upcoming/8019.md @@ -0,0 +1 @@ +- Enhanced `EuiDataGrid` and `EuiBasic/InMemoryTable` to clean content newlines/tabs when users copy and paste from their tabular data From 9a22861a1db97513029ed07351e7472d4b834d5f Mon Sep 17 00:00:00 2001 From: Cee Chen Date: Wed, 18 Sep 2024 14:20:15 -0700 Subject: [PATCH 11/13] [PR feedback] Use non-invisible characters to reduce likelihood of consumer + use 2 characters for increased chances + add more `data` attributes to help identify usages --- .../__snapshots__/basic_table.test.tsx.snap | 82 +++++++------ .../in_memory_table.test.tsx.snap | 42 ++++--- .../__snapshots__/data_grid.test.tsx.snap | 112 +++++++++--------- .../data_grid_body_custom.test.tsx.snap | 12 +- .../data_grid_body_virtualized.test.tsx.snap | 8 +- .../data_grid_cell.test.tsx.snap | 2 +- .../body/footer/data_grid_footer_row.test.tsx | 4 +- .../data_grid_header_cell.test.tsx.snap | 2 +- .../data_grid_control_header_cell.test.tsx | 2 +- .../data_grid_header_cell_wrapper.test.tsx | 4 +- .../body/header/data_grid_header_row.test.tsx | 2 +- .../eui/src/services/copy/tabular_copy.tsx | 29 +++-- 12 files changed, 163 insertions(+), 138 deletions(-) diff --git a/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap b/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap index f8e8e6957c6..8f693343da0 100644 --- a/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap +++ b/packages/eui/src/components/basic_table/__snapshots__/basic_table.test.tsx.snap @@ -23,7 +23,7 @@ exports[`EuiBasicTable actions custom item actions 1`] = ` @@ -40,9 +40,9 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` @@ -89,7 +93,7 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` @@ -117,7 +121,7 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` @@ -141,7 +145,7 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` @@ -165,7 +169,7 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` @@ -176,9 +180,9 @@ exports[`EuiBasicTable renders (bare-bones) 1`] = ` `; @@ -190,9 +194,9 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin
@@ -246,7 +254,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -286,7 +294,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -315,7 +323,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -344,7 +352,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -367,7 +375,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -410,7 +418,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -426,7 +434,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -446,7 +454,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -466,7 +474,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -517,7 +525,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -556,7 +564,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -572,7 +580,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -592,7 +600,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -612,7 +620,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -663,7 +671,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -702,7 +710,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -718,7 +726,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -738,7 +746,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -758,7 +766,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -809,7 +817,7 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin @@ -884,9 +892,9 @@ exports[`EuiBasicTable renders (kitchen sink) with pagination, selection, sortin
@@ -213,7 +217,7 @@ exports[`EuiInMemoryTable empty array 1`] = ` @@ -246,9 +250,9 @@ exports[`EuiInMemoryTable empty array 1`] = ` `; @@ -262,9 +266,9 @@ exports[`EuiInMemoryTable with items 1`] = `
@@ -311,7 +319,7 @@ exports[`EuiInMemoryTable with items 1`] = ` @@ -339,7 +347,7 @@ exports[`EuiInMemoryTable with items 1`] = ` @@ -363,7 +371,7 @@ exports[`EuiInMemoryTable with items 1`] = ` @@ -387,7 +395,7 @@ exports[`EuiInMemoryTable with items 1`] = ` @@ -398,9 +406,9 @@ exports[`EuiInMemoryTable with items 1`] = ` `; diff --git a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 92abf4f64b8..dc04e169057 100644 --- a/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/packages/eui/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -585,9 +585,9 @@ exports[`EuiDataGrid rendering renders additional toolbar controls 1`] = ` ); diff --git a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx index 6a1d5c3b5c7..268f586dc21 100644 --- a/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx +++ b/packages/eui/src/components/datagrid/body/cell/data_grid_cell.tsx @@ -744,9 +744,9 @@ const CellScreenReaderDescription: FunctionComponent<{ return (

- {tabularCopyMarkers.ariaHiddenNoCopyBoundary} + {tabularCopyMarkers.hiddenNoCopyBoundary} {` - ${cellPosition}${canExpandCell ? `. ${enterKeyPrompt}` : ''}`} - {tabularCopyMarkers.ariaHiddenNoCopyBoundary} + {tabularCopyMarkers.hiddenNoCopyBoundary}

); diff --git a/packages/eui/src/services/copy/tabular_copy.testenv.tsx b/packages/eui/src/services/copy/tabular_copy.testenv.tsx index b56b5e267da..0fd565feeb9 100644 --- a/packages/eui/src/services/copy/tabular_copy.testenv.tsx +++ b/packages/eui/src/services/copy/tabular_copy.testenv.tsx @@ -13,7 +13,7 @@ export const tabularCopyMarkers = { hiddenTab: <>, hiddenNewline: <>, hiddenWrapperBoundary: <>, - ariaHiddenNoCopyBoundary: <>, + hiddenNoCopyBoundary: <>, }; // Don't bother initializing in Kibana Jest either diff --git a/packages/eui/src/services/copy/tabular_copy.tsx b/packages/eui/src/services/copy/tabular_copy.tsx index 22c2b72f142..b7d5da489c1 100644 --- a/packages/eui/src/services/copy/tabular_copy.tsx +++ b/packages/eui/src/services/copy/tabular_copy.tsx @@ -85,7 +85,7 @@ export const tabularCopyMarkers = { hiddenWrapperBoundary: ( {CHARS.TABULAR_CONTENT_BOUND} ), - ariaHiddenNoCopyBoundary: ( + hiddenNoCopyBoundary: ( {CHARS.NO_COPY_BOUND} ), }; From 424cc63fdb09d3c197ad573496dc4a5b5a452cbc Mon Sep 17 00:00:00 2001 From: Cee Chen <549407+cee-chen@users.noreply.github.com> Date: Mon, 23 Sep 2024 10:21:10 -0700 Subject: [PATCH 13/13] sad prettier noises --- .../eui/src/components/datagrid/data_grid.stories.utils.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx index c72a35496e4..5d5f8442e30 100644 --- a/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx +++ b/packages/eui/src/components/datagrid/data_grid.stories.utils.tsx @@ -245,10 +245,7 @@ export const defaultStorybookArgs = { ), rowCellRender: () => ( <> - +
- {tabularCopyMarkers.ariaHiddenNoCopyBoundary} + {tabularCopyMarkers.hiddenNoCopyBoundary} {captionElement} - {tabularCopyMarkers.ariaHiddenNoCopyBoundary} + {tabularCopyMarkers.hiddenNoCopyBoundary}