mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[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:
parent
700a5f2455
commit
539905511a
7 changed files with 173 additions and 104 deletions
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
`;
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue