[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
This commit is contained in:
Andrew Goldstein 2021-01-12 01:02:53 -07:00 committed by GitHub
parent 5dca937c01
commit 46083c0973
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 575 additions and 180 deletions

View file

@ -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');
});
});
});

View file

@ -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();

View file

@ -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();

View file

@ -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"]';

View file

@ -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"]';

View file

@ -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) {

View file

@ -220,7 +220,7 @@ const AlertsUtilityBarComponent: React.FC<AlertsUtilityBarProps> = ({
disabled={areEventsLoading}
iconType="arrowDown"
iconSide="right"
ownFocus={false}
ownFocus={true}
popoverContent={UtilityBarAdditionalFiltersContent}
>
{i18n.ADDITIONAL_FILTERS_ACTIONS}

View file

@ -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<HTMLDivElement | null>(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 (
<WrapperPage>
@ -154,7 +183,7 @@ const DetectionEnginePageComponent = () => {
{hasEncryptionKey != null && !hasEncryptionKey && <NoApiIntegrationKeyCallOut />}
<ReadOnlyAlertsCallOut />
{indicesExist ? (
<>
<div onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />
<FiltersGlobal show={showGlobalFilters({ globalFullScreen, graphEventId })}>
<SiemSearchBar id="global" indexPattern={indexPattern} />
@ -211,7 +240,7 @@ const DetectionEnginePageComponent = () => {
to={to}
/>
</WrapperPage>
</>
</div>
) : (
<WrapperPage>
<DetectionEngineHeaderPage border title={i18n.PAGE_TITLE} />

View file

@ -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<HTMLDivElement | null>(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 = () => {
<ReadOnlyAlertsCallOut />
<ReadOnlyRulesCallOut />
{indicesExist ? (
<>
<div onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />
<FiltersGlobal show={showGlobalFilters({ globalFullScreen, graphEventId })}>
<SiemSearchBar id="global" indexPattern={indexPattern} />
@ -588,7 +617,7 @@ const RuleDetailsPageComponent = () => {
)}
{ruleDetailTab === RuleDetailTabs.failures && <FailureHistory id={rule?.id} />}
</WrapperPage>
</>
</div>
) : (
<WrapperPage>
<DetectionEngineHeaderPage border title={i18n.PAGE_TITLE} />

View file

@ -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<HTMLDivElement | null>(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<HTMLButtonElement>('.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 ? (
<>
<div onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />
<FiltersGlobal show={showGlobalFilters({ globalFullScreen, graphEventId })}>
<SiemSearchBar indexPattern={indexPattern} id="global" />
@ -167,7 +197,7 @@ const HostsComponent = () => {
type={hostsModel.HostsType.page}
/>
</WrapperPage>
</>
</div>
) : (
<WrapperPage>
<HeaderPage border title={i18n.PAGE_TITLE} />

View file

@ -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<NetworkComponentProps>(
({ networkPagePath, hasMlUserPermissions, capabilitiesFetched }) => {
const dispatch = useDispatch();
const containerElement = useRef<HTMLDivElement | null>(null);
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const graphEventId = useShallowEqualSelector(
(state) =>
@ -91,6 +97,31 @@ const NetworkComponent = React.memo<NetworkComponentProps>(
);
const { docValueFields, indicesExist, indexPattern, selectedPatterns } = useSourcererScope();
const onSkipFocusBeforeEventsTable = useCallback(() => {
containerElement.current
?.querySelector<HTMLButtonElement>('.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<NetworkComponentProps>(
return (
<>
{indicesExist ? (
<>
<div onKeyDown={onKeyDown} ref={containerElement}>
<EuiWindowEvent event="resize" handler={noop} />
<FiltersGlobal show={showGlobalFilters({ globalFullScreen, graphEventId })}>
<SiemSearchBar indexPattern={indexPattern} id="global" />
@ -176,7 +207,7 @@ const NetworkComponent = React.memo<NetworkComponentProps>(
<NetworkRoutesLoading />
)}
</WrapperPage>
</>
</div>
) : (
<WrapperPage>
<HeaderPage border title={i18n.PAGE_TITLE} />

View file

@ -239,7 +239,7 @@ const FieldsBrowserComponent: React.FC<Props> = ({
data-test-subj="header"
filteredBrowserFields={filteredBrowserFields}
isSearching={isSearching}
onOutsideClick={onOutsideClick}
onOutsideClick={closeAndRestoreFocus}
onSearchInputChange={onInputChange}
onUpdateColumns={onUpdateColumns}
searchInput={searchInput}

View file

@ -55,6 +55,7 @@ const AddTimelineButtonComponent: React.FC<AddTimelineButtonComponentProps> = ({
id="timelineSettingsPopover"
isOpen={showActions}
closePopover={onClosePopover}
ownFocus
repositionOnScroll
>
<EuiFlexGroup alignItems="flexStart" direction="column" gutterSize="none">

View file

@ -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<FlyoutBottomBarProps>(
({ activeTab, showDataproviders, timelineId }) => {
return (
<Container $isGlobal={showDataproviders} data-test-subj="flyoutBottomBar">
<Container
className={FLYOUT_BUTTON_BAR_CLASS_NAME}
$isGlobal={showDataproviders}
data-test-subj="flyoutBottomBar"
>
{showDataproviders && <FlyoutHeaderPanel timelineId={timelineId} />}
{(showDataproviders || (!showDataproviders && activeTab !== TimelineTabs.query)) && (
<DataProvidersPanel paddingSize="none">

View file

@ -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<ActiveTimelinesProps> = ({
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<ActiveTimelinesProps> = ({
<ButtonWrapper grow={false}>
<StyledEuiButtonEmpty
aria-label={i18n.TIMELINE_TOGGLE_BUTTON_ARIA_LABEL({ isOpen, title })}
className={ACTIVE_TIMELINE_BUTTON_CLASS_NAME}
flush="both"
data-test-subj="flyoutOverlay"
size="s"

View file

@ -33,6 +33,7 @@ import { ActiveTimelines } from './active_timelines';
import * as i18n from './translations';
import * as commonI18n from '../../timeline/properties/translations';
import { getTimelineStatusByIdSelector } from './selectors';
import { focusActiveTimelineButton } from '../../timeline/helpers';
// to hide side borders
const StyledPanel = styled(EuiPanel)`
@ -79,10 +80,10 @@ const FlyoutHeaderPanelComponent: React.FC<FlyoutHeaderPanelProps> = ({ 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 (
<StyledPanel

View file

@ -12,6 +12,7 @@ import { useDispatch } from 'react-redux';
import { StatefulTimeline } from '../../timeline';
import * as i18n from './translations';
import { timelineActions } from '../../../store/timeline';
import { focusActiveTimelineButton } from '../../timeline/helpers';
interface FlyoutPaneComponentProps {
timelineId: string;
@ -28,10 +29,10 @@ const EuiFlyoutContainer = styled.div`
const FlyoutPaneComponent: React.FC<FlyoutPaneComponentProps> = ({ 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 (
<EuiFlyoutContainer data-test-subj="flyout-pane">

View file

@ -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<StatefulRowRenderersBrowserProps> = ({
timelineId,
}) => {
const tableRef = useRef<EuiInMemoryTable<{}>>();
const dispatch = useDispatch();
const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []);
const excludedRowRendererIds = useDeepEqualSelector(
@ -105,12 +103,12 @@ const StatefulRowRenderersBrowserComponent: React.FC<StatefulRowRenderersBrowser
const hideFieldBrowser = useCallback(() => 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<StatefulRowRenderersBrowser
<StyledEuiModalBody>
<RowRenderersBrowser
ref={tableRef}
excludedRowRendererIds={excludedRowRendererIds}
setExcludedRowRendererIds={setExcludedRowRendererIds}
/>

View file

@ -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) => (
<StyledNameButton className="kbn-resetFocusState" onClick={handleNameClick(item)}>
{value}
</StyledNameButton>
),
[handleNameClick]
);
const nameColumnRenderCallback = useCallback(
(value, item) => (
<StyledNameButton className="kbn-resetFocusState" onClick={handleNameClick(item)}>
{value}
</StyledNameButton>
),
[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 (
<StyledEuiInMemoryTable
ref={ref}
items={renderers}
itemId="id"
columns={columns}
search={search}
sorting={initialSorting}
isSelectable={true}
selection={selectionValue}
const idColumnRenderCallback = useCallback(
(_, item) => (
<EuiCheckbox
id={item.id}
onChange={handleNameClick(item)}
checked={!excludedRowRendererIds.includes(item.id)}
/>
);
}
);
),
[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 (
<StyledEuiInMemoryTable
items={renderers}
itemId="id"
columns={columns}
search={search}
sorting={initialSorting}
isSelectable={true}
/>
);
};
RowRenderersBrowserComponent.displayName = 'RowRenderersBrowserComponent';

View file

@ -474,6 +474,7 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = `
},
]
}
tabType="query"
timelineId="test"
/>
`;

View file

@ -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<ColumneHeaderProps> = ({
isDragging,
onFilterChange,
sort,
tabType,
}) => {
const keyboardHandlerRef = useRef<HTMLDivElement | null>(null);
const [, setClosePopOverTrigger] = useState(false);
const [, setHoverActionsOwnFocus] = useState<boolean>(false);
const [hoverActionsOwnFocus, setHoverActionsOwnFocus] = useState<boolean>(false);
const restoreFocus = useCallback(() => keyboardHandlerRef.current?.focus(), []);
const dispatch = useDispatch();
const resizableSize = useMemo(
@ -84,10 +109,93 @@ const ColumnHeaderComponent: React.FC<ColumneHeaderProps> = ({
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: <EuiIcon type="eyeClosed" size="s" />,
name: i18n.HIDE_COLUMN,
onClick: () => {
dispatch(timelineActions.removeColumn({ id: timelineId, columnId: header.id }));
handleClosePopOverTrigger();
},
},
{
disabled: !header.aggregatable,
icon: <EuiIcon type="sortUp" size="s" />,
name: i18n.SORT_AZ,
onClick: () => {
onColumnSort(Direction.asc);
handleClosePopOverTrigger();
},
},
{
disabled: !header.aggregatable,
icon: <EuiIcon type="sortDown" size="s" />,
name: i18n.SORT_ZA,
onClick: () => {
onColumnSort(Direction.desc);
handleClosePopOverTrigger();
},
},
],
},
],
[dispatch, handleClosePopOverTrigger, header.aggregatable, header.id, onColumnSort, timelineId]
);
const headerButton = useMemo(
() => (
<Header timelineId={timelineId} header={header} onFilterChange={onFilterChange} sort={sort} />
),
[header, onFilterChange, sort, timelineId]
);
const DraggableContent = useCallback(
@ -99,26 +207,28 @@ const ColumnHeaderComponent: React.FC<ColumneHeaderProps> = ({
ref={dragProvided.innerRef}
>
<EventsThContent>
<Header
timelineId={timelineId}
header={header}
onFilterChange={onFilterChange}
sort={sort}
/>
<PopoverContainer $width={header.width}>
<EuiPopover
anchorPosition="downLeft"
button={headerButton}
closePopover={handleClosePopOverTrigger}
isOpen={hoverActionsOwnFocus}
ownFocus
panelPaddingSize="none"
>
<ContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
</PopoverContainer>
</EventsThContent>
</EventsTh>
),
[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<ColumneHeaderProps> = ({
openPopover,
});
const keyDownHandler = useCallback(
(keyboardEvent: React.KeyboardEvent) => {
if (!hoverActionsOwnFocus) {
onKeyDown(keyboardEvent);
}
},
[hoverActionsOwnFocus, onKeyDown]
);
return (
<Resizable
enable={RESIZABLE_ENABLE}
@ -147,7 +266,7 @@ const ColumnHeaderComponent: React.FC<ColumneHeaderProps> = ({
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 &&

View file

@ -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}
/>
</TestProviders>
@ -74,6 +76,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={sort}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
</TestProviders>
@ -94,6 +97,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={sort}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
</TestProviders>
@ -152,6 +156,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
</TestProviders>
@ -193,6 +198,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
</TestProviders>
@ -229,6 +235,7 @@ describe('ColumnHeaders', () => {
showEventsSelect={false}
showSelectAllCheckbox={false}
sort={mockSort}
tabType={TimelineTabs.query}
timelineId={timelineId}
/>
</TestProviders>

View file

@ -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 = ({
<Droppable
direction={'horizontal'}
droppableId={`${droppableTimelineColumnsPrefix}${timelineId}`}
droppableId={`${droppableTimelineColumnsPrefix}-${tabType}.${timelineId}`}
isDropDisabled={false}
type={DRAG_TYPE_FIELD}
renderClone={renderClone}

View file

@ -22,10 +22,22 @@ export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullS
defaultMessage: 'Full screen',
});
export const HIDE_COLUMN = i18n.translate('xpack.securitySolution.timeline.hideColumnLabel', {
defaultMessage: 'Hide column',
});
export const SORT_AZ = i18n.translate('xpack.securitySolution.timeline.sortAZLabel', {
defaultMessage: 'Sort A-Z',
});
export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', {
defaultMessage: 'Sort fields',
});
export const SORT_ZA = i18n.translate('xpack.securitySolution.timeline.sortZALabel', {
defaultMessage: 'Sort Z-A',
});
export const TYPE = i18n.translate('xpack.securitySolution.timeline.typeTooltip', {
defaultMessage: 'Type',
});

View file

@ -195,6 +195,7 @@ export const BodyComponent = React.memo<StatefulBodyProps>(
showEventsSelect={false}
showSelectAllCheckbox={showCheckboxes}
sort={sort}
tabType={tabType}
timelineId={id}
/>

View file

@ -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]}

View file

@ -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<OwnProps>(({ dataProvider }) => (
<ProviderItemBadge
deleteProvider={noop}
field={dataProvider.queryMatch.displayField || dataProvider.queryMatch.field}
kqlQuery={dataProvider.kqlQuery}
isEnabled={dataProvider.enabled}
isExcluded={dataProvider.excluded}
providerId={dataProvider.id}
toggleExcludedProvider={noop}
toggleEnabledProvider={noop}
toggleTypeProvider={noop}
val={dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value}
operator={dataProvider.queryMatch.operator || IS_OPERATOR}
type={dataProvider.type || DataProviderType.default}
/>
));
export const Provider = React.memo<OwnProps>(({ dataProvider }) => {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return (
<ProviderItemBadge
deleteProvider={noop}
field={dataProvider.queryMatch.displayField || dataProvider.queryMatch.field}
kqlQuery={dataProvider.kqlQuery}
isEnabled={dataProvider.enabled}
isExcluded={dataProvider.excluded}
providerId={dataProvider.id}
isPopoverOpen={isPopoverOpen}
setIsPopoverOpen={setIsPopoverOpen}
toggleExcludedProvider={noop}
toggleEnabledProvider={noop}
toggleTypeProvider={noop}
val={dataProvider.queryMatch.displayValue || dataProvider.queryMatch.value}
operator={dataProvider.queryMatch.operator || IS_OPERATOR}
type={dataProvider.type || DataProviderType.default}
/>
);
});
Provider.displayName = 'Provider';

View file

@ -231,6 +231,7 @@ export class ProviderItemActions extends React.PureComponent<OwnProps> {
button={button}
anchorPosition="downCenter"
panelPaddingSize="none"
ownFocus={true}
>
<div style={{ userSelect: 'none' }}>
<EuiContextMenu initialPanelId={0} panels={panelTree} data-test-subj="providerActions" />

View file

@ -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<ProviderItemBadgeProps>(
kqlQuery,
isEnabled,
isExcluded,
isPopoverOpen,
onDataProviderEdited,
operator,
providerId,
register,
setIsPopoverOpen,
timelineId,
toggleEnabledProvider,
toggleExcludedProvider,
@ -75,16 +79,15 @@ export const ProviderItemBadge = React.memo<ProviderItemBadgeProps>(
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();

View file

@ -155,7 +155,7 @@ interface DataProvidersGroupItem extends Omit<Props, 'dataProviders'> {
export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
({ browserFields, group, groupIndex, dataProvider, index, timelineId }) => {
const keyboardHandlerRef = useRef<HTMLDivElement | null>(null);
const [, setHoverActionsOwnFocus] = useState<boolean>(false);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [, setClosePopOverTrigger] = useState(false);
const dispatch = useDispatch();
@ -240,7 +240,7 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
}, []);
const openPopover = useCallback(() => {
setHoverActionsOwnFocus(true);
setIsPopoverOpen(true);
}, []);
const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({
@ -251,6 +251,15 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
openPopover,
});
const keyDownHandler = useCallback(
(keyboardEvent: React.KeyboardEvent<Element>) => {
if (keyboardHandlerRef.current === document.activeElement) {
onKeyDown(keyboardEvent);
}
},
[onKeyDown]
);
const DraggableContent = useCallback(
(provided, snapshot) => (
<div
@ -275,6 +284,7 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
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<DataProvidersGroupItem>(
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<DataProvidersGroupItem>(
handleToggleExcludedProvider,
handleToggleTypeProvider,
index,
isPopoverOpen,
keyboardHandlerRef,
setIsPopoverOpen,
timelineId,
]
);
@ -326,7 +339,7 @@ export const DataProvidersGroupItem = React.memo<DataProvidersGroupItem>(
data-test-subj="draggableWrapperKeyboardHandler"
onClick={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
onKeyDown={keyDownHandler}
ref={keyboardHandlerRef}
role="button"
tabIndex={0}

View file

@ -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<HTMLButtonElement>(
`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<HTMLButtonElement>('div.siemUtilityBar__action:last-of-type button')
?.focus();
};
/**
* Resets keyboard focus on the page
*/
export const resetKeyboardFocus = () => {
document.querySelector<HTMLAnchorElement>('header.headerGlobalNav a.euiHeaderLogo')?.focus();
};

View file

@ -432,6 +432,7 @@ export const EventsHeadingHandle = styled.div.attrs(({ className = '' }) => ({
*/
export const EventsLoading = styled(EuiLoadingSpinner)`
margin: 0 2px;
vertical-align: middle;
`;