Skip to content

Commit f4a84dd

Browse files
i-runetsIvan Runets
andauthored
fix: empty status message in Picker is not announced by screen readers (#3071)
Co-authored-by: Ivan Runets <ivan_runets@epam.com>
1 parent 7e6e97b commit f4a84dd

11 files changed

Lines changed: 162 additions & 16 deletions

File tree

changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818

1919
**What's Fixed**
20+
* [DataPickerBody]: empty search results are announced via a **polite** off-screen `role="status"` region [#1506](https://github.com/epam/UUI/issues/1506) Case №9.
2021
* [VirtualList]: fixed loading `Blocker` not fully covering the visible area.
2122
* [FiltersPanel]: fixed filters being centered instead of left-aligned inside dropdown popups ([#3065](https://github.com/epam/UUI/issues/3065))
2223
* [DatePicker]: fixed value disappearing on blur when using formats with day name (e.g. `dddd, D MMMM YYYY`) ([#2560](https://github.com/epam/UUI/issues/2560))

test-utils/src/testObjects/Picker.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ export class PickerTestObject {
117117
}
118118

119119
static querySpinner(props: { editMode?: string } = {}) {
120-
const dialog = within(this.getDialog(props.editMode));
121-
return dialog.queryByRole('status', { busy: false }).querySelector('.uui-blocker');
120+
const dialog = this.getDialog(props.editMode);
121+
// VirtualList Blocker uses role="status"; DataPickerBody adds a separate empty-state announcer with the same role.
122+
return dialog.querySelector<HTMLElement>('.uui-blocker-container .uui-blocker')!;
122123
}
123124

124125
public static async waitForLoadingComplete(editMode?: string) {

uui/assets/styles/helpers.scss

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
11
@mixin overflow-ellipsis() {
22
overflow: hidden;
33
text-overflow: ellipsis;
4+
}
5+
6+
@mixin screen-reader-only() {
7+
position: absolute;
8+
width: 1px;
9+
height: 1px;
10+
padding: 0;
11+
margin: -1px;
12+
overflow: hidden;
13+
clip: rect(0, 0, 0, 0);
14+
white-space: nowrap;
15+
border: 0;
416
}

uui/components/pickers/DataPickerBody.module.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,7 @@
3232
padding-top: var(--uui-data_picker_body-no-data-vertical-padding);
3333
padding-bottom: var(--uui-data_picker_body-no-data-vertical-padding);
3434
}
35+
36+
.empty-status-announcer {
37+
@include screen-reader-only();
38+
}

uui/components/pickers/DataPickerBody.tsx

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ export function DataPickerBody<TItem, TId>({ highlightSearchMatches = true, auto
4343
const prevProps = usePrevious(props);
4444
const showSearch = props.showSearch === 'auto' ? props.totalCount > 10 : Boolean(props.showSearch);
4545
const [isKeyboardNavigation, setIsKeyboardNavigation] = React.useState(false);
46+
const [emptyMessage, setEmptyMessage] = React.useState('');
47+
48+
// We need to also ensure that topIndex === 0, because we can have state were there is no rows but topIndex > 0, in case when we scrolled lover than we have rows
49+
// we fix this state on next render and shouldn't show empty state.
50+
const isEmptyList = props.rows.length === 0 && props.value.topIndex === 0;
51+
52+
useEffect(() => {
53+
if (!isEmptyList) {
54+
setEmptyMessage('');
55+
}
56+
}, [isEmptyList]);
4657

4758
useEffect(() => {
4859
if (props.rows.length !== prevProps?.rows.length || (!isEqual(prevProps?.value.checked, props.value.checked) && !props.fixedBodyPosition)) {
@@ -191,6 +202,14 @@ export function DataPickerBody<TItem, TId>({ highlightSearchMatches = true, auto
191202
const renderedDataRows = useMemo(() => props.rows.map((row) => renderRow(row, props.value)), [props.rows, props.value, isKeyboardNavigation]);
192203
return (
193204
<>
205+
<div
206+
className={ css.emptyStatusAnnouncer }
207+
role="status"
208+
aria-live="polite"
209+
aria-atomic="true"
210+
>
211+
{emptyMessage}
212+
</div>
194213
{showSearch && (
195214
<div key="search" className={ cx(css.searchWrapper, 'uui-picker_input-body-search') }>
196215
<FlexCell grow={ 1 }>
@@ -203,10 +222,12 @@ export function DataPickerBody<TItem, TId>({ highlightSearchMatches = true, auto
203222
cx={ cx('uui-picker_input-body') }
204223
rawProps={ { style: { maxHeight: props.maxHeight, maxWidth: props.maxWidth }, tabIndex: -1 } }
205224
>
206-
{ props.rows.length === 0 && props.value.topIndex === 0
207-
// We need to also ensure that topIndex === 0, because we can have state were there is no rows but topIndex > 0, in case when we scrolled lover than we have rows
208-
// we fix this state on next render and shouldn't show empty state.
209-
? renderEmpty() : (
225+
{ isEmptyList
226+
? (
227+
<div ref={ (el) => el && setEmptyMessage(el.textContent ?? '') }>
228+
{renderEmpty()}
229+
</div>
230+
) : (
210231
<VirtualList
211232
value={ props.value }
212233
onValueChange={ props.onValueChange }

uui/components/pickers/__tests__/DataPickerBody.test.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import React from 'react';
2-
import { renderHook, renderSnapshotWithContextAsync } from '@epam/uui-test-utils';
2+
import { renderHook, renderSnapshotWithContextAsync, renderWithContextAsync, waitFor, within } from '@epam/uui-test-utils';
33
import { ArrayDataSource } from '@epam/uui-core';
44
import { DataPickerBody, DataPickerBodyProps } from '../DataPickerBody';
5+
import { i18n } from '../../../i18n';
56

67
type LanguageLevel = { id: number; level: string };
78
const languageLevels = [
@@ -51,4 +52,58 @@ describe('DataPickerBody', () => {
5152
);
5253
expect(tree).toMatchSnapshot();
5354
});
55+
56+
it('should update announcer div when empty list shows default no-records message', async () => {
57+
const { container } = await renderWithContextAsync(
58+
<DataPickerBody
59+
{ ...requiredProps }
60+
rows={ [] }
61+
showSearch
62+
rowsCount={ 0 }
63+
value={ { topIndex: 0, search: 'no-match' } }
64+
/>,
65+
);
66+
67+
await waitFor(() => {
68+
expect(within(container).getByRole('status')).toHaveTextContent(i18n.dataPickerBody.noRecordsMessage);
69+
});
70+
});
71+
72+
it('should update announcer div to match custom renderEmpty text', async () => {
73+
const { container } = await renderWithContextAsync(
74+
<DataPickerBody
75+
{ ...requiredProps }
76+
rows={ [] }
77+
renderEmpty={ () => <div>Custom empty message</div> }
78+
/>,
79+
);
80+
81+
await waitFor(() => {
82+
expect(within(container).getByRole('status')).toHaveTextContent('Custom empty message');
83+
});
84+
});
85+
86+
it('should clear announcer when list has rows', async () => {
87+
const { container, rerender } = await renderWithContextAsync(
88+
<DataPickerBody
89+
{ ...requiredProps }
90+
rows={ [] }
91+
renderEmpty={ () => <div>Was empty</div> }
92+
/>,
93+
);
94+
95+
const emptyStatusAnnouncer = within(container).getByRole('status');
96+
97+
await waitFor(() => {
98+
expect(emptyStatusAnnouncer).toHaveTextContent('Was empty');
99+
});
100+
101+
rerender(
102+
<DataPickerBody { ...requiredProps } rows={ rows } />,
103+
);
104+
105+
await waitFor(() => {
106+
expect(emptyStatusAnnouncer).toHaveTextContent('');
107+
});
108+
});
54109
});

uui/components/pickers/__tests__/PickerInput.test.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,8 +230,8 @@ describe('PickerInput', () => {
230230
const dialog = await screen.findByRole('dialog');
231231
expect(dialog).toBeInTheDocument();
232232

233-
const dialogBody = dialog.firstElementChild?.firstElementChild;
234-
expect(dialogBody).toHaveStyle('max-height: 100px');
233+
const pickerBody = dialog.querySelector('.uui-picker_input-body');
234+
expect(pickerBody).toHaveStyle('max-height: 100px');
235235
});
236236

237237
it('should render custom not found', async () => {
@@ -307,15 +307,15 @@ describe('PickerInput', () => {
307307
expect(dialog).toBeInTheDocument();
308308

309309
await waitFor(async () => {
310-
const notFound = within(await screen.findByRole('dialog')).getByTestId(customTextForNotEnoughCharsInSearchId);
310+
const notFound = within(dialog).getByTestId(customTextForNotEnoughCharsInSearchId);
311311
expect(notFound).toHaveTextContent(customTextForNotEnoughCharsInSearch);
312312
});
313313

314314
const bodyInput = within(dialog).getByPlaceholderText('Search');
315315
fireEvent.change(bodyInput, { target: { value: 'A11' } });
316316

317317
await waitFor(async () => {
318-
const notFound = within(await screen.findByRole('dialog')).getByTestId(customTextForNotFoundId);
318+
const notFound = within(dialog).getByTestId(customTextForNotFoundId);
319319
expect(notFound).toHaveTextContent(customTextForNotFound);
320320
});
321321
});
@@ -610,8 +610,8 @@ describe('PickerInput', () => {
610610
expect(dialog).toBeInTheDocument();
611611

612612
await waitFor(async () => {
613-
const notFound = within(await screen.findByRole('dialog'));
614-
expect(notFound.getByText('Type search to load items')).toBeInTheDocument();
613+
const body = dialog.querySelector<HTMLElement>('.uui-picker_input-body')!;
614+
expect(within(body).getByText('Type search to load items')).toBeInTheDocument();
615615
});
616616

617617
expect(apiMock).toBeCalledTimes(0);
@@ -626,8 +626,8 @@ describe('PickerInput', () => {
626626
jest.useRealTimers();
627627

628628
await waitFor(async () => {
629-
const notFound = within(await screen.findByRole('dialog'));
630-
expect(notFound.getByText('No records found')).toBeInTheDocument();
629+
const body = dialog.querySelector<HTMLElement>('.uui-picker_input-body')!;
630+
expect(within(body).getByText('No records found')).toBeInTheDocument();
631631
});
632632

633633
expect(apiMock).toBeCalledTimes(1);

uui/components/pickers/__tests__/__snapshots__/DataPickerBody.test.tsx.snap

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
exports[`DataPickerBody should be rendered with maximum props 1`] = `
44
<DocumentFragment>
5+
<div
6+
aria-atomic="true"
7+
aria-live="polite"
8+
class="emptyStatusAnnouncer"
9+
role="status"
10+
/>
511
<div
612
class="searchWrapper uui-picker_input-body-search"
713
>
@@ -559,6 +565,12 @@ exports[`DataPickerBody should be rendered with maximum props 1`] = `
559565

560566
exports[`DataPickerBody should be rendered with minimum props 1`] = `
561567
<DocumentFragment>
568+
<div
569+
aria-atomic="true"
570+
aria-live="polite"
571+
class="emptyStatusAnnouncer"
572+
role="status"
573+
/>
562574
<div
563575
class="root uui-flex-row uui-picker_input-body uui-size-36"
564576
tabindex="-1"
@@ -1078,12 +1090,22 @@ exports[`DataPickerBody should be rendered with minimum props 1`] = `
10781090

10791091
exports[`DataPickerBody should be rendered without rows 1`] = `
10801092
<DocumentFragment>
1093+
<div
1094+
aria-atomic="true"
1095+
aria-live="polite"
1096+
class="emptyStatusAnnouncer"
1097+
role="status"
1098+
>
1099+
Not found
1100+
</div>
10811101
<div
10821102
class="root uui-flex-row uui-picker_input-body uui-size-36"
10831103
tabindex="-1"
10841104
>
10851105
<div>
1086-
Not found
1106+
<div>
1107+
Not found
1108+
</div>
10871109
</div>
10881110
</div>
10891111
</DocumentFragment>

uui/components/pickers/__tests__/__snapshots__/PickerInput.test.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ exports[`PickerInput Body Open/Close should open body 1`] = `
5252
style="min-width: 360px; max-width: 360px;"
5353
tabindex="-1"
5454
>
55+
<div
56+
aria-atomic="true"
57+
aria-live="polite"
58+
class="emptyStatusAnnouncer"
59+
role="status"
60+
/>
5561
<div
5662
class="root uui-flex-row uui-picker_input-body uui-size-36"
5763
style="max-height: 300px;"

uui/components/pickers/__tests__/__snapshots__/PickerList.test.tsx.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ exports[`PickerList should open body 1`] = `
225225
</div>
226226
</div>
227227
</div>
228+
<div
229+
aria-atomic="true"
230+
aria-live="polite"
231+
class="emptyStatusAnnouncer"
232+
role="status"
233+
/>
228234
<div
229235
class="root uui-flex-row uui-picker_input-body uui-size-36"
230236
tabindex="-1"

0 commit comments

Comments
 (0)