diff --git a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts index 0346f3bb9439b..32a8c301d0265 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/timeline/events/details/index.ts @@ -9,6 +9,7 @@ import { Inspect, Maybe } from '../../../common'; import { TimelineRequestOptionsPaginated } from '../..'; export interface TimelineEventsDetailsItem { + ariaRowindex?: Maybe; category?: string; field: string; values?: Maybe; diff --git a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx index 8ec5133ef48b0..d2993fa63937d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/timeline_actions/add_to_case_action.tsx @@ -25,11 +25,16 @@ import { useCreateCaseModal } from '../use_create_case_modal'; import { useAllCasesModal } from '../use_all_cases_modal'; interface AddToCaseActionProps { + ariaLabel?: string; ecsRowData: Ecs; disabled: boolean; } -const AddToCaseActionComponent: React.FC = ({ ecsRowData, disabled }) => { +const AddToCaseActionComponent: React.FC = ({ + ariaLabel = i18n.ACTION_ADD_TO_CASE_ARIA_LABEL, + ecsRowData, + disabled, +}) => { const eventId = ecsRowData._id; const eventIndex = ecsRowData._index; @@ -120,7 +125,7 @@ const AddToCaseActionComponent: React.FC = ({ ecsRowData, content={i18n.ACTION_ADD_TO_CASE_TOOLTIP} > = ({ ecsRowData, /> ), - [disabled, openPopover] + [ariaLabel, disabled, openPopover] ); return ( diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.ts new file mode 100644 index 0000000000000..c099a1413a88f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ariaIndexToArrayIndex, arrayIndexToAriaIndex } from './helpers'; + +describe('helpers', () => { + describe('ariaIndexToArrayIndex', () => { + it('returns the expected array index', () => { + expect(ariaIndexToArrayIndex(1)).toEqual(0); + }); + }); + + describe('arrayIndexToAriaIndex', () => { + it('returns the expected aria index', () => { + expect(arrayIndexToAriaIndex(0)).toEqual(1); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts b/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts new file mode 100644 index 0000000000000..d8603c9d02fcb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/helpers.ts @@ -0,0 +1,817 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME } from '../drag_and_drop/helpers'; +import { HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME } from '../with_hover_actions'; + +/** + * The name of the ARIA attribute representing a column, used in conjunction with + * the ARIA: grid role https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + */ +export const ARIA_COLINDEX_ATTRIBUTE = 'aria-colindex'; + +/** + * This alternative attribute to `aria-colindex` is used to decorate the data + * in existing `EuiTable`s to enable keyboard navigation with minimal + * refactoring of existing code until we're ready to migrate to `EuiDataGrid`. + * It may be applied directly to keyboard-focusable elements and thus doesn't + * have exactly the same semantics as `aria-colindex`. + */ +export const DATA_COLINDEX_ATTRIBUTE = 'data-colindex'; + +/** + * The name of the ARIA attribute representing a row, used in conjunction with + * the ARIA: grid role https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + */ +export const ARIA_ROWINDEX_ATTRIBUTE = 'aria-rowindex'; + +/** + * This alternative attribute to `aria-rowindex` is used to decorate the data + * in existing `EuiTable`s to enable keyboard navigation with minimal + * refactoring of existing code until we're ready to migrate to `EuiDataGrid`. + * It's typically applied to `` elements via `EuiTable`'s `rowProps` prop. + */ +export const DATA_ROWINDEX_ATTRIBUTE = 'data-rowindex'; + +/** `aria-colindex` and `aria-rowindex` start at one */ +export const FIRST_ARIA_INDEX = 1; + +/** Converts an aria index, which starts at one, to an array index, which starts at zero */ +export const ariaIndexToArrayIndex = (ariaIndex: number) => ariaIndex - 1; + +/** Converts an array index, which starts at zero, to an aria index, which starts at one */ +export const arrayIndexToAriaIndex = (arrayIndex: number) => arrayIndex + 1; + +/** Returns `true` if the left or right arrow was pressed */ +export const isArrowRightOrArrowLeft = (event: React.KeyboardEvent): boolean => + event.key === 'ArrowLeft' || event.key === 'ArrowRight'; + +/** Returns `true` if the down arrow key was pressed */ +export const isArrowDown = (event: React.KeyboardEvent): boolean => event.key === 'ArrowDown'; + +/** Returns `true` if the up arrow key was pressed */ +export const isArrowUp = (event: React.KeyboardEvent): boolean => event.key === 'ArrowUp'; + +/** Returns `true` if the down or up arrow was pressed */ +export const isArrowDownOrArrowUp = (event: React.KeyboardEvent): boolean => + isArrowDown(event) || isArrowUp(event); + +/** Returns `true` if an arrow key was pressed */ +export const isArrowKey = (event: React.KeyboardEvent): boolean => + isArrowRightOrArrowLeft(event) || isArrowDownOrArrowUp(event); + +/** Returns `true` if the escape key was pressed */ +export const isEscape = (event: React.KeyboardEvent): boolean => event.key === 'Escape'; + +/** Returns `true` if the home key was pressed */ +export const isHome = (event: React.KeyboardEvent): boolean => event.key === 'Home'; + +/** Returns `true` if the end key was pressed */ +export const isEnd = (event: React.KeyboardEvent): boolean => event.key === 'End'; + +/** Returns `true` if the home or end key was pressed */ +export const isHomeOrEnd = (event: React.KeyboardEvent): boolean => isHome(event) || isEnd(event); + +/** Returns `true` if the page up key was pressed */ +export const isPageUp = (event: React.KeyboardEvent): boolean => event.key === 'PageUp'; + +/** Returns `true` if the page down key was pressed */ +export const isPageDown = (event: React.KeyboardEvent): boolean => event.key === 'PageDown'; + +/** Returns `true` if the page up or page down key was pressed */ +export const isPageDownOrPageUp = (event: React.KeyboardEvent): boolean => + isPageDown(event) || isPageUp(event); + +/** Returns `true` if the tab key was pressed */ +export const isTab = (event: React.KeyboardEvent): boolean => event.key === 'Tab'; + +/** Returns `previous` or `next`, depending on which arrow key was pressed */ +export const getFocusOnFromArrowKey = (event: React.KeyboardEvent): 'previous' | 'next' => + event.key === 'ArrowUp' || event.key === 'ArrowLeft' ? 'previous' : 'next'; + +/** + * Returns the column that directly owns focus, or contains a focused element, + * using the `aria-colindex` attribute. + */ +export const getFocusedColumn = ({ + colindexAttribute, + element, +}: { + colindexAttribute: string; + element: Element | null; +}): Element | null => { + return element?.querySelector(`[${colindexAttribute}]:focus-within`) ?? null; +}; + +/** Returns the numeric `aria-colindex` of the specified element */ +export const getColindex = ({ + colindexAttribute, + element, +}: { + colindexAttribute: string; + element: Element | null; +}): number | null => + element?.getAttribute(colindexAttribute) != null + ? Number(element?.getAttribute(colindexAttribute)) + : null; + +/** Returns the row that directly owns focus, or contains a focused element */ +export const getFocusedRow = ({ + rowindexAttribute, + element, +}: { + rowindexAttribute: string; + element: Element | null; +}): Element | null => element?.querySelector(`[${rowindexAttribute}]:focus-within`) ?? null; + +/** Returns the numeric `aria-rowindex` of the specified element */ +export const getRowindex = ({ + rowindexAttribute, + element, +}: { + rowindexAttribute: string; + element: Element | null; +}): number | null => + element?.getAttribute(rowindexAttribute) != null + ? Number(element?.getAttribute(rowindexAttribute)) + : null; + +/** Returns the row with the specified `aria-rowindex` */ +export const getRowByAriaRowindex = ({ + ariaRowindex, + element, + rowindexAttribute, +}: { + ariaRowindex: number; + element: Element | null; + rowindexAttribute: string; +}): HTMLDivElement | null => + element?.querySelector(`[${rowindexAttribute}="${ariaRowindex}"]`) ?? null; + +/** Returns the `previous` or `next` `aria-colindex` relative to the currently focused `aria-colindex` */ +export const getNewAriaColindex = ({ + focusedAriaColindex, + focusOn, + maxAriaColindex, +}: { + focusedAriaColindex: number; + focusOn: 'previous' | 'next'; + maxAriaColindex: number; +}): number => { + const newAriaColindex = + focusOn === 'previous' ? focusedAriaColindex - 1 : focusedAriaColindex + 1; + + if (newAriaColindex < FIRST_ARIA_INDEX) { + return FIRST_ARIA_INDEX; + } + + if (newAriaColindex > maxAriaColindex) { + return maxAriaColindex; + } + + return newAriaColindex; +}; + +/** Returns the element at the specified `aria-colindex` */ +export const getElementWithMatchingAriaColindex = ({ + ariaColindex, + colindexAttribute, + element, +}: { + ariaColindex: number; + colindexAttribute: string; + element: HTMLDivElement | null; +}): HTMLDivElement | null => { + if (element?.getAttribute(colindexAttribute) === `${ariaColindex}`) { + return element; // the current element has it + } + + return element?.querySelector(`[${colindexAttribute}="${ariaColindex}"]`) ?? null; +}; + +/** Returns the `previous` or `next` `aria-rowindex` relative to the currently focused `aria-rowindex` */ +export const getNewAriaRowindex = ({ + focusedAriaRowindex, + focusOn, + maxAriaRowindex, +}: { + focusedAriaRowindex: number; + focusOn: 'previous' | 'next'; + maxAriaRowindex: number; +}): number => { + const newAriaRowindex = + focusOn === 'previous' ? focusedAriaRowindex - 1 : focusedAriaRowindex + 1; + + if (newAriaRowindex < FIRST_ARIA_INDEX) { + return FIRST_ARIA_INDEX; + } + + if (newAriaRowindex > maxAriaRowindex) { + return maxAriaRowindex; + } + + return newAriaRowindex; +}; + +/** Returns the first `aria-rowindex` if the home key is pressed, otherwise the last `aria-rowindex` is returned */ +export const getFirstOrLastAriaRowindex = ({ + event, + maxAriaRowindex, +}: { + event: React.KeyboardEvent; + maxAriaRowindex: number; +}): number => (isHome(event) ? FIRST_ARIA_INDEX : maxAriaRowindex); + +interface FocusColumnResult { + newFocusedColumn: HTMLDivElement | null; + newFocusedColumnAriaColindex: number | null; +} + +/** + * SIDE EFFECT: mutates the DOM by focusing the specified column + * returns the `aria-colindex` of the newly-focused column + */ +export const focusColumn = ({ + colindexAttribute, + containerElement, + ariaColindex, + ariaRowindex, + rowindexAttribute, +}: { + colindexAttribute: string; + containerElement: Element | null; + ariaColindex: number; + ariaRowindex: number; + rowindexAttribute: string; +}): FocusColumnResult => { + if (containerElement == null) { + return { + newFocusedColumnAriaColindex: null, + newFocusedColumn: null, + }; + } + + const row = getRowByAriaRowindex({ ariaRowindex, element: containerElement, rowindexAttribute }); + + const column = getElementWithMatchingAriaColindex({ + ariaColindex, + colindexAttribute, + element: row, + }); + + if (column != null) { + column.focus(); // DOM mutation side effect + return { + newFocusedColumnAriaColindex: ariaColindex, + newFocusedColumn: column, + }; + } + + return { + newFocusedColumnAriaColindex: null, + newFocusedColumn: null, + }; +}; + +export type OnColumnFocused = ({ + newFocusedColumn, + newFocusedColumnAriaColindex, +}: { + newFocusedColumn: HTMLDivElement | null; + newFocusedColumnAriaColindex: number | null; +}) => void; + +/** + * This function implements arrow key support for the `onKeyDownFocusHandler`. + * + * See the `Keyboard Support` section of + * https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + * for details + */ +export const onArrowKeyDown = ({ + colindexAttribute, + containerElement, + event, + focusedAriaColindex, + focusedAriaRowindex, + maxAriaColindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, +}: { + colindexAttribute: string; + containerElement: HTMLElement | null; + event: React.KeyboardEvent; + focusedAriaColindex: number; + focusedAriaRowindex: number; + maxAriaColindex: number; + maxAriaRowindex: number; + onColumnFocused?: OnColumnFocused; + rowindexAttribute: string; +}) => { + const ariaColindex = isArrowRightOrArrowLeft(event) + ? getNewAriaColindex({ + focusedAriaColindex, + focusOn: getFocusOnFromArrowKey(event), + maxAriaColindex, + }) + : focusedAriaColindex; + + const ariaRowindex = isArrowDownOrArrowUp(event) + ? getNewAriaRowindex({ + focusedAriaRowindex, + focusOn: getFocusOnFromArrowKey(event), + maxAriaRowindex, + }) + : focusedAriaRowindex; + + const { newFocusedColumn, newFocusedColumnAriaColindex } = focusColumn({ + ariaColindex, + ariaRowindex, + colindexAttribute, + containerElement, + rowindexAttribute, + }); + + if (onColumnFocused != null && newFocusedColumnAriaColindex != null) { + onColumnFocused({ newFocusedColumn, newFocusedColumnAriaColindex }); + } +}; + +/** + * This function implements `home` and `end` key support for the `onKeyDownFocusHandler`. + * + * See the `Keyboard Support` section of + * https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + * for details + */ +export const onHomeEndDown = ({ + colindexAttribute, + containerElement, + event, + focusedAriaRowindex, + maxAriaColindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, +}: { + colindexAttribute: string; + containerElement: HTMLElement | null; + event: React.KeyboardEvent; + focusedAriaRowindex: number; + maxAriaColindex: number; + maxAriaRowindex: number; + onColumnFocused?: OnColumnFocused; + rowindexAttribute: string; +}) => { + const ariaColindex = isHome(event) ? FIRST_ARIA_INDEX : maxAriaColindex; + + const ariaRowindex = event.ctrlKey + ? getFirstOrLastAriaRowindex({ event, maxAriaRowindex }) + : focusedAriaRowindex; + + const { newFocusedColumn, newFocusedColumnAriaColindex } = focusColumn({ + ariaColindex, + ariaRowindex, + colindexAttribute, + containerElement, + rowindexAttribute, + }); + + if (isHome(event) && event.ctrlKey) { + containerElement?.scrollTo(0, 0); + } + + if (onColumnFocused != null && newFocusedColumnAriaColindex != null) { + onColumnFocused({ newFocusedColumn, newFocusedColumnAriaColindex }); + } +}; + +/** Returns `true` if the specified row is completely visible in the container */ +const isRowCompletelyScrolledIntoView = ({ + container, + row, +}: { + container: DOMRect; + row: HTMLDivElement; +}) => { + const rect = row.getBoundingClientRect(); + const top = rect.top; + const bottom = rect.bottom; + + return top >= container.top && bottom <= container.bottom; +}; + +export const getFirstNonVisibleAriaRowindex = ({ + focusedAriaRowindex, + element, + event, + maxAriaRowindex, + rowindexAttribute, +}: { + focusedAriaRowindex: number; + element: HTMLDivElement | null; + event: React.KeyboardEvent; + maxAriaRowindex: number; + rowindexAttribute: string; +}): number => { + const defaultAriaRowindex = isPageUp(event) ? FIRST_ARIA_INDEX : maxAriaRowindex; // default to the first or last row + + if (element === null) { + return defaultAriaRowindex; + } + + const container = element.getBoundingClientRect(); + const rows = Array.from(element.querySelectorAll(`[${rowindexAttribute}]`) ?? []); + + if (isPageUp(event)) { + return arrayIndexToAriaIndex( + rows.reduceRight( + (found, row, i) => + i < ariaIndexToArrayIndex(focusedAriaRowindex) && + found === ariaIndexToArrayIndex(defaultAriaRowindex) && + !isRowCompletelyScrolledIntoView({ container, row }) + ? i + : found, + ariaIndexToArrayIndex(defaultAriaRowindex) + ) + ); + } else if (isPageDown(event)) { + return arrayIndexToAriaIndex( + rows.reduce( + (found, row, i) => + i > ariaIndexToArrayIndex(focusedAriaRowindex) && + found === ariaIndexToArrayIndex(defaultAriaRowindex) && + !isRowCompletelyScrolledIntoView({ container, row }) + ? i + : found, + ariaIndexToArrayIndex(defaultAriaRowindex) + ) + ); + } else { + return defaultAriaRowindex; + } +}; + +/** + * This function implements `page down` and `page up` key support for the `onKeyDownFocusHandler`. + * + * See the `Keyboard Support` section of + * https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + * for details + */ +export const onPageDownOrPageUp = ({ + colindexAttribute, + containerElement, + event, + focusedAriaColindex, + focusedAriaRowindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, +}: { + colindexAttribute: string; + containerElement: HTMLDivElement | null; + event: React.KeyboardEvent; + focusedAriaColindex: number; + focusedAriaRowindex: number; + maxAriaRowindex: number; + onColumnFocused?: OnColumnFocused; + rowindexAttribute: string; +}) => { + const ariaRowindex = getFirstNonVisibleAriaRowindex({ + element: containerElement, + event, + focusedAriaRowindex, + maxAriaRowindex, + rowindexAttribute, + }); + + const { newFocusedColumn, newFocusedColumnAriaColindex } = focusColumn({ + ariaColindex: focusedAriaColindex, + ariaRowindex, + colindexAttribute, + containerElement, + rowindexAttribute, + }); + + if (onColumnFocused != null) { + onColumnFocused({ newFocusedColumn, newFocusedColumnAriaColindex }); + } +}; + +/** + * This function has side effects: It stops propagation of the provided + * `KeyboardEvent` and prevents the browser's default behavior. + */ +export const stopPropagationAndPreventDefault = (event: React.KeyboardEvent) => { + event.stopPropagation(); + event.preventDefault(); +}; + +/** + * This function adds keyboard accessability to any `containerElement` that + * renders its rows with support for `aria-colindex` and `aria-rowindex`. + * + * To use this function, invoke it in the `onKeyDown` handler of the specified + * `containerElement`. + * + * See the `Keyboard Support` section of + * https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html + * for details of the behavior. + */ +export const onKeyDownFocusHandler = ({ + colindexAttribute, + containerElement, + event, + maxAriaColindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, +}: { + colindexAttribute: string; + containerElement: HTMLDivElement | null; + event: React.KeyboardEvent; + maxAriaColindex: number; + maxAriaRowindex: number; + onColumnFocused: OnColumnFocused; + rowindexAttribute: string; +}) => { + // NOTE: When a row has focus, but none of the columns in that row have focus + // because, for example, the row renderer contained by the row has focus, we + // default `focusedAriaColindex` to be the first non-action column: + const focusedAriaColindex = + getColindex({ + colindexAttribute, + element: getFocusedColumn({ colindexAttribute, element: containerElement }), + }) ?? FIRST_ARIA_INDEX; + const focusedAriaRowindex = getRowindex({ + rowindexAttribute, + element: getFocusedRow({ + rowindexAttribute, + element: containerElement, + }), + }); + + if (focusedAriaColindex != null && focusedAriaRowindex != null) { + if (isArrowKey(event)) { + stopPropagationAndPreventDefault(event); + + onArrowKeyDown({ + colindexAttribute, + containerElement, + event, + focusedAriaColindex, + focusedAriaRowindex, + maxAriaColindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, + }); + } else if (isHomeOrEnd(event)) { + stopPropagationAndPreventDefault(event); + + onHomeEndDown({ + colindexAttribute, + containerElement, + event, + focusedAriaRowindex, + maxAriaColindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, + }); + } else if (isPageDownOrPageUp(event)) { + stopPropagationAndPreventDefault(event); + + onPageDownOrPageUp({ + colindexAttribute, + containerElement, + event, + focusedAriaColindex, + focusedAriaRowindex, + maxAriaRowindex, + onColumnFocused, + rowindexAttribute, + }); + } + } +}; + +/** + * An `onFocus` event handler that focuses the first child draggable + * keyboard handler + */ +export const onFocusReFocusDraggable = (event: React.FocusEvent) => + event.target.querySelector(`.${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}`)?.focus(); + +/** Returns `true` when the element, or one of it's children has focus */ +export const elementOrChildrenHasFocus = (element: HTMLElement | null | undefined): boolean => + element === document.activeElement || element?.querySelector(':focus-within') != null; + +export type FocusableElement = + | HTMLAnchorElement + | HTMLAreaElement + | HTMLAudioElement + | HTMLButtonElement + | HTMLDivElement + | HTMLFormElement + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement + | HTMLVideoElement; + +/** + * This function has a side effect. It focuses the first element with a + * matching `className` in the `containerElement`. + */ +export const skipFocusInContainerTo = ({ + containerElement, + className, +}: { + containerElement: HTMLElement | null; + className: string; +}) => containerElement?.querySelector(`.${className}`)?.focus(); + +/** + * Returns a table cell's focusable children, which may be one of the following + * a) a `HTMLButtonElement` that does NOT have the `disabled` attribute + * b) an element with the `DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME` + */ +export const getFocusableChidren = (cell: HTMLElement | null) => + Array.from( + cell?.querySelectorAll( + `button:not([disabled]), button:not([tabIndex="-1"]), .${DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME}` + ) ?? [] + ); + +export type SKIP_FOCUS_BACKWARDS = 'SKIP_FOCUS_BACKWARDS'; +export type SKIP_FOCUS_FORWARD = 'SKIP_FOCUS_FORWARD'; +export type SKIP_FOCUS_NOOP = 'SKIP_FOCUS_NOOP'; +export type SkipFocus = SKIP_FOCUS_BACKWARDS | SKIP_FOCUS_FORWARD | SKIP_FOCUS_NOOP; + +/** + * If the value of `skipFocus` is `SKIP_FOCUS_BACKWARDS` or `SKIP_FOCUS_FORWARD` + * this function will invoke the provided `onSkipFocusBackwards` or + * `onSkipFocusForward` functions respectively. + * + * If `skipFocus` is `SKIP_FOCUS_NOOP`, the `onSkipFocusBackwards` and + * `onSkipFocusForward` functions will not be invoked. + */ +export const handleSkipFocus = ({ + onSkipFocusBackwards, + onSkipFocusForward, + skipFocus, +}: { + onSkipFocusBackwards: () => void; + onSkipFocusForward: () => void; + skipFocus: SkipFocus; +}): void => { + switch (skipFocus) { + case 'SKIP_FOCUS_BACKWARDS': + onSkipFocusBackwards(); + break; + case 'SKIP_FOCUS_FORWARD': + onSkipFocusForward(); + break; + case 'SKIP_FOCUS_NOOP': // fall through to the default, which does nothing + default: + break; + } +}; + +/** + * The provided `focusedCell` may contain multiple focusable children. For, + * example, the cell may contain multiple `HTMLButtonElement`s that represent + * actions, or the cell may contain multiple draggables. + * + * This function returns `true` when there are still more children of the cell + * that should receive focus when the tab key is pressed. + * + * When this function returns `true`, the caller should NOT move focus away + * from the table. Instead, the browser's "natural" focus management should be + * allowed to automatically focus the next (or previous) focusable child of the + * cell. + */ +export const focusedCellHasMoreFocusableChildren = ({ + focusedCell, + shiftKey, +}: { + focusedCell: HTMLElement | null; + shiftKey: boolean; +}): boolean => { + const focusableChildren = getFocusableChidren(focusedCell); + + if (focusableChildren.length === 0) { + return false; // there no children to focus + } + + const firstOrLastChild = shiftKey + ? focusableChildren[0] + : focusableChildren[focusableChildren.length - 1]; + + return firstOrLastChild !== document.activeElement; +}; + +/** + * Returns `true` when the provided `focusedCell` has always-open hover + * content (i.e. a hover menu) + * + * When this function returns true, the caller should `NOT` move focus away + * from the table. Instead, the browser's "natural" focus management should + * be allowed to manage focus between the table and the hover content. + */ +export const focusedCellHasAlwaysOpenHoverContent = (focusedCell: HTMLElement | null): boolean => + focusedCell?.querySelector(`.${HOVER_ACTIONS_ALWAYS_SHOW_CLASS_NAME}`) != null; + +export type GetFocusedCell = ({ + containerElement, + tableClassName, +}: { + containerElement: HTMLElement | null; + tableClassName: string; +}) => HTMLDivElement | null; + +/** + * Returns true if the focused cell is a plain, non-action `columnheader` + */ +export const focusedCellIsPlainColumnHeader = (focusedCell: HTMLDivElement | null): boolean => + focusedCell?.getAttribute('role') === 'columnheader' && + !focusedCell?.classList.contains('siemEventsTable__thGroupActions'); + +/** + * This function, which works with tables that use the `aria-colindex` or + * `data-colindex` attributes, examines the focus state of the table, and + * returns a `SkipFocus` enumeration. + * + * The `SkipFocus` return value indicates whether the caller should skip focus + * to "before" the table, "after" the table, or take no action, and let the + * browser's "natural" focus management manage focus. + */ +export const getTableSkipFocus = ({ + containerElement, + getFocusedCell, + shiftKey, + tableHasFocus, + tableClassName, +}: { + containerElement: HTMLElement | null; + getFocusedCell: GetFocusedCell; + shiftKey: boolean; + tableHasFocus: (containerElement: HTMLElement | null) => boolean; + tableClassName: string; +}): SkipFocus => { + if (tableHasFocus(containerElement)) { + const focusedCell = getFocusedCell({ containerElement, tableClassName }); + + if (focusedCell == null) { + return 'SKIP_FOCUS_NOOP'; // no cells have focus, often because something with a `dialog` role has focus + } + + if ( + focusedCellHasMoreFocusableChildren({ focusedCell, shiftKey }) && + !focusedCellIsPlainColumnHeader(focusedCell) + ) { + return 'SKIP_FOCUS_NOOP'; // the focused cell still has focusable children + } + + if (focusedCellHasAlwaysOpenHoverContent(focusedCell)) { + return 'SKIP_FOCUS_NOOP'; // the focused cell has always-open hover content + } + + return shiftKey ? 'SKIP_FOCUS_BACKWARDS' : 'SKIP_FOCUS_FORWARD'; // the caller should skip focus "before" or "after" the table + } + + return 'SKIP_FOCUS_NOOP'; // the table does NOT have focus +}; + +/** + * Returns the focused cell for tables that use `aria-colindex` + */ +export const getFocusedAriaColindexCell: GetFocusedCell = ({ + containerElement, + tableClassName, +}: { + containerElement: HTMLElement | null; + tableClassName: string; +}): HTMLDivElement | null => + containerElement?.querySelector( + `.${tableClassName} [aria-colindex]:focus-within` + ) ?? null; + +/** + * Returns the focused cell for tables that use `data-colindex` + */ +export const getFocusedDataColindexCell: GetFocusedCell = ({ + containerElement, + tableClassName, +}: { + containerElement: HTMLElement | null; + tableClassName: string; +}): HTMLDivElement | null => + containerElement?.querySelector( + `.${tableClassName} [data-colindex]:focus-within` + ) ?? null; diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx new file mode 100644 index 0000000000000..807953c51a42c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/index.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiScreenReaderOnly, EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as i18n from './translations'; + +interface Props { + additionalScreenReaderOnlyContext?: string; + content: React.ReactNode; + shortcut: string; + showShortcut: boolean; +} + +const TooltipWithKeyboardShortcutComponent = ({ + additionalScreenReaderOnlyContext = '', + content, + shortcut, + showShortcut, +}: Props) => ( + <> +
{content}
+ {additionalScreenReaderOnlyContext !== '' && ( + +

{additionalScreenReaderOnlyContext}

+
+ )} + {showShortcut && ( + + {i18n.PRESS} + {'\u00a0'} + {shortcut} + + )} + +); + +export const TooltipWithKeyboardShortcut = React.memo(TooltipWithKeyboardShortcutComponent); +TooltipWithKeyboardShortcut.displayName = 'TooltipWithKeyboardShortcut'; diff --git a/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/translations.ts b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/translations.ts new file mode 100644 index 0000000000000..71bef95c6e127 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/accessibility/tooltip_with_keyboard_shortcut/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const PRESS = i18n.translate( + 'xpack.securitySolution.accessibility.tooltipWithKeyboardShortcut.pressTooltipLabel', + { + defaultMessage: 'Press', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx index aa638abf65f7e..327e42205daa7 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/barchart.test.tsx @@ -18,6 +18,15 @@ import '../../mock/react_beautiful_dnd'; import { BarChartBaseComponent, BarChartComponent } from './barchart'; import { ChartSeriesData } from './common'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line react/display-name + EuiScreenReaderOnly: () => <>, + }; +}); + jest.mock('../../lib/kibana'); jest.mock('uuid', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx index ffc2404bd4321..b4e87c2f7abe6 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend.test.tsx @@ -16,6 +16,15 @@ import { TestProviders } from '../../mock'; import { MIN_LEGEND_HEIGHT, DraggableLegend } from './draggable_legend'; import { LegendItem } from './draggable_legend_item'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line react/display-name + EuiScreenReaderOnly: () => <>, + }; +}); + const theme = () => ({ eui: euiDarkVars, darkMode: true }); const allOthersDataProviderId = diff --git a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx index 72e44da3297ea..0fc7c2fb02c2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/charts/draggable_legend_item.test.tsx @@ -15,6 +15,15 @@ import { TestProviders } from '../../mock'; import { DraggableLegendItem, LegendItem } from './draggable_legend_item'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line react/display-name + EuiScreenReaderOnly: () => <>, + }; +}); + const theme = () => ({ eui: euiDarkVars, darkMode: true }); describe('DraggableLegendItem', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx new file mode 100644 index 0000000000000..0736ea0e1d63f --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_keyboard_wrapper_hook/index.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState } from 'react'; +import { FluidDragActions } from 'react-beautiful-dnd'; + +import { useAddToTimeline } from '../../../hooks/use_add_to_timeline'; + +import { draggableKeyDownHandler } from '../helpers'; + +interface Props { + closePopover?: () => void; + draggableId: string; + fieldName: string; + keyboardHandlerRef: React.MutableRefObject; + openPopover?: () => void; +} + +export interface UseDraggableKeyboardWrapper { + onBlur: () => void; + onKeyDown: (keyboardEvent: React.KeyboardEvent) => void; +} + +export const useDraggableKeyboardWrapper = ({ + closePopover, + draggableId, + fieldName, + keyboardHandlerRef, + openPopover, +}: Props): UseDraggableKeyboardWrapper => { + const { beginDrag, cancelDrag, dragToLocation, endDrag, hasDraggableLock } = useAddToTimeline({ + draggableId, + fieldName, + }); + const [dragActions, setDragActions] = useState(null); + + const cancelDragActions = useCallback(() => { + if (dragActions) { + cancelDrag(dragActions); + setDragActions(null); + } + }, [cancelDrag, dragActions, setDragActions]); + + const onBlur = useCallback(() => { + if (dragActions) { + cancelDrag(dragActions); + setDragActions(null); + } + }, [cancelDrag, dragActions, setDragActions]); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + const draggableElement = document.querySelector( + `[data-rbd-drag-handle-draggable-id="${draggableId}"]` + ); + + if (draggableElement) { + if (hasDraggableLock() || (!hasDraggableLock() && keyboardEvent.key === ' ')) { + keyboardEvent.preventDefault(); + keyboardEvent.stopPropagation(); + } + + draggableKeyDownHandler({ + beginDrag, + cancelDragActions, + closePopover, + dragActions, + draggableElement, + dragToLocation, + endDrag, + keyboardEvent, + openPopover, + setDragActions, + }); + + keyboardHandlerRef.current?.focus(); // to handle future key presses + } + }, + [ + beginDrag, + cancelDragActions, + closePopover, + dragActions, + draggableId, + dragToLocation, + endDrag, + hasDraggableLock, + keyboardHandlerRef, + openPopover, + setDragActions, + ] + ); + + return { + onBlur, + onKeyDown, + }; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx index 5223452c8b93d..848c9cc6e7f77 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.test.tsx @@ -16,6 +16,15 @@ import { DragDropContextWrapper } from './drag_drop_context_wrapper'; import { ConditionalPortal, DraggableWrapper, getStyle } from './draggable_wrapper'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line react/display-name + EuiScreenReaderOnly: () => <>, + }; +}); + describe('DraggableWrapper', () => { const dataProvider = mockDataProviders[0]; const message = 'draggable wrapper content'; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx index bd22811612a67..ef27a7b004f36 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiScreenReaderOnly } from '@elastic/eui'; import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Draggable, @@ -22,10 +23,13 @@ import { ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID } from '../../../timelines/com import { TruncatableText } from '../truncatable_text'; import { WithHoverActions } from '../with_hover_actions'; +import { useDraggableKeyboardWrapper } from './draggable_keyboard_wrapper_hook'; import { DraggableWrapperHoverContent, useGetTimelineId } from './draggable_wrapper_hover_content'; -import { getDraggableId, getDroppableId } from './helpers'; +import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableId, getDroppableId } from './helpers'; import { ProviderContainer } from './provider_container'; +import * as i18n from './translations'; + // As right now, we do not know what we want there, we will keep it as a placeholder export const DragEffects = styled.div``; @@ -82,7 +86,7 @@ const ProviderContentWrapper = styled.span` type RenderFunctionProp = ( props: DataProvider, - provided: DraggableProvided, + provided: DraggableProvided | null, state: DraggableStateSnapshot ) => React.ReactNode; @@ -115,6 +119,11 @@ export const getStyle = ( }; }; +const draggableContainsLinks = (draggableElement: HTMLDivElement | null) => { + const links = draggableElement?.querySelectorAll('.euiLink') ?? []; + return links.length > 0; +}; + const DraggableWrapperComponent: React.FC = ({ dataProvider, onFilterAdded, @@ -122,6 +131,7 @@ const DraggableWrapperComponent: React.FC = ({ timelineId, truncate, }) => { + const keyboardHandlerRef = useRef(null); const draggableRef = useRef(null); const [closePopOverTrigger, setClosePopOverTrigger] = useState(false); const [showTopN, setShowTopN] = useState(false); @@ -129,12 +139,24 @@ const DraggableWrapperComponent: React.FC = ({ const timelineIdFind = useGetTimelineId(draggableRef, goGetTimelineId); const [providerRegistered, setProviderRegistered] = useState(false); const isDisabled = dataProvider.id.includes(`-${ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID}-`); + const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); const dispatch = useDispatch(); - const handleClosePopOverTrigger = useCallback( - () => setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger), - [] - ); + const handleClosePopOverTrigger = useCallback(() => { + setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); + setHoverActionsOwnFocus((prevHoverActionsOwnFocus) => { + if (prevHoverActionsOwnFocus) { + setTimeout(() => { + keyboardHandlerRef.current?.focus(); + }, 0); + } + return false; // always give up ownership + }); + + setTimeout(() => { + setHoverActionsOwnFocus(false); + }, 0); // invoked on the next tick, because we want to restore focus first + }, [keyboardHandlerRef, setClosePopOverTrigger, setHoverActionsOwnFocus]); const toggleTopN = useCallback(() => { setShowTopN((prevShowTopN) => { @@ -167,14 +189,27 @@ const DraggableWrapperComponent: React.FC = ({ [unRegisterProvider] ); - const hoverContent = useMemo( - () => ( + const hoverContent = useMemo(() => { + // display links as additional content in the hover menu to enable keyboard + // navigation of links (when the draggable contains them): + const additionalContent = + hoverActionsOwnFocus && !showTopN && draggableContainsLinks(draggableRef.current) ? ( + + {render(dataProvider, null, { isDragging: false, isDropAnimating: false })} + + ) : null; + + return ( = ({ : `${dataProvider.queryMatch.value}` } /> - ), - [ - dataProvider, - handleClosePopOverTrigger, - onFilterAdded, - showTopN, - timelineId, - timelineIdFind, - toggleTopN, - ] - ); + ); + }, [ + dataProvider, + handleClosePopOverTrigger, + hoverActionsOwnFocus, + onFilterAdded, + render, + showTopN, + timelineId, + timelineIdFind, + toggleTopN, + ]); const RenderClone = useCallback( (provided, snapshot) => ( @@ -205,6 +241,7 @@ const DraggableWrapperComponent: React.FC = ({ style={getStyle(provided.draggableProps.style, snapshot)} ref={provided.innerRef} data-test-subj="providerContainer" + tabIndex={-1} > = ({ data-test-subj="providerContainer" isDragging={snapshot.isDragging} registerProvider={registerProvider} + tabIndex={-1} > + +

{dataProvider.queryMatch.field}

+
{truncate && !snapshot.isDragging ? ( {render(dataProvider, provided, snapshot)} @@ -241,26 +282,72 @@ const DraggableWrapperComponent: React.FC = ({ {render(dataProvider, provided, snapshot)}
)} + {!snapshot.isDragging && ( + +

{i18n.DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY}

+
+ )} ), [dataProvider, registerProvider, render, truncate] ); + const openPopover = useCallback(() => { + setHoverActionsOwnFocus(true); + }, [setHoverActionsOwnFocus]); + + const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ + closePopover: handleClosePopOverTrigger, + draggableId: getDraggableId(dataProvider.id), + fieldName: dataProvider.queryMatch.field, + keyboardHandlerRef, + openPopover, + }); + + const onFocus = useCallback(() => { + if (!hoverActionsOwnFocus) { + keyboardHandlerRef.current?.focus(); + } + }, [hoverActionsOwnFocus, keyboardHandlerRef]); + + const onCloseRequested = useCallback(() => { + setShowTopN(false); + + if (hoverActionsOwnFocus) { + setHoverActionsOwnFocus(false); + + setTimeout(() => { + onFocus(); // return focus to this draggable on the next tick, because we owned focus + }, 0); + } + }, [onFocus, hoverActionsOwnFocus, setShowTopN, setHoverActionsOwnFocus]); + const DroppableContent = useCallback( (droppableProvided) => (
- - {DraggableContent} - + + {DraggableContent} + +
{droppableProvided.placeholder} ), - [DraggableContent, dataProvider.id, isDisabled] + [DraggableContent, dataProvider.id, isDisabled, onBlur, onFocus, onKeyDown] ); const content = useMemo( @@ -286,9 +373,10 @@ const DraggableWrapperComponent: React.FC = ({ return ( ); @@ -297,7 +385,6 @@ const DraggableWrapperComponent: React.FC = ({ export const DraggableWrapper = React.memo(DraggableWrapperComponent); DraggableWrapper.displayName = 'DraggableWrapper'; - /** * Conditionally wraps children in an EuiPortal to ensure drag offsets are correct when dragging * from containers that have css transforms diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx index af7e9ad5f1492..fd1c9e515bad1 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.test.tsx @@ -68,6 +68,7 @@ const goGetTimelineId = jest.fn(); const defaultProps = { field, goGetTimelineId, + ownFocus: false, showTopN: false, timelineId, toggleTopN, @@ -77,7 +78,7 @@ const defaultProps = { describe('DraggableWrapperHoverContent', () => { beforeAll(() => { // our mock implementation of the useAddToTimeline hook returns a mock startDragToTimeline function: - (useAddToTimeline as jest.Mock).mockReturnValue(jest.fn()); + (useAddToTimeline as jest.Mock).mockReturnValue({ startDragToTimeline: jest.fn() }); (useSourcererScope as jest.Mock).mockReturnValue({ browserFields: mockBrowserFields, selectedPatterns: [], @@ -390,7 +391,7 @@ describe('DraggableWrapperHoverContent', () => { // The following "startDragToTimeline" function returned by our mock // useAddToTimeline hook is called when the user clicks the // Add to timeline investigation action: - const startDragToTimeline = useAddToTimeline({ + const { startDragToTimeline } = useAddToTimeline({ draggableId, fieldName: aggregatableStringField, }); diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx index 5aaef5cbb9ac4..4210f85fe6779 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/draggable_wrapper_hover_content.tsx @@ -4,12 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; -import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { + EuiButtonIcon, + EuiFocusTrap, + EuiPanel, + EuiScreenReaderOnly, + EuiToolTip, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { DraggableId } from 'react-beautiful-dnd'; +import styled from 'styled-components'; +import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; +import { TooltipWithKeyboardShortcut } from '../accessibility/tooltip_with_keyboard_shortcut'; import { getAllFieldsByName } from '../../containers/source'; import { useAddToTimeline } from '../../hooks/use_add_to_timeline'; +import { COPY_TO_CLIPBOARD_BUTTON_CLASS_NAME } from '../../lib/clipboard/clipboard'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; import { useKibana } from '../../lib/kibana'; import { createFilter } from '../add_filter_to_global_search_bar'; @@ -23,35 +33,82 @@ import { SELECTOR_TIMELINE_GLOBAL_CONTAINER } from '../../../timelines/component import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; +export const AdditionalContent = styled.div` + padding: 2px; +`; + +AdditionalContent.displayName = 'AdditionalContent'; + +const getAdditionalScreenReaderOnlyContext = ({ + field, + value, +}: { + field: string; + value?: string[] | string | null; +}): string => { + if (value == null) { + return field; + } + + return Array.isArray(value) ? `${field} ${value.join(' ')}` : `${field} ${value}`; +}; + +const FILTER_FOR_VALUE_KEYBOARD_SHORTCUT = 'f'; +const FILTER_OUT_VALUE_KEYBOARD_SHORTCUT = 'o'; +const ADD_TO_TIMELINE_KEYBOARD_SHORTCUT = 'a'; +const SHOW_TOP_N_KEYBOARD_SHORTCUT = 't'; +const COPY_TO_CLIPBOARD_KEYBOARD_SHORTCUT = 'c'; + interface Props { + additionalContent?: React.ReactNode; closePopOver?: () => void; draggableId?: DraggableId; field: string; goGetTimelineId?: (args: boolean) => void; onFilterAdded?: () => void; + ownFocus: boolean; showTopN: boolean; timelineId?: string | null; toggleTopN: () => void; value?: string[] | string | null; } +/** Returns a value for the `disabled` prop of `EuiFocusTrap` */ +const isFocusTrapDisabled = ({ + ownFocus, + showTopN, +}: { + ownFocus: boolean; + showTopN: boolean; +}): boolean => { + if (showTopN) { + return false; // we *always* want to trap focus when showing Top N + } + + return !ownFocus; +}; + const DraggableWrapperHoverContentComponent: React.FC = ({ + additionalContent = null, closePopOver, draggableId, field, goGetTimelineId, onFilterAdded, + ownFocus, showTopN, timelineId, toggleTopN, value, }) => { - const startDragToTimeline = useAddToTimeline({ draggableId, fieldName: field }); + const { startDragToTimeline } = useAddToTimeline({ draggableId, fieldName: field }); const kibana = useKibana(); const filterManagerBackup = useMemo(() => kibana.services.data.query.filterManager, [ kibana.services.data.query.filterManager, ]); const { getTimelineFilterManager } = useManageTimeline(); + const defaultFocusedButtonRef = useRef(null); + const panelRef = useRef(null); const filterManager = useMemo( () => @@ -124,91 +181,211 @@ const DraggableWrapperHoverContentComponent: React.FC = ({ } }, [goGetTimelineId, timelineId]); + useEffect(() => { + if (ownFocus) { + setTimeout(() => { + defaultFocusedButtonRef.current?.focus(); + }, 0); + } + }, [ownFocus]); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (!ownFocus) { + return; + } + + switch (keyboardEvent.key) { + case FILTER_FOR_VALUE_KEYBOARD_SHORTCUT: + stopPropagationAndPreventDefault(keyboardEvent); + filterForValue(); + break; + case FILTER_OUT_VALUE_KEYBOARD_SHORTCUT: + stopPropagationAndPreventDefault(keyboardEvent); + filterOutValue(); + break; + case ADD_TO_TIMELINE_KEYBOARD_SHORTCUT: + stopPropagationAndPreventDefault(keyboardEvent); + handleStartDragToTimeline(); + break; + case SHOW_TOP_N_KEYBOARD_SHORTCUT: + stopPropagationAndPreventDefault(keyboardEvent); + toggleTopN(); + break; + case COPY_TO_CLIPBOARD_KEYBOARD_SHORTCUT: + stopPropagationAndPreventDefault(keyboardEvent); + const copyToClipboardButton = panelRef.current?.querySelector( + `.${COPY_TO_CLIPBOARD_BUTTON_CLASS_NAME}` + ); + if (copyToClipboardButton != null) { + copyToClipboardButton.click(); + if (closePopOver != null) { + closePopOver(); + } + } + break; + case 'Enter': + break; + case 'Escape': + stopPropagationAndPreventDefault(keyboardEvent); + if (closePopOver != null) { + closePopOver(); + } + break; + default: + break; + } + }, + + [closePopOver, filterForValue, filterOutValue, handleStartDragToTimeline, ownFocus, toggleTopN] + ); + return ( - <> - {!showTopN && value != null && ( - - - - )} - - {!showTopN && value != null && ( - - - - )} - - {!showTopN && value != null && draggableId != null && ( - - - - )} - - <> - {allowTopN({ - browserField: getAllFieldsByName(browserFields)[field], - fieldName: field, - }) && ( - <> - {!showTopN && ( - - - - )} - - {showTopN && ( - + + +

{i18n.YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS(field)}

+
+ + {additionalContent != null && {additionalContent}} + + {!showTopN && value != null && ( + + } + > + + + )} + + {!showTopN && value != null && ( + + } + > + + + )} + + {!showTopN && value != null && draggableId != null && ( + - )} - + } + > + + )} - - {!showTopN && ( - + <> + {allowTopN({ + browserField: getAllFieldsByName(browserFields)[field], + fieldName: field, + }) && ( + <> + {!showTopN && ( + + } + > + + + )} + + {showTopN && ( + + )} + + )} + + + {!showTopN && ( - - )} - + )} +
+ ); }; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts index ca8bb3d54f278..048d4a67234e7 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/helpers.ts @@ -5,10 +5,11 @@ */ import { isString } from 'lodash/fp'; -import { DropResult } from 'react-beautiful-dnd'; +import { DropResult, FluidDragActions, Position } from 'react-beautiful-dnd'; import { Dispatch } from 'redux'; import { ActionCreator } from 'typescript-fsa'; +import { stopPropagationAndPreventDefault } from '../accessibility/helpers'; import { alertsHeaders } from '../../../detections/components/alerts_table/default_config'; import { BrowserField, BrowserFields, getAllFieldsByName } from '../../containers/source'; import { dragAndDropActions } from '../../store/actions'; @@ -348,3 +349,123 @@ export const allowTopN = ({ export const getTimelineIdFromColumnDroppableId = (droppableId: string) => droppableId.slice(droppableId.lastIndexOf('.') + 1); + +/** The draggable will move this many pixes via the keyboard when the arrow key is pressed */ +export const KEYBOARD_DRAG_OFFSET = 20; + +export const DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME = 'draggable-keyboard-wrapper'; + +/** + * Temporarily disables tab focus on child links of the draggable to work + * around an issue where tab focus becomes stuck on the interactive children + * + * NOTE: This function is (intentionally) only effective when used in a key + * event handler, because it automatically restores focus capabilities on + * the next tick. + */ +export const temporarilyDisableInteractiveChildTabIndexes = (draggableElement: HTMLDivElement) => { + const interactiveChildren = draggableElement.querySelectorAll('a, button'); + interactiveChildren.forEach((interactiveChild) => { + interactiveChild.setAttribute('tabindex', '-1'); // DOM mutation + }); + + // restore the default tabindexs on the next tick: + setTimeout(() => { + interactiveChildren.forEach((interactiveChild) => { + interactiveChild.setAttribute('tabindex', '0'); // DOM mutation + }); + }, 0); +}; + +export const draggableKeyDownHandler = ({ + beginDrag, + cancelDragActions, + closePopover, + draggableElement, + dragActions, + dragToLocation, + endDrag, + keyboardEvent, + openPopover, + setDragActions, +}: { + beginDrag: () => FluidDragActions | null; + cancelDragActions: () => void; + closePopover?: () => void; + draggableElement: HTMLDivElement; + dragActions: FluidDragActions | null; + dragToLocation: ({ + // eslint-disable-next-line @typescript-eslint/no-shadow + dragActions, + position, + }: { + dragActions: FluidDragActions | null; + position: Position; + }) => void; + keyboardEvent: React.KeyboardEvent; + endDrag: (dragActions: FluidDragActions | null) => void; + openPopover?: () => void; + setDragActions: (value: React.SetStateAction) => void; +}) => { + let currentPosition: DOMRect | null = null; + + switch (keyboardEvent.key) { + case ' ': + if (!dragActions) { + // start dragging, because space was pressed + if (closePopover != null) { + closePopover(); + } + setDragActions(beginDrag()); + } else { + // end dragging, because space was pressed + endDrag(dragActions); + setDragActions(null); + } + break; + case 'Escape': + cancelDragActions(); + break; + case 'Tab': + // IMPORTANT: we do NOT want to stop propagation and prevent default when Tab is pressed + temporarilyDisableInteractiveChildTabIndexes(draggableElement); + break; + case 'ArrowUp': + stopPropagationAndPreventDefault(keyboardEvent); + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x, y: currentPosition.y - KEYBOARD_DRAG_OFFSET }, + }); + break; + case 'ArrowDown': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x, y: currentPosition.y + KEYBOARD_DRAG_OFFSET }, + }); + break; + case 'ArrowLeft': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x - KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, + }); + break; + case 'ArrowRight': + currentPosition = draggableElement.getBoundingClientRect(); + dragToLocation({ + dragActions, + position: { x: currentPosition.x + KEYBOARD_DRAG_OFFSET, y: currentPosition.y }, + }); + break; + case 'Enter': + stopPropagationAndPreventDefault(keyboardEvent); // prevents the first item in the popover from getting an errant ENTER + if (!dragActions && openPopover != null) { + openPopover(); + } + break; + default: + break; + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/translations.ts b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/translations.ts index 574b2c190e1db..23b05175699f1 100644 --- a/x-pack/plugins/security_solution/public/common/components/drag_and_drop/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/drag_and_drop/translations.ts @@ -17,6 +17,13 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); +export const DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY = i18n.translate( + 'xpack.securitySolution.dragAndDrop.draggableKeyboardInstructionsNotDraggingScreenReaderOnly', + { + defaultMessage: 'Press enter for options, or press space to begin dragging.', + } +); + export const FIELD = i18n.translate('xpack.securitySolution.dragAndDrop.fieldLabel', { defaultMessage: 'Field', }); @@ -44,3 +51,12 @@ export const SHOW_TOP = (fieldName: string) => values: { fieldName }, defaultMessage: `Show top {fieldName}`, }); + +export const YOU_ARE_IN_A_DIALOG_CONTAINING_OPTIONS = (fieldName: string) => + i18n.translate( + 'xpack.securitySolution.dragAndDrop.youAreInADialogContainingOptionsScreenReaderOnly', + { + values: { fieldName }, + defaultMessage: `You are in a dialog, containing options for field {fieldName}. Press tab to navigate options. Press escape to exit.`, + } + ); diff --git a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx index ff1679875865c..f4cb2044a6b2b 100644 --- a/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/draggables/index.test.tsx @@ -7,6 +7,7 @@ import { shallow } from 'enzyme'; import React from 'react'; +import { DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY } from '../drag_and_drop/translations'; import { TestProviders } from '../../mock'; import '../../mock/match_media'; import { getEmptyString } from '../empty_value'; @@ -101,12 +102,16 @@ describe('draggables', () => { describe('DefaultDraggable', () => { test('it works with just an id, field, and value and is some value', () => { + const field = 'some-field'; + const value = 'some value'; const wrapper = mount( - + ); - expect(wrapper.text()).toEqual('some value'); + expect(wrapper.text()).toEqual( + `${field}${value}${DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY}` + ); }); test('it returns null if value is undefined', () => { @@ -198,7 +203,9 @@ describe('draggables', () => { /> ); - expect(wrapper.text()).toEqual('some value'); + expect(wrapper.text()).toEqual( + `some-fieldsome value${DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY}` + ); }); test('it returns null if value is undefined', () => { @@ -239,7 +246,9 @@ describe('draggables', () => { /> ); - expect(wrapper.text()).toEqual(getEmptyString()); + expect(wrapper.text()).toEqual( + `some-field${getEmptyString()}${DRAGGABLE_KEYBOARD_INSTRUCTIONS_NOT_DRAGGING_SCREEN_READER_ONLY}` + ); }); test('it renders a tooltip with the field name if a tooltip is not explicitly provided', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx index 0b2fbcf703d77..6bc3a4904e031 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/columns.tsx @@ -16,24 +16,24 @@ import { EuiIconTip, } from '@elastic/eui'; import React from 'react'; -import { Draggable } from 'react-beautiful-dnd'; import styled from 'styled-components'; +import { onFocusReFocusDraggable } from '../accessibility/helpers'; import { BrowserFields } from '../../containers/source'; import { ToStringArray } from '../../../graphql/types'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { DragEffects } from '../drag_and_drop/draggable_wrapper'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; -import { getDroppableId, getDraggableFieldId, DRAG_TYPE_FIELD } from '../drag_and_drop/helpers'; +import { DRAG_TYPE_FIELD, getDroppableId } from '../drag_and_drop/helpers'; import { DraggableFieldBadge } from '../draggables/field_badge'; -import { FieldName } from '../../../timelines/components/fields_browser/field_name'; +import { DraggableFieldsBrowserField } from '../../../timelines/components/fields_browser/field_items'; import { OverflowField } from '../tables/helpers'; import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers'; import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants'; import { MESSAGE_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; import { FormattedFieldValue } from '../../../timelines/components/timeline/body/renderers/formatted_field'; import { OnUpdateColumns } from '../../../timelines/components/timeline/events'; -import { getIconFromType, getExampleText, getColumnsWithTimestamp } from './helpers'; +import { getIconFromType, getExampleText } from './helpers'; import * as i18n from './translations'; import { EventFieldsData } from './types'; @@ -57,6 +57,7 @@ export const getColumns = ({ eventId, onUpdateColumns, contextId, + timelineId, toggleColumn, getLinkValue, }: { @@ -65,6 +66,7 @@ export const getColumns = ({ eventId: string; onUpdateColumns: OnUpdateColumns; contextId: string; + timelineId: string; toggleColumn: (column: ColumnHeaderOptions) => void; getLinkValue: (field: string) => string | null; }) => [ @@ -75,10 +77,12 @@ export const getColumns = ({ truncateText: false, width: '30px', render: (field: string) => ( - + c.id === field) !== -1} data-test-subj={`toggle-field-${field}`} + data-colindex={1} id={field} onChange={() => toggleColumn({ @@ -119,6 +123,7 @@ export const getColumns = ({ {...provided.draggableProps} {...provided.dragHandleProps} ref={provided.innerRef} + tabIndex={-1} > @@ -126,32 +131,15 @@ export const getColumns = ({ )} > - - {(provided) => ( -
- -
- )} -
+ @@ -179,19 +167,21 @@ export const getColumns = ({ component="span" key={`event-details-value-flex-item-${contextId}-${eventId}-${data.field}-${i}-${value}`} > - {data.field === MESSAGE_FIELD_NAME ? ( - - ) : ( - - )} +
+ {data.field === MESSAGE_FIELD_NAME ? ( + + ) : ( + + )} +
))} diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx index e4365c4b7b2d8..cd50eb7880e56 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.test.tsx @@ -14,6 +14,15 @@ import { EventFieldsBrowser } from './event_fields_browser'; import { mockBrowserFields } from '../../containers/source/mock'; import { useMountAppended } from '../../utils/use_mount_appended'; +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + // eslint-disable-next-line react/display-name + EuiScreenReaderOnly: () => <>, + }; +}); + jest.mock('../link_to'); const mockDispatch = jest.fn(); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx index b494960f12fac..9ef723ad1e587 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_fields_browser.tsx @@ -4,13 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr, sortBy } from 'lodash/fp'; +import { getOr, noop, sortBy } from 'lodash/fp'; import { EuiInMemoryTable } from '@elastic/eui'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { rgba } from 'polished'; import styled from 'styled-components'; +import { + arrayIndexToAriaIndex, + DATA_COLINDEX_ATTRIBUTE, + DATA_ROWINDEX_ATTRIBUTE, + isTab, + onKeyDownFocusHandler, +} from '../accessibility/helpers'; +import { ADD_TIMELINE_BUTTON_CLASS_NAME } from '../../../timelines/components/flyout/add_timeline_button'; import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { BrowserFields, getAllFieldsByName } from '../../containers/source'; @@ -19,7 +27,7 @@ import { getColumnHeaders } from '../../../timelines/components/timeline/body/co import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { getColumns } from './columns'; -import { search } from './helpers'; +import { EVENT_FIELDS_TABLE_CLASS_NAME, onEventDetailsTabKeyPressed, search } from './helpers'; import { useDeepEqualSelector } from '../../hooks/use_selector'; interface Props { @@ -68,18 +76,29 @@ const StyledEuiInMemoryTable = styled(EuiInMemoryTable as any)` } `; +/** + * This callback, invoked via `EuiInMemoryTable`'s `rowProps, assigns + * attributes to every ``. + */ +const getAriaRowindex = (timelineEventsDetailsItem: TimelineEventsDetailsItem) => + timelineEventsDetailsItem.ariaRowindex != null + ? { 'data-rowindex': timelineEventsDetailsItem.ariaRowindex } + : {}; + /** Renders a table view or JSON view of the `ECS` `data` */ export const EventFieldsBrowser = React.memo( ({ browserFields, data, eventId, timelineId }) => { + const containerElement = useRef(null); const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const fieldsByName = useMemo(() => getAllFieldsByName(browserFields), [browserFields]); const items = useMemo( () => - sortBy(['field'], data).map((item) => ({ + sortBy(['field'], data).map((item, i) => ({ ...item, ...fieldsByName[item.field], valuesConcatenated: item.values != null ? item.values.join() : '', + ariaRowindex: arrayIndexToAriaIndex(i), })), [data, fieldsByName] ); @@ -137,7 +156,8 @@ export const EventFieldsBrowser = React.memo( columnHeaders, eventId, onUpdateColumns, - contextId: timelineId, + contextId: `event-fields-browser-for-${timelineId}`, + timelineId, toggleColumn, getLinkValue, }), @@ -152,14 +172,54 @@ export const EventFieldsBrowser = React.memo( ] ); + const focusSearchInput = useCallback(() => { + // the selector below is used to focus the input because EuiInMemoryTable does not expose a ref to its built-in search input + containerElement.current?.querySelector('input[type="search"]')?.focus(); + }, [containerElement]); + + const focusAddTimelineButton = useCallback(() => { + // the document selector below is required because we may be in a flyout or full screen timeline context + document.querySelector(`.${ADD_TIMELINE_BUTTON_CLASS_NAME}`)?.focus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onEventDetailsTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable: focusSearchInput, + onSkipFocusAfterEventsTable: focusAddTimelineButton, + }); + } else { + onKeyDownFocusHandler({ + colindexAttribute: DATA_COLINDEX_ATTRIBUTE, + containerElement: containerElement?.current, + event: keyboardEvent, + maxAriaColindex: 3, + maxAriaRowindex: data.length, + onColumnFocused: noop, + rowindexAttribute: DATA_ROWINDEX_ATTRIBUTE, + }); + } + }, + [containerElement, data, focusAddTimelineButton, focusSearchInput] + ); + + useEffect(() => { + focusSearchInput(); + }, [focusSearchInput]); + return ( - + ); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx index 6aae2c9937ec0..1c42eb8dc8644 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/helpers.tsx @@ -6,6 +6,13 @@ import { get, getOr, isEmpty, uniqBy } from 'lodash/fp'; +import { + elementOrChildrenHasFocus, + getFocusedDataColindexCell, + getTableSkipFocus, + handleSkipFocus, + stopPropagationAndPreventDefault, +} from '../accessibility/helpers'; import { BrowserField, BrowserFields } from '../../containers/source'; import { ColumnHeaderOptions } from '../../../timelines/store/timeline/model'; import { @@ -113,3 +120,53 @@ export const getIconFromType = (type: string | null) => { return 'questionInCircle'; } }; + +export const EVENT_FIELDS_TABLE_CLASS_NAME = 'event-fields-table'; + +/** + * Returns `true` if the Event Details "event fields" table, or it's children, + * has focus + */ +export const tableHasFocus = (containerElement: HTMLElement | null): boolean => + elementOrChildrenHasFocus( + containerElement?.querySelector(`.${EVENT_FIELDS_TABLE_CLASS_NAME}`) + ); + +/** + * This function has a side effect. It will skip focus "after" or "before" + * the Event Details table, with exceptions as noted below. + * + * If the currently-focused table cell has additional focusable children, + * i.e. draggables or always-open popover content, the browser's "natural" + * focus management will determine which element is focused next. + */ +export const onEventDetailsTabKeyPressed = ({ + containerElement, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, +}: { + containerElement: HTMLElement | null; + keyboardEvent: React.KeyboardEvent; + onSkipFocusBeforeEventsTable: () => void; + onSkipFocusAfterEventsTable: () => void; +}) => { + const { shiftKey } = keyboardEvent; + + const eventFieldsTableSkipFocus = getTableSkipFocus({ + containerElement, + getFocusedCell: getFocusedDataColindexCell, + shiftKey, + tableHasFocus, + tableClassName: EVENT_FIELDS_TABLE_CLASS_NAME, + }); + + if (eventFieldsTableSkipFocus !== 'SKIP_FOCUS_NOOP') { + stopPropagationAndPreventDefault(keyboardEvent); + handleSkipFocus({ + onSkipFocusBackwards: onSkipFocusBeforeEventsTable, + onSkipFocusForward: onSkipFocusAfterEventsTable, + skipFocus: eventFieldsTableSkipFocus, + }); + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts index 76ae2cd4a88a8..d56d590172a13 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/event_details/translations.ts @@ -55,9 +55,8 @@ export const COPY_TO_CLIPBOARD = i18n.translate( } ); -export const TOGGLE_COLUMN_TOOLTIP = i18n.translate( - 'xpack.securitySolution.eventDetails.toggleColumnTooltip', - { - defaultMessage: 'Toggle column', - } -); +export const VIEW_COLUMN = (field: string) => + i18n.translate('xpack.securitySolution.eventDetails.viewColumnCheckboxAriaLabel', { + values: { field }, + defaultMessage: 'View {field} column', + }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index d6b2efbe43053..67fc0ef119582 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -23,7 +23,11 @@ import { Sort } from '../../../timelines/components/timeline/body/sort'; import { StatefulBody } from '../../../timelines/components/timeline/body'; import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { Footer, footerHeight } from '../../../timelines/components/timeline/footer'; -import { combineQueries, resolverIsShowing } from '../../../timelines/components/timeline/helpers'; +import { + calculateTotalPages, + combineQueries, + resolverIsShowing, +} from '../../../timelines/components/timeline/helpers'; import { TimelineRefetch } from '../../../timelines/components/timeline/refetch_timeline'; import { EventDetailsWidthProvider } from './event_details_width_context'; import * as i18n from './translations'; @@ -306,6 +310,7 @@ const EventsViewerComponent: React.FC = ({ = ({ onRuleChange={onRuleChange} refetch={refetch} sort={sort} + totalPages={calculateTotalPages({ + itemsCount: totalCountMinusDeleted, + itemsPerPage, + })} />