[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:
parent
ca685f01fc
commit
2cc2312f6d
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue