From 1951698e2e5c352041156eef6c16b405a7d4d3b0 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Thu, 10 Oct 2019 12:57:12 -0600 Subject: [PATCH 1/9] Allow custom ReactNode for column header display --- .../__snapshots__/data_grid.test.tsx.snap | 279 ++++++++++++++++++ src/components/datagrid/data_grid.test.tsx | 18 ++ .../datagrid/data_grid_header_row.tsx | 6 +- src/components/datagrid/data_grid_types.ts | 2 + 4 files changed, 303 insertions(+), 2 deletions(-) diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index a8fcfe29e0e..c9cbc4b1fb5 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -158,6 +158,285 @@ exports[`EuiDataGrid pagination renders 1`] = ` `; +exports[`EuiDataGrid rendering renders custom column headers 1`] = ` +Array [ +
, +
, +
+
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ Column A +
+
+
+
+
+ More Elements +
+
+
+
+
+
+
+
+ 0, A +
+
+
+
+
+
+ 0, B +
+
+
+
+
+
+
+
+ 1, A +
+
+
+
+
+
+ 1, B +
+
+
+
+
+
+
+
+ 2, A +
+
+
+
+
+
+ 2, B +
+
+
+
+
+
+
+ +
+
, +
, +] +`; + exports[`EuiDataGrid rendering renders with common and div attributes 1`] = ` Array [
{ expect(component).toMatchSnapshot(); }); + it('renders custom column headers', () => { + const component = render( + More Elements
}, + ]} + rowCount={3} + renderCellValue={({ rowIndex, columnId }) => + `${rowIndex}, ${columnId}` + } + /> + ); + + expect(component).toMatchSnapshot(); + }); + it('renders with appropriate role structure', () => { const component = render( {columns.map(props => { - const { id } = props; + const { id, display } = props; const width = columnWidths[id] || defaultColumnWidth; @@ -102,7 +102,9 @@ const EuiDataGridHeaderRow: FunctionComponent< /> ) : null} -
{id}
+
+ {display || id} +
{sorting && sorting.columns.length >= 2 && (
{sortString}
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts index 9fd29ce20fe..0507361d61d 100644 --- a/src/components/datagrid/data_grid_types.ts +++ b/src/components/datagrid/data_grid_types.ts @@ -1,7 +1,9 @@ import { EuiDataGridSchema } from './data_grid_schema'; +import { ReactNode } from 'react'; export interface EuiDataGridColumn { id: string; + display?: ReactNode; // allow devs to pass arbitrary dataType strings, but internally keep the code matching against the known types dataType?: EuiDataGridSchema['*']['columnType']; } From 8465a285405537b448291c73d33f26c61eff2ce8 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 14 Oct 2019 10:55:25 -0600 Subject: [PATCH 2/9] Allow navigation into grid headers if any are interactive --- src-docs/src/views/datagrid/datagrid.js | 11 + .../datagrid/_data_grid_header_row.scss | 9 + src/components/datagrid/data_grid.tsx | 68 ++++- .../datagrid/data_grid_header_row.tsx | 255 ++++++++++++------ .../mutation_observer/mutation_observer.ts | 8 +- 5 files changed, 254 insertions(+), 97 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 18bd0f34aee..08c61122561 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -15,10 +15,21 @@ import { EuiSpacer, } from '../../../../src/components/'; import { EuiRadioGroup } from '../../../../src/components/form/radio'; +import { EuiButtonIcon } from '../../../../src/components/button/button_icon'; const columns = [ { id: 'name', + display: ( + + name + + + ), }, { id: 'email', diff --git a/src/components/datagrid/_data_grid_header_row.scss b/src/components/datagrid/_data_grid_header_row.scss index a329f73ea0a..fee61ab8ebb 100644 --- a/src/components/datagrid/_data_grid_header_row.scss +++ b/src/components/datagrid/_data_grid_header_row.scss @@ -22,6 +22,15 @@ &.euiDataGridHeaderCell--currency { text-align: right; } + + &:focus { + border: 1px solid transparent; + margin-top: -1px; + box-shadow: 0 0 0 2px $euiFocusRingColor; + border-radius: 1px; + // Needed so it sits above the other rows + z-index: 2; + } } // Header alternates diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx index a41283c2dde..69a8ae876b6 100644 --- a/src/components/datagrid/data_grid.tsx +++ b/src/components/datagrid/data_grid.tsx @@ -9,6 +9,7 @@ import React, { ReactChild, } from 'react'; import classNames from 'classnames'; +import tabbable from 'tabbable'; import { EuiI18n } from '../i18n'; import { EuiDataGridHeaderRow } from './data_grid_header_row'; import { CommonProps, Omit } from '../common'; @@ -46,6 +47,7 @@ import { useDetectSchema, } from './data_grid_schema'; import { useColumnSorting } from './column_sorting'; +import { EuiMutationObserver } from '../observer/mutation_observer'; // When below this number the grid only shows the full screen button const MINIMUM_WIDTH_FOR_GRID_CONTROLS = 479; @@ -233,6 +235,7 @@ function createKeyDownHandler( props: EuiDataGridProps, visibleColumns: EuiDataGridProps['columns'], focusedCell: [number, number], + headerIsInteractive: boolean, setFocusedCell: (focusedCell: [number, number]) => void ) { return (event: KeyboardEvent) => { @@ -257,7 +260,8 @@ function createKeyDownHandler( case keyCodes.UP: event.preventDefault(); // TODO sort out when a user can arrow up into the column headers - if (y > 0) { + const minimumIndex = headerIsInteractive ? -1 : 0; + if (y > minimumIndex) { setFocusedCell([x, y - 1]); } break; @@ -278,6 +282,38 @@ export const EuiDataGrid: FunctionComponent = props => { const [containerRef, setContainerRef] = useState(null); const [interactiveCellId] = useState(htmlIdGenerator()()); + const [headerIsInteractive, setHeaderIsInteractive] = useState(false); + const handleHeaderChange = useCallback( + records => { + const [{ target }] = records; + + // find the wrapping header div + let headerRow = target.parentElement; + while ( + headerRow && + (headerRow.getAttribute('data-test-subj') || '').indexOf( + 'dataGridHeader' + ) === -1 + ) { + headerRow = headerRow.parentElement; + } + + if (headerRow) { + const hasTabbables = tabbable(headerRow).length > 0; + if (hasTabbables !== headerIsInteractive) { + setHeaderIsInteractive(hasTabbables); + + // if the focus is on the header, and the header is no longer interactive + // move the focus down to the first row + if (hasTabbables === false && focusedCell[1] === -1) { + setFocusedCell([focusedCell[0], 0]); + } + } + } + }, + [headerIsInteractive, setHeaderIsInteractive, focusedCell, setFocusedCell] + ); + const [columnWidths, setColumnWidth] = useColumnWidths(); // enables/disables grid controls based on available width @@ -425,6 +461,7 @@ export const EuiDataGrid: FunctionComponent = props => { props, visibleColumns, focusedCell, + headerIsInteractive, setFocusedCell )} className="euiDataGrid__verticalScroll" @@ -450,14 +487,27 @@ export const EuiDataGrid: FunctionComponent = props => { className="euiDataGrid__content" role="grid" {...gridAriaProps}> - + + {ref => ( + + )} + void; + sorting?: EuiDataGridSorting; + focusedCell: EuiDataGridDataRowProps['focusedCell']; + setFocusedCell: EuiDataGridDataRowProps['onCellFocus']; + headerIsInteractive: boolean; +} type EuiDataGridHeaderRowProps = CommonProps & - HTMLAttributes & { - columns: EuiDataGridColumn[]; - columnWidths: EuiDataGridColumnWidths; - schema: EuiDataGridSchema; - defaultColumnWidth?: number | null; - setColumnWidth: (columnId: string, width: number) => void; - sorting?: EuiDataGridSorting; - }; - -const EuiDataGridHeaderRow: FunctionComponent< - EuiDataGridHeaderRowProps + HTMLAttributes & + EuiDataGridHeaderRowPropsSpecificProps; + +interface EuiDataGridHeaderCellProps + extends Omit { + column: EuiDataGridColumn; + index: number; +} +const EuiDataGridHeaderCell: FunctionComponent< + EuiDataGridHeaderCellProps > = props => { + const { + column, + index, + columnWidths, + schema, + defaultColumnWidth, + setColumnWidth, + sorting, + focusedCell, + setFocusedCell, + headerIsInteractive, + } = props; + const { id, display } = column; + + const width = columnWidths[id] || defaultColumnWidth; + + const ariaProps: { + 'aria-sort'?: HTMLAttributes['aria-sort']; + 'aria-describedby'?: HTMLAttributes['aria-describedby']; + } = {}; + + let screenReaderId; + let sortString; + + if (sorting) { + const sortedColumnIds = new Set(sorting.columns.map(({ id }) => id)); + + if (sorting.columns.length === 1 && sortedColumnIds.has(id)) { + const sortDirection = sorting.columns[0].direction; + + let sortValue: HTMLAttributes['aria-sort'] = 'other'; + if (sortDirection === 'asc') { + sortValue = 'ascending'; + } else if (sortDirection === 'desc') { + sortValue = 'descending'; + } + + ariaProps['aria-sort'] = sortValue; + } else if (sorting.columns.length >= 2 && sortedColumnIds.has(id)) { + sortString = sorting.columns + .map(col => `Sorted by ${col.id} ${col.direction}`) + .join(' then '); + screenReaderId = htmlIdGenerator()(); + ariaProps['aria-describedby'] = screenReaderId; + } + } + + const columnType = schema[id] ? schema[id].columnType : null; + + const classes = classnames('euiDataGridHeaderCell', { + [`euiDataGridHeaderCell--${columnType}`]: columnType, + }); + + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const headerRef = useRef(null); + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + headerRef.current.focus(); + } + + function onFocus(e: FocusEvent) { + if (headerIsInteractive === false) { + // header is not interactive, avoid focusing + requestAnimationFrame(() => headerRef.current!.blur()); + e.preventDefault(); + return false; + } else { + // take the focus + setFocusedCell([index, -1]); + } + } + + headerRef.current.addEventListener('focus', onFocus); + return () => { + headerRef.current!.removeEventListener('focus', onFocus); + }; + } + }, [headerIsInteractive, isFocused, headerRef.current]); + + return ( +
+ {width ? ( + + ) : null} + +
{display || id}
+ {sorting && sorting.columns.length >= 2 && ( + +
{sortString}
+
+ )} +
+ ); +}; + +const EuiDataGridHeaderRow = forwardRef< + HTMLDivElement, + EuiDataGridHeaderRowProps +>((props, ref) => { const { columns, schema, @@ -32,6 +162,9 @@ const EuiDataGridHeaderRow: FunctionComponent< className, setColumnWidth, sorting, + focusedCell, + setFocusedCell, + headerIsInteractive, 'data-test-subj': _dataTestSubj, ...rest } = props; @@ -40,81 +173,29 @@ const EuiDataGridHeaderRow: FunctionComponent< const dataTestSubj = classnames('dataGridHeader', _dataTestSubj); return ( -
- {columns.map(props => { - const { id, display } = props; - - const width = columnWidths[id] || defaultColumnWidth; - - const ariaProps: { - 'aria-sort'?: HTMLAttributes['aria-sort']; - 'aria-describedby'?: HTMLAttributes< - HTMLDivElement - >['aria-describedby']; - } = {}; - - let screenReaderId; - let sortString; - - if (sorting) { - const sortedColumnIds = new Set(sorting.columns.map(({ id }) => id)); - - if (sorting.columns.length === 1 && sortedColumnIds.has(id)) { - const sortDirection = sorting.columns[0].direction; - - let sortValue: HTMLAttributes['aria-sort'] = - 'other'; - if (sortDirection === 'asc') { - sortValue = 'ascending'; - } else if (sortDirection === 'desc') { - sortValue = 'descending'; - } - - ariaProps['aria-sort'] = sortValue; - } else if (sorting.columns.length >= 2 && sortedColumnIds.has(id)) { - sortString = sorting.columns - .map(col => `Sorted by ${col.id} ${col.direction}`) - .join(' then '); - screenReaderId = htmlIdGenerator()(); - ariaProps['aria-describedby'] = screenReaderId; - } - } - - const columnType = schema[id] ? schema[id].columnType : null; - - const classes = classnames('euiDataGridHeaderCell', { - [`euiDataGridHeaderCell--${columnType}`]: columnType, - }); - - return ( -
- {width ? ( - - ) : null} - -
- {display || id} -
- {sorting && sorting.columns.length >= 2 && ( - -
{sortString}
-
- )} -
- ); - })} +
+ {columns.map((column, index) => ( + + ))}
); -}; +}); export { EuiDataGridHeaderRow }; diff --git a/src/components/observer/mutation_observer/mutation_observer.ts b/src/components/observer/mutation_observer/mutation_observer.ts index 0430fa68fbd..0f018d506eb 100644 --- a/src/components/observer/mutation_observer/mutation_observer.ts +++ b/src/components/observer/mutation_observer/mutation_observer.ts @@ -11,6 +11,12 @@ interface Props { export class EuiMutationObserver extends EuiObserver { name = 'EuiMutationObserver'; + // the `onMutation` prop may change while the observer is bound, abstracting + // it out into a separate function means the current `onMutation` value is used + onMutation: MutationCallback = (records, observer) => { + this.props.onMutation(records, observer); + } + beginObserve = () => { // IE11 and the MutationObserver polyfill used in Kibana (for Jest) implement // an older spec in which specifying `attributeOldValue` or `attributeFilter` @@ -27,7 +33,7 @@ export class EuiMutationObserver extends EuiObserver { observerOptions.attributes = true; } - this.observer = new MutationObserver(this.props.onMutation); + this.observer = new MutationObserver(this.onMutation); this.observer.observe(this.childNode!, observerOptions); }; } From e0db8c132111aca584a322a02e73304dc3ab206f Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 14 Oct 2019 11:17:59 -0600 Subject: [PATCH 3/9] Properly wrap cell focus and use [enter], [f2] to interact --- src-docs/src/views/datagrid/datagrid.js | 1 + .../__snapshots__/data_grid.test.tsx.snap | 4 ++ src/components/datagrid/data_grid.tsx | 12 ++++-- .../datagrid/data_grid_header_row.tsx | 42 +++++++++++++++++++ .../mutation_observer/mutation_observer.ts | 2 +- 5 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 08c61122561..0cf6b99a9c7 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -26,6 +26,7 @@ const columns = [ alert('Icon Clicked!')} style={{ position: 'absolute', right: 0 }} /> diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 9a9fa8f64a9..c0292bef991 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -288,6 +288,7 @@ Array [ class="euiDataGridHeaderCell" data-test-subj="dataGridHeaderCell-A" role="columnheader" + tabindex="-1" >
= props => { } if (headerRow) { - const hasTabbables = tabbable(headerRow).length > 0; - if (hasTabbables !== headerIsInteractive) { - setHeaderIsInteractive(hasTabbables); + const tabbables = tabbable(headerRow); + const managed = headerRow.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + const hasInteractives = tabbables.length > 0 || managed.length > 0; + if (hasInteractives !== headerIsInteractive) { + setHeaderIsInteractive(hasInteractives); // if the focus is on the header, and the header is no longer interactive // move the focus down to the first row - if (hasTabbables === false && focusedCell[1] === -1) { + if (hasInteractives === false && focusedCell[1] === -1) { setFocusedCell([focusedCell[0], 0]); } } diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index 86ffc1e664f..e5d6af8d03a 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -6,6 +6,7 @@ import React, { useEffect, } from 'react'; import classnames from 'classnames'; +import tabbable from 'tabbable'; import { EuiDataGridColumnWidths, EuiDataGridColumn, @@ -17,6 +18,7 @@ import { htmlIdGenerator } from '../../services/accessibility'; import { EuiScreenReaderOnly } from '../accessibility'; import { EuiDataGridSchema } from './data_grid_schema'; import { EuiDataGridDataRowProps } from './data_grid_data_row'; +import { keyCodes } from '../../services'; interface EuiDataGridHeaderRowPropsSpecificProps { columns: EuiDataGridColumn[]; @@ -101,6 +103,19 @@ const EuiDataGridHeaderCell: FunctionComponent< if (headerRef.current) { if (isFocused) { headerRef.current.focus(); + const disabledElements = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + for (let i = 0; i < disabledElements.length; i++) { + disabledElements[i].setAttribute('tabIndex', '0'); + } + } else { + const tababbles = tabbable(headerRef.current); + for (let i = 0; i < tababbles.length; i++) { + const element = tababbles[i]; + element.setAttribute('data-euigrid-tab-managed', 'true'); + element.setAttribute('tabIndex', '-1'); + } } function onFocus(e: FocusEvent) { @@ -115,9 +130,36 @@ const EuiDataGridHeaderCell: FunctionComponent< } } + function onKeyUp(e: KeyboardEvent) { + switch (e.keyCode) { + case keyCodes.ENTER: { + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + break; + } + case keyCodes.F2: { + if (document.activeElement === headerRef.current) { + // move focus into cell's interactives + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + // move focus to cell + headerRef.current!.focus(); + } + break; + } + } + } + headerRef.current.addEventListener('focus', onFocus); + headerRef.current.addEventListener('keyup', onKeyUp); return () => { headerRef.current!.removeEventListener('focus', onFocus); + headerRef.current!.removeEventListener('keyup', onKeyUp); }; } }, [headerIsInteractive, isFocused, headerRef.current]); diff --git a/src/components/observer/mutation_observer/mutation_observer.ts b/src/components/observer/mutation_observer/mutation_observer.ts index 0f018d506eb..5aaff0bf759 100644 --- a/src/components/observer/mutation_observer/mutation_observer.ts +++ b/src/components/observer/mutation_observer/mutation_observer.ts @@ -15,7 +15,7 @@ export class EuiMutationObserver extends EuiObserver { // it out into a separate function means the current `onMutation` value is used onMutation: MutationCallback = (records, observer) => { this.props.onMutation(records, observer); - } + }; beginObserve = () => { // IE11 and the MutationObserver polyfill used in Kibana (for Jest) implement From 27e4eff89eaca80f438e9405204a28f5b79544b2 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Mon, 14 Oct 2019 11:59:00 -0600 Subject: [PATCH 4/9] Corrected header cell focus-state on blurring, [escape]. and single interactives --- src-docs/src/views/datagrid/datagrid.js | 17 ++++ .../datagrid/data_grid_header_row.tsx | 94 +++++++++++++++---- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 0cf6b99a9c7..d8be02ea1ea 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -34,6 +34,23 @@ const columns = [ }, { id: 'email', + display: ( +
+ name +
+ alert('Email Icon Clicked!')} + /> + alert('Menu Icon Clicked!')} + /> +
+
+ ), }, { id: 'location', diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index e5d6af8d03a..52fbbc52059 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -4,6 +4,7 @@ import React, { FunctionComponent, useRef, useEffect, + useState, } from 'react'; import classnames from 'classnames'; import tabbable from 'tabbable'; @@ -97,20 +98,23 @@ const EuiDataGridHeaderCell: FunctionComponent< [`euiDataGridHeaderCell--${columnType}`]: columnType, }); - const isFocused = focusedCell[0] === index && focusedCell[1] === -1; const headerRef = useRef(null); + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const [isCellEntered, setIsCellEntered] = useState(false); + useEffect(() => { if (headerRef.current) { - if (isFocused) { - headerRef.current.focus(); - const disabledElements = headerRef.current.querySelectorAll( + function enableInteractives() { + const disabledElements = headerRef.current!.querySelectorAll( '[data-euigrid-tab-managed]' ); for (let i = 0; i < disabledElements.length; i++) { disabledElements[i].setAttribute('tabIndex', '0'); } - } else { - const tababbles = tabbable(headerRef.current); + } + + function disableInteractives() { + const tababbles = tabbable(headerRef.current!); for (let i = 0; i < tababbles.length; i++) { const element = tababbles[i]; element.setAttribute('data-euigrid-tab-managed', 'true'); @@ -118,7 +122,35 @@ const EuiDataGridHeaderCell: FunctionComponent< } } - function onFocus(e: FocusEvent) { + if (isCellEntered) { + enableInteractives(); + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + disableInteractives(); + } + } + }, [isCellEntered]); + + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + const interactives = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + if (interactives.length === 1) { + setIsCellEntered(true); + } else { + headerRef.current.focus(); + } + } else { + setIsCellEntered(false); + } + + // focusin bubbles while focus does not, and this needs to react to children gaining focus + function onFocusIn(e: FocusEvent) { if (headerIsInteractive === false) { // header is not interactive, avoid focusing requestAnimationFrame(() => headerRef.current!.blur()); @@ -130,24 +162,40 @@ const EuiDataGridHeaderCell: FunctionComponent< } } + // focusout bubbles while blur does not, and this needs to react to the children losing focus + function onFocusOut() { + // wait for the next element to receive focus, then update interactives' state + requestAnimationFrame(() => { + if (headerRef.current) { + if (headerRef.current.contains(document.activeElement) === false) { + setIsCellEntered(false); + } + } + }); + } + function onKeyUp(e: KeyboardEvent) { switch (e.keyCode) { case keyCodes.ENTER: { - const tabbables = tabbable(headerRef.current!); - if (tabbables.length > 0) { - tabbables[0].focus(); - } + e.preventDefault(); + setIsCellEntered(true); + break; + } + case keyCodes.ESCAPE: { + e.preventDefault(); + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); break; } case keyCodes.F2: { + e.preventDefault(); if (document.activeElement === headerRef.current) { // move focus into cell's interactives - const tabbables = tabbable(headerRef.current!); - if (tabbables.length > 0) { - tabbables[0].focus(); - } + setIsCellEntered(true); } else { // move focus to cell + setIsCellEntered(false); headerRef.current!.focus(); } break; @@ -155,14 +203,24 @@ const EuiDataGridHeaderCell: FunctionComponent< } } - headerRef.current.addEventListener('focus', onFocus); + // @ts-ignore-next line TS doesn't have focusin + headerRef.current.addEventListener('focusin', onFocusIn); + headerRef.current.addEventListener('focusout', onFocusOut); headerRef.current.addEventListener('keyup', onKeyUp); return () => { - headerRef.current!.removeEventListener('focus', onFocus); + // @ts-ignore-next line TS doesn't have focusin + headerRef.current!.removeEventListener('focusin', onFocusIn); + headerRef.current!.removeEventListener('focusout', onFocusOut); headerRef.current!.removeEventListener('keyup', onKeyUp); }; } - }, [headerIsInteractive, isFocused, headerRef.current]); + }, [ + headerIsInteractive, + isFocused, + headerRef.current, + setIsCellEntered, + setFocusedCell, + ]); return (
Date: Mon, 14 Oct 2019 11:59:00 -0600 Subject: [PATCH 5/9] Corrected header cell focus-state on blurring, [escape]. and single interactives --- src-docs/src/views/datagrid/datagrid.js | 17 ++++ .../datagrid/data_grid_header_row.tsx | 94 +++++++++++++++---- 2 files changed, 93 insertions(+), 18 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 0cf6b99a9c7..d8be02ea1ea 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -34,6 +34,23 @@ const columns = [ }, { id: 'email', + display: ( +
+ name +
+ alert('Email Icon Clicked!')} + /> + alert('Menu Icon Clicked!')} + /> +
+
+ ), }, { id: 'location', diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index e5d6af8d03a..37a2ad2916a 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -4,6 +4,7 @@ import React, { FunctionComponent, useRef, useEffect, + useState, } from 'react'; import classnames from 'classnames'; import tabbable from 'tabbable'; @@ -97,20 +98,23 @@ const EuiDataGridHeaderCell: FunctionComponent< [`euiDataGridHeaderCell--${columnType}`]: columnType, }); - const isFocused = focusedCell[0] === index && focusedCell[1] === -1; const headerRef = useRef(null); + const isFocused = focusedCell[0] === index && focusedCell[1] === -1; + const [isCellEntered, setIsCellEntered] = useState(false); + useEffect(() => { if (headerRef.current) { - if (isFocused) { - headerRef.current.focus(); - const disabledElements = headerRef.current.querySelectorAll( + function enableInteractives() { + const disabledElements = headerRef.current!.querySelectorAll( '[data-euigrid-tab-managed]' ); for (let i = 0; i < disabledElements.length; i++) { disabledElements[i].setAttribute('tabIndex', '0'); } - } else { - const tababbles = tabbable(headerRef.current); + } + + function disableInteractives() { + const tababbles = tabbable(headerRef.current!); for (let i = 0; i < tababbles.length; i++) { const element = tababbles[i]; element.setAttribute('data-euigrid-tab-managed', 'true'); @@ -118,7 +122,35 @@ const EuiDataGridHeaderCell: FunctionComponent< } } - function onFocus(e: FocusEvent) { + if (isCellEntered) { + enableInteractives(); + const tabbables = tabbable(headerRef.current!); + if (tabbables.length > 0) { + tabbables[0].focus(); + } + } else { + disableInteractives(); + } + } + }, [isCellEntered]); + + useEffect(() => { + if (headerRef.current) { + if (isFocused) { + const interactives = headerRef.current.querySelectorAll( + '[data-euigrid-tab-managed]' + ); + if (interactives.length === 1) { + setIsCellEntered(true); + } else { + headerRef.current.focus(); + } + } else { + setIsCellEntered(false); + } + + // focusin bubbles while focus does not, and this needs to react to children gaining focus + function onFocusIn(e: FocusEvent) { if (headerIsInteractive === false) { // header is not interactive, avoid focusing requestAnimationFrame(() => headerRef.current!.blur()); @@ -130,24 +162,40 @@ const EuiDataGridHeaderCell: FunctionComponent< } } + // focusout bubbles while blur does not, and this needs to react to the children losing focus + function onFocusOut() { + // wait for the next element to receive focus, then update interactives' state + requestAnimationFrame(() => { + if (headerRef.current) { + if (headerRef.current.contains(document.activeElement) === false) { + setIsCellEntered(false); + } + } + }); + } + function onKeyUp(e: KeyboardEvent) { switch (e.keyCode) { case keyCodes.ENTER: { - const tabbables = tabbable(headerRef.current!); - if (tabbables.length > 0) { - tabbables[0].focus(); - } + e.preventDefault(); + setIsCellEntered(true); + break; + } + case keyCodes.ESCAPE: { + e.preventDefault(); + // move focus to cell + setIsCellEntered(false); + headerRef.current!.focus(); break; } case keyCodes.F2: { + e.preventDefault(); if (document.activeElement === headerRef.current) { // move focus into cell's interactives - const tabbables = tabbable(headerRef.current!); - if (tabbables.length > 0) { - tabbables[0].focus(); - } + setIsCellEntered(true); } else { // move focus to cell + setIsCellEntered(false); headerRef.current!.focus(); } break; @@ -155,14 +203,24 @@ const EuiDataGridHeaderCell: FunctionComponent< } } - headerRef.current.addEventListener('focus', onFocus); + // @ts-ignore-next line TS doesn't have focusin + headerRef.current.addEventListener('focusin', onFocusIn); + headerRef.current.addEventListener('focusout', onFocusOut); headerRef.current.addEventListener('keyup', onKeyUp); return () => { - headerRef.current!.removeEventListener('focus', onFocus); + // @ts-ignore-next line TS doesn't have focusin + headerRef.current!.removeEventListener('focusin', onFocusIn); + headerRef.current!.removeEventListener('focusout', onFocusOut); headerRef.current!.removeEventListener('keyup', onKeyUp); }; } - }, [headerIsInteractive, isFocused, headerRef.current]); + }, [ + headerIsInteractive, + isFocused, + headerRef.current, + setIsCellEntered, + setFocusedCell, + ]); return (
Date: Tue, 15 Oct 2019 09:19:10 -0600 Subject: [PATCH 6/9] When datagrid header is interactive, default its tabstop to the first header cell --- src-docs/src/views/datagrid/datagrid.js | 2 +- src/components/datagrid/data_grid.tsx | 18 ++++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index d8be02ea1ea..a2bea9bdfff 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -36,7 +36,7 @@ const columns = [ id: 'email', display: (
- name + email
= props => { const [isFullScreen, setIsFullScreen] = useState(false); const [showGridControls, setShowGridControls] = useState(true); - const [focusedCell, setFocusedCell] = useState<[number, number]>(ORIGIN); + const [focusedCell, setFocusedCell] = useState<[number, number] | null>(null); const [containerRef, setContainerRef] = useState(null); const [interactiveCellId] = useState(htmlIdGenerator()()); @@ -309,7 +308,11 @@ export const EuiDataGrid: FunctionComponent = props => { // if the focus is on the header, and the header is no longer interactive // move the focus down to the first row - if (hasInteractives === false && focusedCell[1] === -1) { + if ( + hasInteractives === false && + focusedCell && + focusedCell[1] === -1 + ) { setFocusedCell([focusedCell[0], 0]); } } @@ -432,6 +435,9 @@ export const EuiDataGrid: FunctionComponent = props => { delete rest['aria-labelledby']; } + const realizedFocusedCell: [number, number] = + focusedCell || (headerIsInteractive ? [0, -1] : [0, 0]); + return (
= props => { onKeyDown={createKeyDownHandler( props, visibleColumns, - focusedCell, + realizedFocusedCell, headerIsInteractive, setFocusedCell )} @@ -507,7 +513,7 @@ export const EuiDataGrid: FunctionComponent = props => { schema={mergedSchema} sorting={sorting} headerIsInteractive={headerIsInteractive} - focusedCell={focusedCell} + focusedCell={realizedFocusedCell} setFocusedCell={setFocusedCell} /> )} @@ -520,7 +526,7 @@ export const EuiDataGrid: FunctionComponent = props => { columns={visibleColumns} schema={mergedSchema} expansionFormatters={expansionFormatters} - focusedCell={focusedCell} + focusedCell={realizedFocusedCell} onCellFocus={setFocusedCell} pagination={pagination} sorting={sorting} From a1810278df406b2a6163c1064f096cdf175f04d9 Mon Sep 17 00:00:00 2001 From: Chandler Prall Date: Tue, 15 Oct 2019 11:45:42 -0600 Subject: [PATCH 7/9] EuiDataGridHeaderCell warns about multiple interactive elements --- src-docs/src/views/datagrid/datagrid.js | 20 +++++++------------ .../datagrid/data_grid_header_row.tsx | 13 +++++++++--- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index a2bea9bdfff..62a3f150df7 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -26,7 +26,7 @@ const columns = [ alert('Icon Clicked!')} + onClick={() => alert('Menu Icon Clicked!')} style={{ position: 'absolute', right: 0 }} /> @@ -37,18 +37,12 @@ const columns = [ display: (
email -
- alert('Email Icon Clicked!')} - /> - alert('Menu Icon Clicked!')} - /> -
+ alert('Email Icon Clicked!')} + style={{ position: 'absolute', right: 0 }} + />
), }, diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index 37a2ad2916a..db1e2650c35 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -105,16 +105,23 @@ const EuiDataGridHeaderCell: FunctionComponent< useEffect(() => { if (headerRef.current) { function enableInteractives() { - const disabledElements = headerRef.current!.querySelectorAll( + const interactiveElements = headerRef.current!.querySelectorAll( '[data-euigrid-tab-managed]' ); - for (let i = 0; i < disabledElements.length; i++) { - disabledElements[i].setAttribute('tabIndex', '0'); + for (let i = 0; i < interactiveElements.length; i++) { + interactiveElements[i].setAttribute('tabIndex', '0'); } } function disableInteractives() { const tababbles = tabbable(headerRef.current!); + if (tababbles.length > 1) { + console.warn( + `EuiDataGridHeaderCell expects at most 1 tabbable element, ${ + tababbles.length + } found instead` + ); + } for (let i = 0; i < tababbles.length; i++) { const element = tababbles[i]; element.setAttribute('data-euigrid-tab-managed', 'true'); From 54616b67962061269986918f6c41e422003ae2dc Mon Sep 17 00:00:00 2001 From: Dave Snider Date: Tue, 15 Oct 2019 15:37:50 -0700 Subject: [PATCH 8/9] fix focus, example and screenreader stuffs, looks like tests pass --- src-docs/src/views/datagrid/datagrid.js | 36 +++++++++---------- .../__snapshots__/data_grid.test.tsx.snap | 12 +++++++ .../datagrid/_data_grid_header_row.scss | 17 +++++++-- .../datagrid/data_grid_header_row.tsx | 6 +++- 4 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src-docs/src/views/datagrid/datagrid.js b/src-docs/src/views/datagrid/datagrid.js index 62a3f150df7..24702c0c448 100644 --- a/src-docs/src/views/datagrid/datagrid.js +++ b/src-docs/src/views/datagrid/datagrid.js @@ -13,6 +13,8 @@ import { EuiLink, EuiPopover, EuiSpacer, + EuiFlexGroup, + EuiFlexItem, } from '../../../../src/components/'; import { EuiRadioGroup } from '../../../../src/components/form/radio'; import { EuiButtonIcon } from '../../../../src/components/button/button_icon'; @@ -20,30 +22,24 @@ import { EuiButtonIcon } from '../../../../src/components/button/button_icon'; const columns = [ { id: 'name', - display: ( - - name - alert('Menu Icon Clicked!')} - style={{ position: 'absolute', right: 0 }} - /> - - ), }, { id: 'email', display: ( -
- email - alert('Email Icon Clicked!')} - style={{ position: 'absolute', right: 0 }} - /> -
+ // This is an example of an icon next to a title that still respects text truncate + + +
email
+
+ + alert('Email Icon Clicked!')} + /> + +
), }, { diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index c0292bef991..8f8ce0d3359 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -285,24 +285,30 @@ Array [ role="row" >
Column A
@@ -812,24 +818,30 @@ Array [ role="row" >
A
B diff --git a/src/components/datagrid/_data_grid_header_row.scss b/src/components/datagrid/_data_grid_header_row.scss index fee61ab8ebb..d1d4a8d91ee 100644 --- a/src/components/datagrid/_data_grid_header_row.scss +++ b/src/components/datagrid/_data_grid_header_row.scss @@ -8,11 +8,20 @@ font-weight: $euiFontWeightBold; padding: $euiDataGridCellPaddingM; - position: relative; flex: 0 0 auto; + position: relative; + align-items: center; + display: flex; + + > * { + max-width: 100%; + width: 100%; + } .euiDataGridHeaderCell__content { @include euiTextTruncate; + overflow: hidden; + white-space: nowrap; } &.euiDataGridHeaderCell--numeric { @@ -25,7 +34,6 @@ &:focus { border: 1px solid transparent; - margin-top: -1px; box-shadow: 0 0 0 2px $euiFocusRingColor; border-radius: 1px; // Needed so it sits above the other rows @@ -38,8 +46,11 @@ @include euiDataGridStyles(headerUnderline) { @include euiDataGridHeaderCell { + border-top: none; + border-left: none; + border-right: none; border-bottom: $euiBorderThick; - border-color: $euiTextColor; + border-bottom-color: $euiTextColor; } } diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index db1e2650c35..5c445d76996 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -234,6 +234,8 @@ const EuiDataGridHeaderCell: FunctionComponent< role="columnheader" {...ariaProps} key={id} + id={id} + aria-label={`Column header ${index + 1}:`} ref={headerRef} tabIndex={isFocused ? 0 : -1} className={classes} @@ -247,7 +249,9 @@ const EuiDataGridHeaderCell: FunctionComponent< /> ) : null} -
{display || id}
+
+ {display || id} +
{sorting && sorting.columns.length >= 2 && (
{sortString}
From f13c010ff0398a9dae4ab34686bf4d95d1708af1 Mon Sep 17 00:00:00 2001 From: Michail Yasonik Date: Wed, 16 Oct 2019 15:36:29 +0530 Subject: [PATCH 9/9] simplifying screen reader read out --- .../datagrid/__snapshots__/data_grid.test.tsx.snap | 12 ------------ src/components/datagrid/data_grid_header_row.tsx | 6 +----- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap index 8f8ce0d3359..c0292bef991 100644 --- a/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap +++ b/src/components/datagrid/__snapshots__/data_grid.test.tsx.snap @@ -285,30 +285,24 @@ Array [ role="row" >
Column A
@@ -818,30 +812,24 @@ Array [ role="row" >
A
B diff --git a/src/components/datagrid/data_grid_header_row.tsx b/src/components/datagrid/data_grid_header_row.tsx index 5c445d76996..db1e2650c35 100644 --- a/src/components/datagrid/data_grid_header_row.tsx +++ b/src/components/datagrid/data_grid_header_row.tsx @@ -234,8 +234,6 @@ const EuiDataGridHeaderCell: FunctionComponent< role="columnheader" {...ariaProps} key={id} - id={id} - aria-label={`Column header ${index + 1}:`} ref={headerRef} tabIndex={isFocused ? 0 : -1} className={classes} @@ -249,9 +247,7 @@ const EuiDataGridHeaderCell: FunctionComponent< /> ) : null} -
- {display || id} -
+
{display || id}
{sorting && sorting.columns.length >= 2 && (
{sortString}