[App Search] Result component - a11y enhancements (#86841)

* Refactor Result card layout

- Move toggle action to the bottom of the card content
- [TODO] Action button to the right will be used for new link button (separate for accessibility/screen readers)
- Use grid to get the layout we want without extra div wrappers

* Add action button link to document detail

+ remove <a> tag on article content - should have onClick only
- this allows screenreaders to granularly navigate through the card content while allowing mouse users the entire card to click
- the new actionButton details link is accessible to both keyboard & screen reader users

* [Polish] Hover effects to help guide mouse users

* [i18n] Add pluralization to fields copy

* Update tests

* [Cleanup] Remove unneeded wrapper

* [??] More specific title for result group

- since the aria-label for the new detail button link is basically that
This commit is contained in:
Constance 2020-12-23 10:39:21 -08:00 committed by GitHub
parent ca685f01fc
commit 2cc2312f6d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 142 additions and 81 deletions

View file

@ -1,17 +1,43 @@
.appSearchResult {
display: flex;
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr auto;
grid-template-areas:
'content actions'
'toggle actions';
overflow: hidden; // Prevents child background-colors from clipping outside of panel border-radius
&__content {
grid-area: content;
width: 100%;
padding: $euiSize;
overflow: hidden;
color: $euiTextColor;
}
&__hiddenFieldsIndicator {
&__hiddenFieldsToggle {
grid-area: toggle;
display: flex;
justify-content: center;
padding: $euiSizeS;
border-top: $euiBorderThin;
font-size: $euiFontSizeXS;
color: $euiColorDarkShade;
margin-top: $euiSizeS;
color: $euiColorPrimary;
&:hover,
&:focus {
background-color: $euiPageBackgroundColor;
}
.euiIcon {
margin-left: $euiSizeXS;
}
}
&__actionButtons {
grid-area: actions;
display: flex;
flex-wrap: no-wrap;
}
&__actionButton {
@ -22,10 +48,27 @@
border-left: $euiBorderThin;
&:hover,
&:focus,
&:active {
&:focus {
background-color: $euiPageBackgroundColor;
cursor: pointer;
}
}
}
/**
* CSS for hover specific logic
* It's mildly horrific, so I pulled it out to its own section here
*/
.appSearchResult--link {
&:hover,
&:focus {
@include euiSlightShadowHover;
}
}
.appSearchResult__content--link:hover {
cursor: pointer;
& ~ .appSearchResult__actionButtons .appSearchResult__actionButton--link {
background-color: $euiPageBackgroundColor;
}
}

View file

@ -49,6 +49,7 @@ describe('Result', () => {
it('renders', () => {
const wrapper = shallow(<Result {...props} />);
expect(wrapper.find(EuiPanel).exists()).toBe(true);
expect(wrapper.find(EuiPanel).prop('title')).toEqual('Document 1');
});
it('should render a ResultField for each field except id and _meta', () => {
@ -76,16 +77,20 @@ describe('Result', () => {
describe('document detail link', () => {
it('will render a link if shouldLinkToDetailPage is true', () => {
const wrapper = shallow(<Result {...props} shouldLinkToDetailPage={true} />);
expect(wrapper.find(ReactRouterHelper).prop('to')).toEqual('/engines/my-engine/documents/1');
expect(wrapper.find('article.appSearchResult__content').exists()).toBe(false);
expect(wrapper.find('a.appSearchResult__content').exists()).toBe(true);
wrapper.find(ReactRouterHelper).forEach((link) => {
expect(link.prop('to')).toEqual('/engines/my-engine/documents/1');
});
expect(wrapper.hasClass('appSearchResult--link')).toBe(true);
expect(wrapper.find('.appSearchResult__content--link').exists()).toBe(true);
expect(wrapper.find('.appSearchResult__actionButton--link').exists()).toBe(true);
});
it('will not render a link if shouldLinkToDetailPage is not set', () => {
const wrapper = shallow(<Result {...props} />);
expect(wrapper.find(ReactRouterHelper).exists()).toBe(false);
expect(wrapper.find('article.appSearchResult__content').exists()).toBe(true);
expect(wrapper.find('a.appSearchResult__content').exists()).toBe(false);
expect(wrapper.hasClass('appSearchResult--link')).toBe(false);
expect(wrapper.find('.appSearchResult__content--link').exists()).toBe(false);
expect(wrapper.find('.appSearchResult__actionButton--link').exists()).toBe(false);
});
});
@ -140,20 +145,18 @@ describe('Result', () => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
});
it('renders a collapse button', () => {
it('renders a hidden fields toggle button', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsToggle').exists()).toBe(true);
});
it('renders a collapse icon', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false);
});
it('does not render an expand button', () => {
it('does not render an expand icon', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true);
});
it('renders a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual(
'1 more fields'
);
});
it('shows no more than 5 fields', () => {
expect(wrapper.find(ResultField).length).toEqual(5);
});
@ -164,22 +167,24 @@ describe('Result', () => {
beforeAll(() => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true);
wrapper.find('.appSearchResult__actionButton').simulate('click');
expect(wrapper.find('.appSearchResult__hiddenFieldsToggle').exists()).toBe(true);
wrapper.find('.appSearchResult__hiddenFieldsToggle').simulate('click');
});
it('renders a collapse button', () => {
it('renders correct toggle text', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsToggle').text()).toEqual(
'Hide additional fields<EuiIcon />'
);
});
it('renders a collapse icon', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(true);
});
it('does not render an expand button', () => {
it('does not render an expand icon', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(false);
});
it('does not render a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').exists()).toBe(false);
});
it('shows all fields', () => {
expect(wrapper.find(ResultField).length).toEqual(6);
});
@ -190,25 +195,25 @@ describe('Result', () => {
beforeAll(() => {
wrapper = shallow(<Result {...propsWithMoreFields} />);
expect(wrapper.find('.appSearchResult__actionButton').exists()).toBe(true);
wrapper.find('.appSearchResult__actionButton').simulate('click');
wrapper.find('.appSearchResult__actionButton').simulate('click');
expect(wrapper.find('.appSearchResult__hiddenFieldsToggle').exists()).toBe(true);
wrapper.find('.appSearchResult__hiddenFieldsToggle').simulate('click');
wrapper.find('.appSearchResult__hiddenFieldsToggle').simulate('click');
});
it('renders a collapse button', () => {
it('renders correct toggle text', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsToggle').text()).toEqual(
'Show 1 additional field<EuiIcon />'
);
});
it('renders a collapse icon', () => {
expect(wrapper.find('[data-test-subj="CollapseResult"]').exists()).toBe(false);
});
it('does not render an expand button', () => {
it('does not render an expand icon', () => {
expect(wrapper.find('[data-test-subj="ExpandResult"]').exists()).toBe(true);
});
it('renders a hidden fields indicator', () => {
expect(wrapper.find('.appSearchResult__hiddenFieldsIndicator').text()).toEqual(
'1 more fields'
);
});
it('shows no more than 5 fields', () => {
expect(wrapper.find(ResultField).length).toEqual(5);
});

View file

@ -5,6 +5,7 @@
*/
import React, { useState, useMemo } from 'react';
import classNames from 'classnames';
import './result.scss';
@ -49,23 +50,31 @@ export const Result: React.FC<Props> = ({
if (schemaForTypeHighlights) return schemaForTypeHighlights[fieldName];
};
const documentLink = getDocumentDetailRoute(resultMeta.engine, resultMeta.id);
const conditionallyLinkedArticle = (children: React.ReactNode) => {
return shouldLinkToDetailPage ? (
<ReactRouterHelper to={getDocumentDetailRoute(resultMeta.engine, resultMeta.id)}>
<a className="appSearchResult__content">{children}</a>
<ReactRouterHelper to={documentLink}>
<article className="appSearchResult__content appSearchResult__content--link">
{children}
</article>
</ReactRouterHelper>
) : (
<article className="appSearchResult__content">{children}</article>
);
};
const classes = classNames('appSearchResult', {
'appSearchResult--link': shouldLinkToDetailPage,
});
return (
<EuiPanel
paddingSize="none"
className="appSearchResult"
className={classes}
data-test-subj="AppSearchResult"
title={i18n.translate('xpack.enterpriseSearch.appSearch.result.title', {
defaultMessage: 'View document details',
defaultMessage: 'Document {id}',
values: { id: result[ID].raw },
})}
>
{conditionallyLinkedArticle(
@ -75,53 +84,57 @@ export const Result: React.FC<Props> = ({
showScore={!!showScore}
isMetaEngine={isMetaEngine}
/>
<div className="appSearchResult__body">
{resultFields
.slice(0, isOpen ? resultFields.length : RESULT_CUTOFF)
.map(([field, value]: [string, FieldValue]) => (
<ResultField
key={field}
field={field}
raw={value.raw}
snippet={value.snippet}
type={typeForField(field)}
/>
))}
</div>
{numResults > RESULT_CUTOFF && !isOpen && (
<footer className="appSearchResult__hiddenFieldsIndicator">
{i18n.translate('xpack.enterpriseSearch.appSearch.result.numberOfAdditionalFields', {
defaultMessage: '{numberOfAdditionalFields} more fields',
values: {
numberOfAdditionalFields: numResults - RESULT_CUTOFF,
},
})}
</footer>
)}
{resultFields
.slice(0, isOpen ? resultFields.length : RESULT_CUTOFF)
.map(([field, value]: [string, FieldValue]) => (
<ResultField
key={field}
field={field}
raw={value.raw}
snippet={value.snippet}
type={typeForField(field)}
/>
))}
</>
)}
{numResults > RESULT_CUTOFF && (
<button
type="button"
className="appSearchResult__actionButton"
className="appSearchResult__hiddenFieldsToggle"
onClick={() => setIsOpen(!isOpen)}
aria-label={
isOpen
? i18n.translate('xpack.enterpriseSearch.appSearch.result.hideAdditionalFields', {
defaultMessage: 'Hide additional fields',
})
: i18n.translate('xpack.enterpriseSearch.appSearch.result.showAdditionalFields', {
defaultMessage: 'Show additional fields',
})
}
>
{isOpen ? (
<EuiIcon data-test-subj="CollapseResult" type="arrowUp" />
) : (
<EuiIcon data-test-subj="ExpandResult" type="arrowDown" />
)}
{isOpen
? i18n.translate('xpack.enterpriseSearch.appSearch.result.hideAdditionalFields', {
defaultMessage: 'Hide additional fields',
})
: i18n.translate('xpack.enterpriseSearch.appSearch.result.showAdditionalFields', {
defaultMessage:
'Show {numberOfAdditionalFields, number} additional {numberOfAdditionalFields, plural, one {field} other {fields}}',
values: {
numberOfAdditionalFields: numResults - RESULT_CUTOFF,
},
})}
<EuiIcon
type={isOpen ? 'arrowUp' : 'arrowDown'}
data-test-subj={isOpen ? 'CollapseResult' : 'ExpandResult'}
/>
</button>
)}
<div className="appSearchResult__actionButtons">
{shouldLinkToDetailPage && (
<ReactRouterHelper to={documentLink}>
<a
className="appSearchResult__actionButton appSearchResult__actionButton--link"
aria-label={i18n.translate(
'xpack.enterpriseSearch.appSearch.result.documentDetailLink',
{ defaultMessage: 'Visit document details' }
)}
>
<EuiIcon type="popout" />
</a>
</ReactRouterHelper>
)}
</div>
</EuiPanel>
);
};