fix: [Rules > Rule Detail][SCREEN READER]: Abbreviations must be readaloud correctly (#182417)

Closes: https://github.com/elastic/security-team/issues/8649
Closes: https://github.com/elastic/security-team/issues/8658

## Description

The Schedule description list on Rule Detail views is announcing hours
and minutes incorrectly to screen readers. VoiceOver announced `5m` as
"Five meters" and `1h` as "1 eche". This confusion can be remedied by
spelling out the whole word and hiding it visually. Screen shot and code
sample attached.

### Steps to recreate

1. Open [Detection Rules
(SIEM)](https://kibana.siem.estc.dev/app/security/rules/management)
2. Click on a rule name to land on the detail view
3. Start your preferred screen reader
4. Jump down to the Schedule module and listen to the description list
items

### What was done?: 
The `IntervalAbbrScreenReader` component was developed, and it was
integrated into
`/rule_creation_ui/components/description_step/index.tsx` and
`/rule_details/rule_schedule_section.tsx `to handle the `interval` and
`from` fields.

### Screen:
<img width="1508" alt="image"
src="06e54e68-0dcf-45e3-95df-b3c09cb56db5">
 
#### DOM:
<img width="769" alt="image"
src="b0ba88f2-79a2-4fd2-9246-ad5639090bec">
This commit is contained in:
Alexey Antonov 2024-05-10 13:30:54 +03:00 committed by GitHub
parent f3a04a24dd
commit 99813cff15
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 200 additions and 19 deletions

View file

@ -6,3 +6,4 @@
*/
export * from './tooltip_with_keyboard_shortcut';
export * from './interval_abbr_screen_reader';

View file

@ -0,0 +1,39 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { TestProviders } from '../../../mock';
import { IntervalAbbrScreenReader } from '.';
describe('IntervalAbbrScreenReader', () => {
test('should add screen reader text for 35s', () => {
render(
<TestProviders>
<IntervalAbbrScreenReader interval="35s" />
</TestProviders>
);
expect(screen.getByText('35 seconds')).toBeDefined();
});
test('should add screen reader text for 1m', () => {
render(
<TestProviders>
<IntervalAbbrScreenReader interval="1m" />
</TestProviders>
);
expect(screen.getByText('1 minute')).toBeDefined();
});
test('should add screen reader text for 2h', () => {
render(
<TestProviders>
<IntervalAbbrScreenReader interval="2h" />
</TestProviders>
);
expect(screen.getByText('2 hours')).toBeDefined();
});
});

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import { EuiScreenReaderOnly } from '@elastic/eui';
import * as i18n from './translations';
interface IntervalAbbrScreenReaderProps {
interval: string;
}
export const IntervalAbbrScreenReader = ({ interval }: IntervalAbbrScreenReaderProps) => {
const screenReaderInterval: string | undefined = useMemo(() => {
if (interval) {
const number = parseInt(interval.slice(0, -1), 10);
const unit = interval.charAt(interval.length - 1);
if (Number.isFinite(number)) {
switch (unit) {
case 's': {
return i18n.SECONDS_SCREEN_READER(number);
}
case 'm': {
return i18n.MINUTES_SCREEN_READER(number);
}
case 'h': {
return i18n.HOURS_SCREEN_READER(number);
}
}
}
}
return undefined;
}, [interval]);
return (
<>
<span data-test-subj="interval-abbr-value" aria-hidden={Boolean(screenReaderInterval)}>
{interval}
</span>
{screenReaderInterval && (
<EuiScreenReaderOnly>
<p>{screenReaderInterval}</p>
</EuiScreenReaderOnly>
)}
</>
);
};

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const SECONDS_SCREEN_READER = (value: number) =>
i18n.translate('xpack.securitySolution.accessibility.intervalAbbrScreenReader.seconds', {
defaultMessage: '{value} {value, plural, one { second } other { seconds }}',
values: {
value,
},
});
export const MINUTES_SCREEN_READER = (value: number) =>
i18n.translate('xpack.securitySolution.accessibility.intervalAbbrScreenReader.minutes', {
defaultMessage: '{value} {value, plural, one { minute } other { minutes }}',
values: {
value,
},
});
export const HOURS_SCREEN_READER = (value: number) =>
i18n.translate('xpack.securitySolution.accessibility.intervalAbbrScreenReader.hours', {
defaultMessage: '{value} {value, plural, one { hour } other { hours }}',
values: {
value,
},
});

View file

@ -27,6 +27,7 @@ import { FieldIcon } from '@kbn/react-field';
import type { ThreatMapping, Type, Threats } from '@kbn/securitysolution-io-ts-alerting-types';
import { FilterBadgeGroup } from '@kbn/unified-search-plugin/public';
import { IntervalAbbrScreenReader } from '../../../../common/components/accessibility';
import type {
RequiredFieldArray,
Threshold,
@ -676,3 +677,12 @@ export const buildSetupDescription = (label: string, setup: string): ListItems[]
}
return [];
};
export const buildIntervalDescription = (label: string, value: string): ListItems[] => {
return [
{
title: label,
description: <IntervalAbbrScreenReader interval={value} />,
},
];
};

View file

@ -49,6 +49,7 @@ import {
buildHighlightedFieldsOverrideDescription,
buildSetupDescription,
getQueryLabel,
buildIntervalDescription,
} from './helpers';
import * as i18n from './translations';
import { buildMlJobsDescription } from './build_ml_jobs_description';
@ -342,6 +343,8 @@ export const getDescriptionItem = (
return get('isBuildingBlock', data)
? [{ title: i18n.BUILDING_BLOCK_LABEL, description: i18n.BUILDING_BLOCK_DESCRIPTION }]
: [];
} else if (['interval', 'from'].includes(field)) {
return buildIntervalDescription(label, get(field, data));
} else if (field === 'maxSignals') {
const value: number | undefined = get(field, data);
return value ? [{ title: label, description: value }] : [];

View file

@ -8,6 +8,7 @@
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import { IntervalAbbrScreenReader } from '../../../../common/components/accessibility';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
@ -19,7 +20,7 @@ interface IntervalProps {
const Interval = ({ interval }: IntervalProps) => (
<EuiText size="s" data-test-subj="intervalPropertyValue">
{interval}
<IntervalAbbrScreenReader interval={interval} />
</EuiText>
);
@ -30,7 +31,7 @@ interface FromProps {
const From = ({ from, interval }: FromProps) => (
<EuiText size="s" data-test-subj={`fromPropertyValue-${from}`}>
{getHumanizedDuration(from, interval)}
<IntervalAbbrScreenReader interval={getHumanizedDuration(from, interval)} />
</EuiText>
);

View file

@ -46,6 +46,7 @@ import {
TIMELINE_TEMPLATE_DETAILS,
DATA_VIEW_DETAILS,
EDIT_RULE_SETTINGS_LINK,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { GLOBAL_SEARCH_BAR_FILTER_ITEM } from '../../../../screens/search_bar';
@ -143,12 +144,16 @@ describe('Custom query rules', { tags: ['@ess', '@serverless'] }, () => {
});
cy.get(DEFINITION_DETAILS).should('not.contain', INDEX_PATTERNS_DETAILS);
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -39,6 +39,7 @@ import {
SEVERITY_DETAILS,
TAGS_DETAILS,
TIMELINE_TEMPLATE_DETAILS,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { getDetails, waitForTheRuleToBeExecuted } from '../../../../tasks/rule_details';
@ -125,12 +126,16 @@ describe('EQL rules', { tags: ['@ess', '@serverless'] }, () => {
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -40,6 +40,7 @@ import {
INDICATOR_INDEX_QUERY,
INDICATOR_MAPPING,
INDICATOR_PREFIX_OVERRIDE,
INTERVAL_ABBR_VALUE,
INVESTIGATION_NOTES_MARKDOWN,
INVESTIGATION_NOTES_TOGGLE,
MITRE_ATTACK_DETAILS,
@ -478,12 +479,16 @@ describe('indicator match', { tags: ['@ess', '@serverless'] }, () => {
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -37,6 +37,7 @@ import {
SEVERITY_DETAILS,
TAGS_DETAILS,
TIMELINE_TEMPLATE_DETAILS,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { getDetails } from '../../../../tasks/rule_details';
@ -114,12 +115,16 @@ describe('Machine Learning rules', { tags: ['@ess', '@serverless'] }, () => {
cy.get(MACHINE_LEARNING_JOB_ID).should('have.text', machineLearningJobsArray.join(''));
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${mlRule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${mlRule.interval}`);
const humanizedDuration = getHumanizedDuration(
mlRule.from ?? 'now-6m',
mlRule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
});
});

View file

@ -44,6 +44,7 @@ import {
SUPPRESS_BY_DETAILS,
SUPPRESS_FOR_DETAILS,
SUPPRESS_MISSING_FIELD,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { getDetails, waitForTheRuleToBeExecuted } from '../../../../tasks/rule_details';
@ -136,12 +137,16 @@ describe(
getDetails(NEW_TERMS_HISTORY_WINDOW_DETAILS).should('have.text', '51000h');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -49,6 +49,7 @@ import {
TAGS_DETAILS,
TIMELINE_TEMPLATE_DETAILS,
TIMESTAMP_OVERRIDE_DETAILS,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
@ -139,9 +140,13 @@ describe('Rules override', { tags: ['@ess', '@serverless'] }, () => {
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'None');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(rule.from ?? 'now-6m', rule.interval ?? '5m');
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -42,6 +42,7 @@ import {
THRESHOLD_DETAILS,
TIMELINE_TEMPLATE_DETAILS,
SUPPRESS_FOR_DETAILS,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { expectNumberOfRules, goToRuleDetailsOf } from '../../../../tasks/alerts_detection_rules';
import { deleteAlertsAndRules } from '../../../../tasks/api_calls/common';
@ -134,12 +135,16 @@ describe(
assertDetailsNotExist(SUPPRESS_FOR_DETAILS);
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', `${rule.interval}`);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${rule.interval}`);
const humanizedDuration = getHumanizedDuration(
rule.from ?? 'now-6m',
rule.interval ?? '5m'
);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', `${humanizedDuration}`);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', `${humanizedDuration}`);
});
waitForTheRuleToBeExecuted();

View file

@ -36,6 +36,7 @@ import {
CUSTOM_QUERY_DETAILS,
DEFINITION_DETAILS,
INDEX_PATTERNS_DETAILS,
INTERVAL_ABBR_VALUE,
INVESTIGATION_NOTES_TOGGLE,
RISK_SCORE_DETAILS,
RULE_NAME_HEADER,
@ -149,7 +150,9 @@ describe('Custom query rules', { tags: ['@ess', '@serverless'] }, () => {
});
if (getEditedRule().interval) {
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', getEditedRule().interval);
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', getEditedRule().interval);
});
}
});

View file

@ -41,6 +41,7 @@ import {
THREAT_TACTIC,
THREAT_TECHNIQUE,
TIMELINE_TEMPLATE_DETAILS,
INTERVAL_ABBR_VALUE,
} from '../../../../screens/rule_details';
import { createTimeline } from '../../../../tasks/api_calls/timelines';
@ -153,8 +154,10 @@ describe('Common rule detail flows', { tags: ['@ess', '@serverless'] }, function
getDetails(TIMELINE_TEMPLATE_DETAILS).should('have.text', 'Security Timeline');
});
cy.get(SCHEDULE_DETAILS).within(() => {
getDetails(RUNS_EVERY_DETAILS).should('have.text', ruleFields.ruleInterval);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).should('have.text', '55m');
getDetails(RUNS_EVERY_DETAILS)
.find(INTERVAL_ABBR_VALUE)
.should('have.text', ruleFields.ruleInterval);
getDetails(ADDITIONAL_LOOK_BACK_DETAILS).find(INTERVAL_ABBR_VALUE).should('have.text', '55m');
});
});

View file

@ -17,6 +17,8 @@ export const ABOUT_DETAILS =
export const ADDITIONAL_LOOK_BACK_DETAILS = 'Additional look-back time';
export const INTERVAL_ABBR_VALUE = '[data-test-subj="interval-abbr-value"]';
export const ALERTS_TAB = '[data-test-subj="navigation-alerts"]';
export const ANOMALY_SCORE_DETAILS = 'Anomaly score';