From 46083c0973211ec6bd60ab873a4501cc558d6a78 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 12 Jan 2021 01:02:53 -0700 Subject: [PATCH] [Security Solution] Accessibility (a11y) fixes (#87783) ## [Security Solution] Accessibility (a11y) fixes This PR fixes the following accessibility (a11y) issues: - Fixes an issue that prevented tabbing through all elements on pages with embedded Timelines - Fixes an issue where the Timeline data providers popover menu was not displayed when Enter is pressed - Fixes an issue where duplicate draggable IDs caused errors when re-arranging Timeline columns - Fixes an issue where Timeline columns could not be removed or sorted via keyboard - Fixes an issue where focus is not restored to the `Customize Columns` button when the `Reset` button is pressed - Fixes an issue where filtering the `Customize Event Renderers` view via the input cleared selected entries - Fixes an issue where the active timeline button wasn't focused when Timeline is closed - Fixes an issue where the `(+)` Create / Open Timeline button's hover panel didn't own focus --- .../integration/fields_browser.spec.ts | 15 ++ .../timeline_data_providers.spec.ts | 12 ++ .../timeline_flyout_button.spec.ts | 42 ++++- .../cypress/screens/security_main.ts | 2 + .../cypress/screens/timeline.ts | 2 + .../cypress/tasks/security_main.ts | 5 + .../alerts_table/alerts_utility_bar/index.tsx | 2 +- .../detection_engine/detection_engine.tsx | 37 +++- .../detection_engine/rules/details/index.tsx | 37 +++- .../public/hosts/pages/hosts.tsx | 38 +++- .../public/network/pages/network.tsx | 39 +++- .../fields_browser/field_browser.tsx | 2 +- .../flyout/add_timeline_button/index.tsx | 1 + .../components/flyout/bottom_bar/index.tsx | 9 +- .../flyout/header/active_timelines.tsx | 14 +- .../components/flyout/header/index.tsx | 9 +- .../components/flyout/pane/index.tsx | 9 +- .../row_renderers_browser/index.tsx | 15 +- .../row_renderers_browser.tsx | 174 ++++++++---------- .../__snapshots__/index.test.tsx.snap | 1 + .../body/column_headers/column_header.tsx | 152 +++++++++++++-- .../body/column_headers/index.test.tsx | 7 + .../timeline/body/column_headers/index.tsx | 9 +- .../body/column_headers/translations.ts | 12 ++ .../components/timeline/body/index.tsx | 1 + .../__snapshots__/provider.test.tsx.snap | 2 + .../timeline/data_providers/provider.tsx | 40 ++-- .../data_providers/provider_item_actions.tsx | 1 + .../data_providers/provider_item_badge.tsx | 9 +- .../timeline/data_providers/providers.tsx | 19 +- .../timelines/components/timeline/helpers.tsx | 37 ++++ .../timelines/components/timeline/styles.tsx | 1 + 32 files changed, 575 insertions(+), 180 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts index 55ded8014db3..e65cbf85e6e7 100644 --- a/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/fields_browser.spec.ts @@ -16,6 +16,7 @@ import { FIELDS_BROWSER_SELECTED_CATEGORY_COUNT, FIELDS_BROWSER_SYSTEM_CATEGORIES_COUNT, } from '../screens/fields_browser'; +import { TIMELINE_FIELDS_BUTTON } from '../screens/timeline'; import { cleanKibana } from '../tasks/common'; import { @@ -182,5 +183,19 @@ describe('Fields Browser', () => { cy.get(FIELDS_BROWSER_HEADER_HOST_GEO_CONTINENT_NAME_HEADER).should('not.exist'); }); + + it('restores focus to the Customize Columns button when `Reset Fields` is clicked', () => { + openTimelineFieldsBrowser(); + resetFields(); + + cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); + }); + + it('restores focus to the Customize Columns button when Esc is pressed', () => { + openTimelineFieldsBrowser(); + cy.get('body').type('{esc}'); + + cy.get(TIMELINE_FIELDS_BUTTON).should('have.focus'); + }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts index 32ffb01b8ff5..5c8fea7319fc 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_data_providers.spec.ts @@ -8,6 +8,7 @@ import { TIMELINE_DATA_PROVIDERS, TIMELINE_DATA_PROVIDERS_EMPTY, TIMELINE_DROPPED_DATA_PROVIDERS, + TIMELINE_DATA_PROVIDERS_ACTION_MENU, } from '../screens/timeline'; import { HOSTS_NAMES_DRAGGABLE } from '../screens/hosts/all_hosts'; @@ -53,6 +54,17 @@ describe('timeline data providers', () => { }); }); + it('displays the data provider action menu when Enter is pressed', () => { + dragAndDropFirstHostToTimeline(); + openTimelineUsingToggle(); + cy.get(TIMELINE_DATA_PROVIDERS_ACTION_MENU).should('not.exist'); + + cy.get(TIMELINE_DROPPED_DATA_PROVIDERS).first().focus(); + cy.get(TIMELINE_DROPPED_DATA_PROVIDERS).first().parent().type('{enter}'); + + cy.get(TIMELINE_DATA_PROVIDERS_ACTION_MENU).should('exist'); + }); + it('sets the background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers', () => { dragFirstHostToTimeline(); diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts index acf245251d7e..a09f1c187506 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_flyout_button.spec.ts @@ -4,12 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TIMELINE_FLYOUT_HEADER, TIMELINE_DATA_PROVIDERS } from '../screens/timeline'; +import { TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON } from '../screens/security_main'; +import { + CREATE_NEW_TIMELINE, + TIMELINE_DATA_PROVIDERS, + TIMELINE_FLYOUT_HEADER, + TIMELINE_SETTINGS_ICON, +} from '../screens/timeline'; import { cleanKibana } from '../tasks/common'; import { dragFirstHostToTimeline, waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; import { loginAndWaitForPage } from '../tasks/login'; -import { openTimelineUsingToggle, closeTimelineUsingToggle } from '../tasks/security_main'; +import { + closeTimelineUsingCloseButton, + closeTimelineUsingToggle, + openTimelineUsingToggle, +} from '../tasks/security_main'; import { HOSTS_URL } from '../urls/navigation'; @@ -26,6 +36,34 @@ describe('timeline flyout button', () => { closeTimelineUsingToggle(); }); + it('re-focuses the toggle button when timeline is closed by clicking the active timeline toggle button', () => { + openTimelineUsingToggle(); + closeTimelineUsingToggle(); + + cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).should('have.focus'); + }); + + it('re-focuses the toggle button when timeline is closed by clicking the [X] close button', () => { + openTimelineUsingToggle(); + closeTimelineUsingCloseButton(); + + cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).should('have.focus'); + }); + + it('re-focuses the toggle button when timeline is closed by pressing the Esc key', () => { + openTimelineUsingToggle(); + cy.get('body').type('{esc}'); + + cy.get(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).should('have.focus'); + }); + + it('the `(+)` button popover menu owns focus', () => { + cy.get(TIMELINE_SETTINGS_ICON).filter(':visible').click({ force: true }); + cy.get(CREATE_NEW_TIMELINE).should('have.focus'); + cy.get('body').type('{esc}'); + cy.get(CREATE_NEW_TIMELINE).should('not.be.visible'); + }); + it('sets the data providers background to euiColorSuccess with a 10% alpha channel when the user starts dragging a host, but is not hovering over the data providers area', () => { dragFirstHostToTimeline(); diff --git a/x-pack/plugins/security_solution/cypress/screens/security_main.ts b/x-pack/plugins/security_solution/cypress/screens/security_main.ts index c6c1067825f1..22f7cd68659b 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_main.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +export const CLOSE_TIMELINE_BUTTON = '[data-test-subj="close-timeline"]'; + export const MAIN_PAGE = '[data-test-subj="kibanaChrome"]'; export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index fef94da062e0..42d2b699fc8d 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -111,6 +111,8 @@ export const TIMELINE_COLUMN_SPINNER = '[data-test-subj="timeline-loading-spinne export const TIMELINE_DATA_PROVIDERS = '[data-test-subj="dataProviders"]'; +export const TIMELINE_DATA_PROVIDERS_ACTION_MENU = '[data-test-subj="providerActions"]'; + export const TIMELINE_DATA_PROVIDERS_EMPTY = '[data-test-subj="dataProviders"] [data-test-subj="empty"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts index eb03c56ef04e..05ba6e3223c9 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/security_main.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/security_main.ts @@ -5,6 +5,7 @@ */ import { + CLOSE_TIMELINE_BUTTON, MAIN_PAGE, TIMELINE_TOGGLE_BUTTON, TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON, @@ -18,6 +19,10 @@ export const closeTimelineUsingToggle = () => { cy.get(TIMELINE_TOGGLE_BUTTON).filter(':visible').click(); }; +export const closeTimelineUsingCloseButton = () => { + cy.get(CLOSE_TIMELINE_BUTTON).filter(':visible').click(); +}; + export const openTimelineIfClosed = () => cy.get(MAIN_PAGE).then(($page) => { if ($page.find(TIMELINE_BOTTOM_BAR_TOGGLE_BUTTON).length === 1) { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx index fc7385f807cb..3b3cbcbd72fd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_utility_bar/index.tsx @@ -220,7 +220,7 @@ const AlertsUtilityBarComponent: React.FC = ({ disabled={areEventsLoading} iconType="arrowDown" iconSide="right" - ownFocus={false} + ownFocus={true} popoverContent={UtilityBarAdditionalFiltersContent} > {i18n.ADDITIONAL_FILTERS_ACTIONS} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index fe1b6933763f..4a8f8a586e08 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -6,7 +6,7 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; @@ -14,6 +14,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { SecurityPageName } from '../../../app/types'; import { TimelineId } from '../../../../common/types/timeline'; import { useGlobalTime } from '../../../common/containers/use_global_time'; +import { isTab } from '../../../common/components/accessibility/helpers'; import { UpdateDateRange } from '../../../common/components/charts/common'; import { FiltersGlobal } from '../../../common/components/filters_global'; import { getRulesUrl } from '../../../common/components/link_to/redirect_to_detection_engine'; @@ -39,7 +40,12 @@ import { LinkButton } from '../../../common/components/links'; import { useFormatUrl } from '../../../common/components/link_to'; import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../../../hosts/pages/display'; -import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; +import { + focusUtilityBarAction, + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; import { buildShowBuildingBlockFilter } from '../../components/alerts_table/default_config'; @@ -48,6 +54,7 @@ import { SourcererScopeName } from '../../../common/store/sourcerer/model'; const DetectionEnginePageComponent = () => { const dispatch = useDispatch(); + const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const graphEventId = useShallowEqualSelector( (state) => (getTimeline(state, TimelineId.detectionsPage) ?? timelineDefaults).graphEventId @@ -128,6 +135,28 @@ const DetectionEnginePageComponent = () => { const { indicesExist, indexPattern } = useSourcererScope(SourcererScopeName.detections); + const onSkipFocusBeforeEventsTable = useCallback(() => { + focusUtilityBarAction(containerElement.current); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + if (isUserAuthenticated != null && !isUserAuthenticated && !loading) { return ( @@ -154,7 +183,7 @@ const DetectionEnginePageComponent = () => { {hasEncryptionKey != null && !hasEncryptionKey && } {indicesExist ? ( - <> +
@@ -211,7 +240,7 @@ const DetectionEnginePageComponent = () => { to={to} /> - +
) : ( diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 243413a7b6ac..bcf28d2889a9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -19,7 +19,7 @@ import { } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { noop } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; import { useDispatch } from 'react-redux'; @@ -81,7 +81,12 @@ import { useGlobalFullScreen } from '../../../../../common/containers/use_full_s import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports'; import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; -import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers'; +import { + focusUtilityBarAction, + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../../../timelines/store/timeline'; import { timelineDefaults } from '../../../../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../../../../common/containers/sourcerer'; @@ -95,6 +100,7 @@ import { import * as detectionI18n from '../../translations'; import * as ruleI18n from '../translations'; import * as i18n from './translations'; +import { isTab } from '../../../../../common/components/accessibility/helpers'; enum RuleDetailTabs { alerts = 'alerts', @@ -127,6 +133,7 @@ const getRuleDetailsTabs = (rule: Rule | null) => { const RuleDetailsPageComponent = () => { const dispatch = useDispatch(); + const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const graphEventId = useShallowEqualSelector( (state) => @@ -408,6 +415,28 @@ const RuleDetailsPageComponent = () => { } }, [rule]); + const onSkipFocusBeforeEventsTable = useCallback(() => { + focusUtilityBarAction(containerElement.current); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + if ( redirectToDetections( isSignalIndexExists, @@ -430,7 +459,7 @@ const RuleDetailsPageComponent = () => { {indicesExist ? ( - <> +
@@ -588,7 +617,7 @@ const RuleDetailsPageComponent = () => { )} {ruleDetailTab === RuleDetailTabs.failures && } - +
) : ( diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index 52ec837a09eb..0af60c268012 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -6,7 +6,7 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -40,7 +40,12 @@ import * as i18n from './translations'; import { filterHostData } from './navigation'; import { hostsModel } from '../store'; import { HostsTableType } from '../store/model'; -import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; +import { isTab } from '../../common/components/accessibility/helpers'; +import { + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../timelines/store/timeline'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../common/containers/sourcerer'; @@ -48,6 +53,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hook const HostsComponent = () => { const dispatch = useDispatch(); + const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const graphEventId = useShallowEqualSelector( (state) => @@ -114,10 +120,34 @@ const HostsComponent = () => { [indexPattern, query, tabsFilters, uiSettings] ); + const onSkipFocusBeforeEventsTable = useCallback(() => { + containerElement.current + ?.querySelector('.inspectButtonComponent:last-of-type') + ?.focus(); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + return ( <> {indicesExist ? ( - <> +
@@ -167,7 +197,7 @@ const HostsComponent = () => { type={hostsModel.HostsType.page} /> - +
) : ( diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index 3a095cfb21f0..a9959fb3a326 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -6,7 +6,7 @@ import { EuiSpacer, EuiWindowEvent } from '@elastic/eui'; import { noop } from 'lodash/fp'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; @@ -38,8 +38,13 @@ import { OverviewEmpty } from '../../overview/components/overview_empty'; import * as i18n from './translations'; import { NetworkComponentProps } from './types'; import { NetworkRouteType } from './navigation/types'; -import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; +import { + onTimelineTabKeyPressed, + resetKeyboardFocus, + showGlobalFilters, +} from '../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../timelines/store/timeline'; +import { isTab } from '../../common/components/accessibility/helpers'; import { TimelineId } from '../../../common/types/timeline'; import { timelineDefaults } from '../../timelines/store/timeline/defaults'; import { useSourcererScope } from '../../common/containers/sourcerer'; @@ -48,6 +53,7 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../common/hook const NetworkComponent = React.memo( ({ networkPagePath, hasMlUserPermissions, capabilitiesFetched }) => { const dispatch = useDispatch(); + const containerElement = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const graphEventId = useShallowEqualSelector( (state) => @@ -91,6 +97,31 @@ const NetworkComponent = React.memo( ); const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope(); + + const onSkipFocusBeforeEventsTable = useCallback(() => { + containerElement.current + ?.querySelector('.inspectButtonComponent:last-of-type') + ?.focus(); + }, [containerElement]); + + const onSkipFocusAfterEventsTable = useCallback(() => { + resetKeyboardFocus(); + }, []); + + const onKeyDown = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (isTab(keyboardEvent)) { + onTimelineTabKeyPressed({ + containerElement: containerElement.current, + keyboardEvent, + onSkipFocusBeforeEventsTable, + onSkipFocusAfterEventsTable, + }); + } + }, + [containerElement, onSkipFocusBeforeEventsTable, onSkipFocusAfterEventsTable] + ); + const filterQuery = convertToBuildEsQuery({ config: esQuery.getEsQueryConfig(kibana.services.uiSettings), indexPattern, @@ -107,7 +138,7 @@ const NetworkComponent = React.memo( return ( <> {indicesExist ? ( - <> +
@@ -176,7 +207,7 @@ const NetworkComponent = React.memo( )} - +
) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx index eabe94c088a5..525683cea186 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/fields_browser/field_browser.tsx @@ -239,7 +239,7 @@ const FieldsBrowserComponent: React.FC = ({ data-test-subj="header" filteredBrowserFields={filteredBrowserFields} isSearching={isSearching} - onOutsideClick={onOutsideClick} + onOutsideClick={closeAndRestoreFocus} onSearchInputChange={onInputChange} onUpdateColumns={onUpdateColumns} searchInput={searchInput} diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx index 5c4c7a38b130..7aa9b01e8972 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/add_timeline_button/index.tsx @@ -55,6 +55,7 @@ const AddTimelineButtonComponent: React.FC = ({ id="timelineSettingsPopover" isOpen={showActions} closePopover={onClosePopover} + ownFocus repositionOnScroll > diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx index edc571528e94..d460d2c7b83d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/bottom_bar/index.tsx @@ -13,11 +13,10 @@ import { IS_DRAGGING_CLASS_NAME } from '../../../../common/components/drag_and_d import { DataProvider } from '../../timeline/data_providers/data_provider'; import { flattenIntoAndGroups } from '../../timeline/data_providers/helpers'; import { DataProviders } from '../../timeline/data_providers'; +import { FLYOUT_BUTTON_BAR_CLASS_NAME, FLYOUT_BUTTON_CLASS_NAME } from '../../timeline/helpers'; import { FlyoutHeaderPanel } from '../header'; import { TimelineTabs } from '../../../../../common/types/timeline'; -export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; - export const getBadgeCount = (dataProviders: DataProvider[]): number => flattenIntoAndGroups(dataProviders).reduce((total, group) => total + group.length, 0); @@ -76,7 +75,11 @@ interface FlyoutBottomBarProps { export const FlyoutBottomBar = React.memo( ({ activeTab, showDataproviders, timelineId }) => { return ( - + {showDataproviders && } {(showDataproviders || (!showDataproviders && activeTab !== TimelineTabs.query)) && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index ba24a1402adb..1dbd201d763a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -13,6 +13,10 @@ import { FormattedRelative } from '@kbn/i18n/react'; import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; +import { + ACTIVE_TIMELINE_BUTTON_CLASS_NAME, + focusActiveTimelineButton, +} from '../../timeline/helpers'; import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; import { timelineActions } from '../../../store/timeline'; import * as i18n from './translations'; @@ -50,11 +54,10 @@ const ActiveTimelinesComponent: React.FC = ({ isOpen, }) => { const dispatch = useDispatch(); - - const handleToggleOpen = useCallback( - () => dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })), - [dispatch, isOpen, timelineId] - ); + const handleToggleOpen = useCallback(() => { + dispatch(timelineActions.showTimeline({ id: timelineId, show: !isOpen })); + focusActiveTimelineButton(); + }, [dispatch, isOpen, timelineId]); const title = !isEmpty(timelineTitle) ? timelineTitle @@ -83,6 +86,7 @@ const ActiveTimelinesComponent: React.FC = ({ = ({ timeline [dataProviders, kqlQuery] ); - const handleClose = useCallback( - () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), - [dispatch, timelineId] - ); + const handleClose = useCallback(() => { + dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); + focusActiveTimelineButton(); + }, [dispatch, timelineId]); return ( = ({ timelineId }) => { const dispatch = useDispatch(); - const handleClose = useCallback( - () => dispatch(timelineActions.showTimeline({ id: timelineId, show: false })), - [dispatch, timelineId] - ); + const handleClose = useCallback(() => { + dispatch(timelineActions.showTimeline({ id: timelineId, show: false })); + focusActiveTimelineButton(); + }, [dispatch, timelineId]); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 2ded93377de9..ea68fc0a2a5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -17,18 +17,17 @@ import { EuiButton, EuiFlexGroup, EuiFlexItem, - EuiInMemoryTable, } from '@elastic/eui'; -import React, { useState, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; import { State } from '../../../common/store'; +import { RowRendererId } from '../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { setExcludedRowRendererIds as dispatchSetExcludedRowRendererIds } from '../../store/timeline/actions'; import { timelineSelectors } from '../../store/timeline'; import { timelineDefaults } from '../../store/timeline/defaults'; -import { renderers } from './catalog'; import { RowRenderersBrowser } from './row_renderers_browser'; import * as i18n from './translations'; @@ -81,7 +80,6 @@ interface StatefulRowRenderersBrowserProps { const StatefulRowRenderersBrowserComponent: React.FC = ({ timelineId, }) => { - const tableRef = useRef>(); const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const excludedRowRendererIds = useDeepEqualSelector( @@ -105,12 +103,12 @@ const StatefulRowRenderersBrowserComponent: React.FC setShow(false), []); const handleDisableAll = useCallback(() => { - tableRef?.current?.setSelection([]); - }, [tableRef]); + setExcludedRowRendererIds(Object.values(RowRendererId)); + }, [setExcludedRowRendererIds]); const handleEnableAll = useCallback(() => { - tableRef?.current?.setSelection(renderers); - }, [tableRef]); + setExcludedRowRendererIds([]); + }, [setExcludedRowRendererIds]); return ( <> @@ -168,7 +166,6 @@ const StatefulRowRenderersBrowserComponent: React.FC diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx index f1414724e243..ca5aa36b597d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexItem, EuiInMemoryTable } from '@elastic/eui'; +import { EuiCheckbox, EuiFlexItem, EuiInMemoryTable } from '@elastic/eui'; import React, { useMemo, useCallback } from 'react'; -import { xor, xorBy } from 'lodash/fp'; +import { xor } from 'lodash/fp'; import styled from 'styled-components'; import { RowRendererId } from '../../../../common/types/timeline'; @@ -76,101 +76,89 @@ const StyledNameButton = styled.button` text-align: left; `; -const RowRenderersBrowserComponent = React.forwardRef( - ({ excludedRowRendererIds = [], setExcludedRowRendererIds }: RowRenderersBrowserProps, ref) => { - const notExcludedRowRenderers = useMemo(() => { - if (excludedRowRendererIds.length === Object.keys(RowRendererId).length) return []; +const RowRenderersBrowserComponent = ({ + excludedRowRendererIds = [], + setExcludedRowRendererIds, +}: RowRenderersBrowserProps) => { + const handleNameClick = useCallback( + (item: RowRendererOption) => () => { + const newSelection = xor([item.id], excludedRowRendererIds); - return renderers.filter((renderer) => !excludedRowRendererIds.includes(renderer.id)); - }, [excludedRowRendererIds]); + setExcludedRowRendererIds(newSelection); + }, + [excludedRowRendererIds, setExcludedRowRendererIds] + ); - const handleNameClick = useCallback( - (item: RowRendererOption) => () => { - const newSelection = xor([item], notExcludedRowRenderers); - // @ts-expect-error - ref?.current?.setSelection(newSelection); - }, - [notExcludedRowRenderers, ref] - ); + const nameColumnRenderCallback = useCallback( + (value, item) => ( + + {value} + + ), + [handleNameClick] + ); - const nameColumnRenderCallback = useCallback( - (value, item) => ( - - {value} - - ), - [handleNameClick] - ); - - const columns = useMemo( - () => [ - { - field: 'name', - name: 'Name', - sortable: true, - width: '10%', - render: nameColumnRenderCallback, - }, - { - field: 'description', - name: 'Description', - width: '25%', - render: (description: React.ReactNode) => description, - }, - { - field: 'example', - name: 'Example', - width: '65%', - render: ExampleWrapperComponent, - }, - { - field: 'searchableDescription', - name: 'Searchable Description', - sortable: false, - width: '0px', - render: renderSearchableDescriptionNoop, - }, - ], - [nameColumnRenderCallback] - ); - - const handleSelectable = useCallback(() => true, []); - - const handleSelectionChange = useCallback( - (selection: RowRendererOption[]) => { - if (!selection || !selection.length) - return setExcludedRowRendererIds(Object.values(RowRendererId)); - - const excludedRowRenderers = xorBy('id', renderers, selection); - - setExcludedRowRendererIds(excludedRowRenderers.map((rowRenderer) => rowRenderer.id)); - }, - [setExcludedRowRendererIds] - ); - - const selectionValue = useMemo( - () => ({ - selectable: handleSelectable, - onSelectionChange: handleSelectionChange, - initialSelected: notExcludedRowRenderers, - }), - [handleSelectable, handleSelectionChange, notExcludedRowRenderers] - ); - - return ( - ( + - ); - } -); + ), + [excludedRowRendererIds, handleNameClick] + ); + + const columns = useMemo( + () => [ + { + field: 'id', + name: '', + sortable: false, + width: '32px', + render: idColumnRenderCallback, + }, + { + field: 'name', + name: 'Name', + sortable: true, + width: '10%', + render: nameColumnRenderCallback, + }, + { + field: 'description', + name: 'Description', + width: '25%', + render: (description: React.ReactNode) => description, + }, + { + field: 'example', + name: 'Example', + width: '65%', + render: ExampleWrapperComponent, + }, + { + field: 'searchableDescription', + name: 'Searchable Description', + sortable: false, + width: '0px', + render: renderSearchableDescriptionNoop, + }, + ], + [idColumnRenderCallback, nameColumnRenderCallback] + ); + + return ( + + ); +}; RowRenderersBrowserComponent.displayName = 'RowRenderersBrowserComponent'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index 695032d80f8a..83e783d97e40 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -474,6 +474,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` }, ] } + tabType="query" timelineId="test" /> `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx index 440f36cb465c..c067c200bf46 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/column_header.tsx @@ -4,19 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiContextMenu, EuiContextMenuPanelDescriptor, EuiIcon, EuiPopover } from '@elastic/eui'; import React, { useCallback, useMemo, useRef, useState } from 'react'; import { Draggable } from 'react-beautiful-dnd'; import { Resizable, ResizeCallback } from 're-resizable'; import deepEqual from 'fast-deep-equal'; import { useDispatch } from 'react-redux'; +import styled from 'styled-components'; import { useDraggableKeyboardWrapper } from '../../../../../common/components/drag_and_drop/draggable_keyboard_wrapper_hook'; import { DRAGGABLE_KEYBOARD_WRAPPER_CLASS_NAME, getDraggableFieldId, } from '../../../../../common/components/drag_and_drop/helpers'; +import { TimelineTabs } from '../../../../../../common/types/timeline'; import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; import { OnFilterChange } from '../../events'; +import { Direction } from '../../../../../graphql/types'; import { ARIA_COLUMN_INDEX_OFFSET } from '../../helpers'; import { EventsTh, EventsThContent, EventsHeadingHandle } from '../../styles'; import { Sort } from '../sort'; @@ -24,6 +28,25 @@ import { Sort } from '../sort'; import { Header } from './header'; import { timelineActions } from '../../../../store/timeline'; +import * as i18n from './translations'; + +const ContextMenu = styled(EuiContextMenu)` + width: 115px; + + & .euiContextMenuItem { + font-size: 12px; + padding: 4px 8px; + width: 115px; + } +`; + +const PopoverContainer = styled.div<{ $width: number }>` + & .euiPopover__anchor { + padding-right: 8px; + width: ${({ $width }) => $width}px; + } +`; + const RESIZABLE_ENABLE = { right: true }; interface ColumneHeaderProps { @@ -32,6 +55,7 @@ interface ColumneHeaderProps { isDragging: boolean; onFilterChange?: OnFilterChange; sort: Sort[]; + tabType: TimelineTabs; timelineId: string; } @@ -42,10 +66,11 @@ const ColumnHeaderComponent: React.FC = ({ isDragging, onFilterChange, sort, + tabType, }) => { const keyboardHandlerRef = useRef(null); - const [, setClosePopOverTrigger] = useState(false); - const [, setHoverActionsOwnFocus] = useState(false); + const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState(false); + const restoreFocus = useCallback(() => keyboardHandlerRef.current?.focus(), []); const dispatch = useDispatch(); const resizableSize = useMemo( @@ -84,10 +109,93 @@ const ColumnHeaderComponent: React.FC = ({ const draggableId = useMemo( () => getDraggableFieldId({ - contextId: `timeline-column-headers-${timelineId}`, + contextId: `timeline-column-headers-${tabType}-${timelineId}`, fieldId: header.id, }), - [timelineId, header.id] + [tabType, timelineId, header.id] + ); + + const onColumnSort = useCallback( + (sortDirection: Direction) => { + const columnId = header.id; + const headerIndex = sort.findIndex((col) => col.columnId === columnId); + const newSort = + headerIndex === -1 + ? [ + ...sort, + { + columnId, + columnType: `${header.type}`, + sortDirection, + }, + ] + : [ + ...sort.slice(0, headerIndex), + { + columnId, + columnType: `${header.type}`, + sortDirection, + }, + ...sort.slice(headerIndex + 1), + ]; + + dispatch( + timelineActions.updateSort({ + id: timelineId, + sort: newSort, + }) + ); + }, + [dispatch, header, sort, timelineId] + ); + + const handleClosePopOverTrigger = useCallback(() => { + setHoverActionsOwnFocus(false); + restoreFocus(); + }, [restoreFocus]); + + const panels: EuiContextMenuPanelDescriptor[] = useMemo( + () => [ + { + id: 0, + items: [ + { + icon: , + name: i18n.HIDE_COLUMN, + onClick: () => { + dispatch(timelineActions.removeColumn({ id: timelineId, columnId: header.id })); + handleClosePopOverTrigger(); + }, + }, + { + disabled: !header.aggregatable, + icon: , + name: i18n.SORT_AZ, + onClick: () => { + onColumnSort(Direction.asc); + handleClosePopOverTrigger(); + }, + }, + { + disabled: !header.aggregatable, + icon: , + name: i18n.SORT_ZA, + onClick: () => { + onColumnSort(Direction.desc); + handleClosePopOverTrigger(); + }, + }, + ], + }, + ], + [dispatch, handleClosePopOverTrigger, header.aggregatable, header.id, onColumnSort, timelineId] + ); + + const headerButton = useMemo( + () => ( +
+ ), + [header, onFilterChange, sort, timelineId] ); const DraggableContent = useCallback( @@ -99,26 +207,28 @@ const ColumnHeaderComponent: React.FC = ({ ref={dragProvided.innerRef} > -
+ + + + + ), - [header, onFilterChange, sort, timelineId] + [handleClosePopOverTrigger, headerButton, header.width, hoverActionsOwnFocus, panels] ); const onFocus = useCallback(() => { keyboardHandlerRef.current?.focus(); }, []); - const handleClosePopOverTrigger = useCallback(() => { - setClosePopOverTrigger((prevClosePopOverTrigger) => !prevClosePopOverTrigger); - }, []); - const openPopover = useCallback(() => { setHoverActionsOwnFocus(true); }, []); @@ -131,6 +241,15 @@ const ColumnHeaderComponent: React.FC = ({ openPopover, }); + const keyDownHandler = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (!hoverActionsOwnFocus) { + onKeyDown(keyboardEvent); + } + }, + [hoverActionsOwnFocus, onKeyDown] + ); + return ( = ({ data-test-subj="draggableWrapperKeyboardHandler" onClick={onFocus} onBlur={onBlur} - onKeyDown={onKeyDown} + onKeyDown={keyDownHandler} ref={keyboardHandlerRef} role="columnheader" tabIndex={0} @@ -171,6 +290,7 @@ export const ColumnHeader = React.memo( ColumnHeaderComponent, (prevProps, nextProps) => prevProps.draggableIndex === nextProps.draggableIndex && + prevProps.tabType === nextProps.tabType && prevProps.timelineId === nextProps.timelineId && prevProps.isDragging === nextProps.isDragging && prevProps.onFilterChange === nextProps.onFilterChange && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx index 307166388b49..22a450ac2926 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.test.tsx @@ -19,6 +19,7 @@ import { useMountAppended } from '../../../../../common/utils/use_mount_appended import { ColumnHeadersComponent } from '.'; import { cloneDeep } from 'lodash/fp'; import { timelineActions } from '../../../../store/timeline'; +import { TimelineTabs } from '../../../../../../common/types/timeline'; const mockDispatch = jest.fn(); jest.mock('react-redux', () => { @@ -55,6 +56,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} + tabType={TimelineTabs.query} timelineId={timelineId} /> @@ -74,6 +76,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} + tabType={TimelineTabs.query} timelineId={timelineId} /> @@ -94,6 +97,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={sort} + tabType={TimelineTabs.query} timelineId={timelineId} /> @@ -152,6 +156,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={mockSort} + tabType={TimelineTabs.query} timelineId={timelineId} /> @@ -193,6 +198,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={mockSort} + tabType={TimelineTabs.query} timelineId={timelineId} /> @@ -229,6 +235,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} showSelectAllCheckbox={false} sort={mockSort} + tabType={TimelineTabs.query} timelineId={timelineId} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 21f32a211f42..a6dd88553529 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -31,7 +31,7 @@ import { useGlobalFullScreen, useTimelineFullScreen, } from '../../../../../common/containers/use_full_screen'; -import { TimelineId } from '../../../../../../common/types/timeline'; +import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline'; import { OnSelectAll } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; import { StatefulFieldsBrowser } from '../../../fields_browser'; @@ -76,6 +76,7 @@ interface Props { showEventsSelect: boolean; showSelectAllCheckbox: boolean; sort: Sort[]; + tabType: TimelineTabs; timelineId: string; } @@ -122,6 +123,7 @@ export const ColumnHeadersComponent = ({ showEventsSelect, showSelectAllCheckbox, sort, + tabType, timelineId, }: Props) => { const dispatch = useDispatch(); @@ -186,9 +188,10 @@ export const ColumnHeadersComponent = ({ header={header} isDragging={draggingIndex === draggableIndex} sort={sort} + tabType={tabType} /> )), - [columnHeaders, timelineId, draggingIndex, sort] + [columnHeaders, timelineId, draggingIndex, sort, tabType] ); const fullScreen = useMemo( @@ -335,7 +338,7 @@ export const ColumnHeadersComponent = ({ ( showEventsSelect={false} showSelectAllCheckbox={showCheckboxes} sort={sort} + tabType={tabType} timelineId={id} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap index d589a9aa33f0..acc48bdc6c04 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/provider.test.tsx.snap @@ -6,9 +6,11 @@ exports[`Provider rendering renders correctly against snapshot 1`] = ` field="name" isEnabled={true} isExcluded={false} + isPopoverOpen={false} kqlQuery="" operator=":" providerId="id-Provider 1" + setIsPopoverOpen={[Function]} toggleEnabledProvider={[Function]} toggleExcludedProvider={[Function]} toggleTypeProvider={[Function]} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx index 2b598c7cf04f..0c52ec6ab41c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider.tsx @@ -5,7 +5,7 @@ */ import { noop } from 'lodash/fp'; -import React from 'react'; +import React, { useState } from 'react'; import { DataProvider, DataProviderType, IS_OPERATOR } from './data_provider'; import { ProviderItemBadge } from './provider_item_badge'; @@ -14,21 +14,27 @@ interface OwnProps { dataProvider: DataProvider; } -export const Provider = React.memo(({ dataProvider }) => ( - -)); +export const Provider = React.memo(({ dataProvider }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + return ( + + ); +}); Provider.displayName = 'Provider'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx index 7aa782c05c0d..a9808ee9c800 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_actions.tsx @@ -231,6 +231,7 @@ export class ProviderItemActions extends React.PureComponent { button={button} anchorPosition="downCenter" panelPaddingSize="none" + ownFocus={true} >
diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx index 866c07fd2fcc..db6051043e24 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/provider_item_badge.tsx @@ -28,10 +28,12 @@ interface ProviderItemBadgeProps { kqlQuery: string; isEnabled: boolean; isExcluded: boolean; + isPopoverOpen: boolean; onDataProviderEdited?: OnDataProviderEdited; operator: QueryOperator; providerId: string; register?: DataProvidersAnd; + setIsPopoverOpen: (isPopoverOpen: boolean) => void; timelineId?: string; toggleEnabledProvider: () => void; toggleExcludedProvider: () => void; @@ -50,10 +52,12 @@ export const ProviderItemBadge = React.memo( kqlQuery, isEnabled, isExcluded, + isPopoverOpen, onDataProviderEdited, operator, providerId, register, + setIsPopoverOpen, timelineId, toggleEnabledProvider, toggleExcludedProvider, @@ -75,16 +79,15 @@ export const ProviderItemBadge = React.memo( getManageTimelineById, timelineId, ]); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const togglePopover = useCallback(() => { setIsPopoverOpen(!isPopoverOpen); - }, [isPopoverOpen]); + }, [isPopoverOpen, setIsPopoverOpen]); const closePopover = useCallback(() => { setIsPopoverOpen(false); wrapperRef?.current?.focus(); - }, [wrapperRef]); + }, [wrapperRef, setIsPopoverOpen]); const onToggleEnabledProvider = useCallback(() => { toggleEnabledProvider(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index d01edebda60e..2f61756b97c6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -155,7 +155,7 @@ interface DataProvidersGroupItem extends Omit { export const DataProvidersGroupItem = React.memo( ({ browserFields, group, groupIndex, dataProvider, index, timelineId }) => { const keyboardHandlerRef = useRef(null); - const [, setHoverActionsOwnFocus] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [, setClosePopOverTrigger] = useState(false); const dispatch = useDispatch(); @@ -240,7 +240,7 @@ export const DataProvidersGroupItem = React.memo( }, []); const openPopover = useCallback(() => { - setHoverActionsOwnFocus(true); + setIsPopoverOpen(true); }, []); const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({ @@ -251,6 +251,15 @@ export const DataProvidersGroupItem = React.memo( openPopover, }); + const keyDownHandler = useCallback( + (keyboardEvent: React.KeyboardEvent) => { + if (keyboardHandlerRef.current === document.activeElement) { + onKeyDown(keyboardEvent); + } + }, + [onKeyDown] + ); + const DraggableContent = useCallback( (provided, snapshot) => (
( kqlQuery={index > 0 ? dataProvider.kqlQuery : group[0].kqlQuery} isEnabled={index > 0 ? dataProvider.enabled : group[0].enabled} isExcluded={index > 0 ? dataProvider.excluded : group[0].excluded} + isPopoverOpen={isPopoverOpen} onDataProviderEdited={handleDataProviderEdited} operator={ index > 0 @@ -284,6 +294,7 @@ export const DataProvidersGroupItem = React.memo( register={dataProvider} providerId={index > 0 ? group[0].id : dataProvider.id} timelineId={timelineId} + setIsPopoverOpen={setIsPopoverOpen} toggleEnabledProvider={handleToggleEnabledProvider} toggleExcludedProvider={handleToggleExcludedProvider} toggleTypeProvider={handleToggleTypeProvider} @@ -315,7 +326,9 @@ export const DataProvidersGroupItem = React.memo( handleToggleExcludedProvider, handleToggleTypeProvider, index, + isPopoverOpen, keyboardHandlerRef, + setIsPopoverOpen, timelineId, ] ); @@ -326,7 +339,7 @@ export const DataProvidersGroupItem = React.memo( data-test-subj="draggableWrapperKeyboardHandler" onClick={onFocus} onBlur={onBlur} - onKeyDown={onKeyDown} + onKeyDown={keyDownHandler} ref={keyboardHandlerRef} role="button" tabIndex={0} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index 02b3d0673a5a..f261d9ed60c6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -242,3 +242,40 @@ export const onTimelineTabKeyPressed = ({ }); } }; + +export const ACTIVE_TIMELINE_BUTTON_CLASS_NAME = 'active-timeline-button'; +export const FLYOUT_BUTTON_BAR_CLASS_NAME = 'timeline-flyout-button-bar'; +export const FLYOUT_BUTTON_CLASS_NAME = 'timeline-flyout-button'; + +/** + * This function focuses the active timeline button on the next tick. Focus + * is updated on the next tick because this function is typically + * invoked in `onClick` handlers that also dispatch Redux actions (that + * in-turn update focus states). + */ +export const focusActiveTimelineButton = () => { + setTimeout(() => { + document + .querySelector( + `div.${FLYOUT_BUTTON_BAR_CLASS_NAME} .${ACTIVE_TIMELINE_BUTTON_CLASS_NAME}` + ) + ?.focus(); + }, 0); +}; + +/** + * Focuses the utility bar action contained by the provided `containerElement` + * when a valid container is provided + */ +export const focusUtilityBarAction = (containerElement: HTMLElement | null) => { + containerElement + ?.querySelector('div.siemUtilityBar__action:last-of-type button') + ?.focus(); +}; + +/** + * Resets keyboard focus on the page + */ +export const resetKeyboardFocus = () => { + document.querySelector('header.headerGlobalNav a.euiHeaderLogo')?.focus(); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx index d86f0a936376..a81318ee95ed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/styles.tsx @@ -432,6 +432,7 @@ export const EventsHeadingHandle = styled.div.attrs(({ className = '' }) => ({ */ export const EventsLoading = styled(EuiLoadingSpinner)` + margin: 0 2px; vertical-align: middle; `;