[ML] [Accessibility] Screen reader should announce field name as well as type for data visualizer card #31108 (#34689) (#35059)

* card title announce itself including type and name on both data visualize card and indexed stats' cards
* aria labels moved from IconType to FieldTypesUtils; tabIndex moved from IconType to Card's component;
* refactoring ML components, moved some code to field_types_utils.js, rewritten field_type_icon component, tests are adjusted;
This commit is contained in:
Philipp B 2019-04-15 14:10:03 +03:00 committed by GitHub
parent 700a5f2455
commit 539905511a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 173 additions and 104 deletions

View file

@ -10,6 +10,7 @@ import React from 'react';
import { EuiText, EuiToolTip } from '@elastic/eui';
import { FieldTypeIcon } from '../field_type_icon';
import { getMLJobTypeAriaLabel } from '../../util/field_types_utils';
import { i18n } from '@kbn/i18n';
export function FieldTitleBar({ card }) {
@ -18,7 +19,13 @@ export function FieldTitleBar({ card }) {
return null;
}
const fieldName = card.fieldName || i18n.translate('xpack.ml.fieldTitleBar.documentCountLabel', {
defaultMessage: 'document count'
});
const cardTitleAriaLabel = [fieldName];
const classNames = ['ml-field-title-bar'];
if (card.fieldName === undefined) {
classNames.push('document_count');
} else if (card.isUnsupportedType === true) {
@ -27,15 +34,21 @@ export function FieldTitleBar({ card }) {
classNames.push(card.type);
}
const fieldName = card.fieldName || i18n.translate('xpack.ml.fieldTitleBar.documentCountLabel', {
defaultMessage: 'document count'
});
if (card.isUnsupportedType !== true) {
cardTitleAriaLabel.unshift(
getMLJobTypeAriaLabel(card.type)
);
}
return (
<EuiText className={classNames.join(' ')}>
<FieldTypeIcon type={card.type} tooltipEnabled={true} />
<FieldTypeIcon type={card.type} tooltipEnabled={true} needsAria={false} />
<EuiToolTip position="left" content={fieldName}>
<div className="field-name">
<div
className="field-name"
tabIndex="0"
aria-label={`${cardTitleAriaLabel.join(', ')}`}
>
{fieldName}
</div>
</EuiToolTip>

View file

@ -5,13 +5,15 @@ exports[`FieldTypeIcon render component when type matches a field type 1`] = `
ariaLabel="keyword type"
className="field-type-icon"
iconChar="t"
needsAria={true}
/>
`;
exports[`FieldTypeIcon update component 1`] = `
<FieldTypeIconContainer
ariaLabel="IP type"
ariaLabel="ip type"
className="field-type-icon kuiIcon fa-laptop"
iconChar=""
needsAria={true}
/>
`;

View file

@ -11,86 +11,60 @@ import { EuiToolTip } from '@elastic/eui';
// don't use something like plugins/ml/../common
// because it won't work with the jest tests
import { getMLJobTypeAriaLabel } from '../../util/field_types_utils';
import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types';
import { FormattedMessage, injectI18n } from '@kbn/i18n/react';
import { i18n } from '@kbn/i18n';
export const FieldTypeIcon = injectI18n(function FieldTypeIcon({ tooltipEnabled = false, type, intl }) {
let ariaLabel = '';
let iconClass = '';
export const FieldTypeIcon = ({ tooltipEnabled = false, type, needsAria = true }) => {
const ariaLabel = getMLJobTypeAriaLabel(type);
if (ariaLabel === null) {
// All ml job field types should have associated aria labels.
// Once it is missing, it means that the passed *type* is not a valid field type.
// if type doesn't match one of ML_JOB_FIELD_TYPES
// don't render the component at all
return null;
}
const iconClass = ['field-type-icon'];
let iconChar = '';
switch (type) {
// icon class names
case ML_JOB_FIELD_TYPES.BOOLEAN:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.booleanTypeAriaLabel',
defaultMessage: 'boolean type'
});
iconClass = 'fa-adjust';
iconClass.push('kuiIcon', 'fa-adjust');
break;
case ML_JOB_FIELD_TYPES.DATE:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.dateTypeAriaLabel',
defaultMessage: 'date type'
});
iconClass = 'fa-clock-o';
break;
case ML_JOB_FIELD_TYPES.NUMBER:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.numberTypeAriaLabel',
defaultMessage: 'number type'
});
iconChar = '#';
iconClass.push('kuiIcon', 'fa-clock-o');
break;
case ML_JOB_FIELD_TYPES.GEO_POINT:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.geoPointTypeAriaLabel',
defaultMessage: '{geoPointParam} type'
}, { geoPointParam: 'geo_point' });
iconClass = 'fa-globe';
break;
case ML_JOB_FIELD_TYPES.KEYWORD:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.keywordTypeAriaLabel',
defaultMessage: 'keyword type'
});
iconChar = 't';
iconClass.push('kuiIcon', 'fa-globe');
break;
case ML_JOB_FIELD_TYPES.TEXT:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.textTypeAriaLabel',
defaultMessage: 'text type'
});
iconClass = 'fa-file-text-o';
iconClass.push('kuiIcon', 'fa-file-text-o');
break;
case ML_JOB_FIELD_TYPES.IP:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.ipTypeAriaLabel',
defaultMessage: 'IP type'
});
iconClass = 'fa-laptop';
iconClass.push('kuiIcon', 'fa-laptop');
break;
// icon chars
case ML_JOB_FIELD_TYPES.KEYWORD:
iconChar = 't';
break;
case ML_JOB_FIELD_TYPES.NUMBER:
iconChar = '#';
break;
case ML_JOB_FIELD_TYPES.UNKNOWN:
ariaLabel = intl.formatMessage({
id: 'xpack.ml.fieldTypeIcon.unknownTypeAriaLabel',
defaultMessage: 'Unknown type'
});
iconChar = '?';
break;
default:
// if type doesn't match one of ML_JOB_FIELD_TYPES
// don't render the component at all
return null;
}
let className = 'field-type-icon';
if (iconClass !== '') {
className += ' kuiIcon ' + iconClass;
}
const containerProps = {
ariaLabel,
className,
iconChar
className: iconClass.join(' '),
iconChar,
needsAria
};
if (tooltipEnabled === true) {
@ -100,11 +74,10 @@ export const FieldTypeIcon = injectI18n(function FieldTypeIcon({ tooltipEnabled
return (
<EuiToolTip
position="left"
content={<FormattedMessage
id="xpack.ml.fieldTypeIcon.fieldTypeTooltip"
defaultMessage="{type} type"
values={{ type }}
/>}
content={i18n.translate('xpack.ml.fieldTypeIcon.fieldTypeTooltip', {
defaultMessage: '{type} type',
values: { type }
})}
>
<FieldTypeIconContainer {...containerProps} />
</EuiToolTip>
@ -112,21 +85,34 @@ export const FieldTypeIcon = injectI18n(function FieldTypeIcon({ tooltipEnabled
}
return <FieldTypeIconContainer {...containerProps} />;
});
FieldTypeIcon.WrappedComponent.propTypes = {
};
FieldTypeIcon.propTypes = {
tooltipEnabled: PropTypes.bool,
type: PropTypes.string
};
// If the tooltip is used, it will apply its events to its first inner child.
// To pass on its properties we apply `rest` to the outer `span` element.
function FieldTypeIconContainer({ ariaLabel, className, iconChar, ...rest }) {
function FieldTypeIconContainer({
ariaLabel,
className,
iconChar,
needsAria,
...rest
}) {
const wrapperProps = { className };
if (needsAria && ariaLabel) {
wrapperProps['aria-label'] = ariaLabel;
}
return (
<span className="field-type-icon-container" {...rest} tabIndex="0">
<span className="field-type-icon-container" {...rest}>
{(iconChar === '') ? (
<span aria-label={ariaLabel} className={className} />
<span {...wrapperProps} />
) : (
<span aria-label={ariaLabel} className={className}>
<span {...wrapperProps}>
<strong aria-hidden="true">{iconChar}</strong>
</span>
)}

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import React from 'react';
import { mount, shallow } from 'enzyme';
import { FieldTypeIcon } from './field_type_icon';
import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types';
@ -13,38 +13,38 @@ import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types';
describe('FieldTypeIcon', () => {
test(`don't render component when type is undefined`, () => {
const wrapper = shallowWithIntl(<FieldTypeIcon.WrappedComponent />);
expect(wrapper.isEmptyRender()).toBeTruthy();
const typeIconComponent = shallow(<FieldTypeIcon />);
expect(typeIconComponent.isEmptyRender()).toBeTruthy();
});
test(`don't render component when type doesn't match a field type`, () => {
const wrapper = shallowWithIntl(<FieldTypeIcon.WrappedComponent type="foo" />);
expect(wrapper.isEmptyRender()).toBeTruthy();
const typeIconComponent = shallow(<FieldTypeIcon type="foo" />);
expect(typeIconComponent.isEmptyRender()).toBeTruthy();
});
test(`render component when type matches a field type`, () => {
const wrapper = shallowWithIntl(<FieldTypeIcon.WrappedComponent type={ML_JOB_FIELD_TYPES.KEYWORD} />);
expect(wrapper).toMatchSnapshot();
const typeIconComponent = shallow(<FieldTypeIcon type={ML_JOB_FIELD_TYPES.KEYWORD} />);
expect(typeIconComponent).toMatchSnapshot();
});
test(`render with tooltip and test hovering`, () => {
const wrapper = mountWithIntl(<FieldTypeIcon.WrappedComponent type={ML_JOB_FIELD_TYPES.KEYWORD} tooltipEnabled={true} />);
const container = wrapper.find({ className: 'field-type-icon-container' });
const typeIconComponent = mount(<FieldTypeIcon type={ML_JOB_FIELD_TYPES.KEYWORD} tooltipEnabled={true} />);
const container = typeIconComponent.find({ className: 'field-type-icon-container' });
expect(wrapper.find('EuiToolTip').children()).toHaveLength(1);
expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1);
container.simulate('mouseover');
expect(wrapper.find('EuiToolTip').children()).toHaveLength(2);
expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(2);
container.simulate('mouseout');
expect(wrapper.find('EuiToolTip').children()).toHaveLength(1);
expect(typeIconComponent.find('EuiToolTip').children()).toHaveLength(1);
});
test(`update component`, () => {
const wrapper = shallowWithIntl(<FieldTypeIcon.WrappedComponent />);
expect(wrapper.isEmptyRender()).toBeTruthy();
wrapper.setProps({ type: ML_JOB_FIELD_TYPES.IP });
expect(wrapper).toMatchSnapshot();
const typeIconComponent = shallow(<FieldTypeIcon />);
expect(typeIconComponent.isEmptyRender()).toBeTruthy();
typeIconComponent.setProps({ type: ML_JOB_FIELD_TYPES.IP });
expect(typeIconComponent).toMatchSnapshot();
});
});

View file

@ -4,15 +4,14 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import {
EuiSpacer,
EuiSpacer
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { FieldTypeIcon } from '../../../components/field_type_icon';
import { getMLJobTypeAriaLabel } from '../../../util/field_types_utils';
export function FieldStatsCard({ field }) {
@ -21,6 +20,12 @@ export function FieldStatsCard({ field }) {
type = 'number';
}
const typeAriaLabel = getMLJobTypeAriaLabel(type);
const cardTitleAriaLabel = [field.name];
if (typeAriaLabel) {
cardTitleAriaLabel.unshift(typeAriaLabel);
}
return (
<React.Fragment>
<div className="card-container">
@ -28,8 +33,14 @@ export function FieldStatsCard({ field }) {
<div
className={`ml-field-title-bar ${type}`}
>
<FieldTypeIcon type={type} />
<div className="field-name">{field.name}</div>
<FieldTypeIcon type={type} needsAria={false} />
<div
className="field-name"
tabIndex="0"
aria-label={`${cardTitleAriaLabel.join(', ')}`}
>
{field.name}
</div>
</div>
<div className="card-contents">

View file

@ -4,11 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { ML_JOB_FIELD_TYPES, KBN_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
import { kbnTypeToMLJobType } from 'plugins/ml/util/field_types_utils';
import { KBN_FIELD_TYPES, ML_JOB_FIELD_TYPES } from './../../../common/constants/field_types';
import {
kbnTypeToMLJobType,
getMLJobTypeAriaLabel,
mlJobTypeAriaLabels
} from './../field_types_utils';
describe('ML - field type utils', () => {
@ -61,4 +63,22 @@ describe('ML - field type utils', () => {
});
describe('getMLJobTypeAriaLabel: Getting a field type aria label by passing what it is stored in constants', () => {
it('should returns all ML_JOB_FIELD_TYPES labels exactly as it is for each correct value', () => {
const mlKeys = Object.keys(ML_JOB_FIELD_TYPES);
const receivedMlLabels = {};
const testStorage = mlJobTypeAriaLabels;
mlKeys.forEach(constant => {
receivedMlLabels[constant] = getMLJobTypeAriaLabel(ML_JOB_FIELD_TYPES[constant]);
});
expect(receivedMlLabels).to.eql(testStorage);
});
it('should returns NULL as ML_JOB_FIELD_TYPES does not contain such a keyword', () => {
expect(
getMLJobTypeAriaLabel('ML_JOB_FIELD_TYPES', 'asd')
).to.be.null;
});
});
});

View file

@ -4,9 +4,11 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { ML_JOB_FIELD_TYPES, KBN_FIELD_TYPES } from 'plugins/ml/../common/constants/field_types';
import { i18n } from '@kbn/i18n';
import {
KBN_FIELD_TYPES,
ML_JOB_FIELD_TYPES,
} from './../../common/constants/field_types';
// convert kibana types to ML Job types
// this is needed because kibana types only have string and not text and keyword.
@ -39,3 +41,38 @@ export function kbnTypeToMLJobType(field) {
return type;
}
export const mlJobTypeAriaLabels = {
BOOLEAN: i18n.translate('xpack.ml.fieldTypeIcon.booleanTypeAriaLabel', {
defaultMessage: 'boolean type',
}),
DATE: i18n.translate('xpack.ml.fieldTypeIcon.dateTypeAriaLabel', {
defaultMessage: 'date type',
}),
GEO_POINT: i18n.translate('xpack.ml.fieldTypeIcon.geoPointTypeAriaLabel', {
defaultMessage: '{geoPointParam} type',
values: {
geoPointParam: 'geo point'
}
}),
IP: i18n.translate('xpack.ml.fieldTypeIcon.ipTypeAriaLabel', {
defaultMessage: 'ip type',
}),
KEYWORD: i18n.translate('xpack.ml.fieldTypeIcon.keywordTypeAriaLabel', {
defaultMessage: 'keyword type',
}),
NUMBER: i18n.translate('xpack.ml.fieldTypeIcon.numberTypeAriaLabel', {
defaultMessage: 'number type',
}),
TEXT: i18n.translate('xpack.ml.fieldTypeIcon.textTypeAriaLabel', {
defaultMessage: 'text type',
}),
UNKNOWN: i18n.translate('xpack.ml.fieldTypeIcon.unknownTypeAriaLabel', {
defaultMessage: 'unknown type',
}),
};
export const getMLJobTypeAriaLabel = (type) => {
const requestedFieldType = Object.keys(ML_JOB_FIELD_TYPES).find(k => (ML_JOB_FIELD_TYPES[k] === type));
return mlJobTypeAriaLabels[requestedFieldType] || null;
};