Skip to content

Commit 02e70f6

Browse files
authored
Merge pull request #6213 from limaagabriel/LPD-55597
feat(@clayui/autocomplete): LPD-55597 Improve infinite scrolling accessibility with SR announcements and visual feedback
2 parents e84e332 + f558fd6 commit 02e70f6

File tree

8 files changed

+430
-143
lines changed

8 files changed

+430
-143
lines changed

packages/clay-autocomplete/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
],
3232
"dependencies": {
3333
"@clayui/core": "^3.155.0",
34+
"@clayui/data-provider": "^3.151.0",
3435
"@clayui/drop-down": "^3.155.0",
3536
"@clayui/form": "^3.151.0",
3637
"@clayui/loading-indicator": "^3.144.1",

packages/clay-autocomplete/src/Autocomplete.tsx

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2929

3030
import {AutocompleteContext} from './Context';
3131
import Item from './Item';
32+
import {useInfiniteScroll} from './useInfiniteScroll';
3233

3334
import type {AnnouncerAPI, ICollectionProps} from '@clayui/core';
3435
import type {Locator} from '@clayui/shared';
@@ -45,6 +46,18 @@ type ItemProps<T extends Item> = {
4546
keyValue: React.Key;
4647
};
4748

49+
export type AutocompleteMessages = {
50+
infiniteScrollInitialLoad?: string;
51+
infiniteScrollInitialLoadPlural?: string;
52+
infiniteScrollOnLoad?: string;
53+
infiniteScrollOnLoaded?: string;
54+
infiniteScrollOnLoadedPlural?: string;
55+
listCount?: string;
56+
listCountPlural?: string;
57+
loading: string;
58+
notFound: string;
59+
};
60+
4861
export interface IProps<T>
4962
extends Omit<
5063
React.HTMLAttributes<HTMLInputElement>,
@@ -120,12 +133,7 @@ export interface IProps<T>
120133
/**
121134
* Messages for the Autocomplete.
122135
*/
123-
messages?: {
124-
listCount?: string;
125-
listCountPlural?: string;
126-
loading: string;
127-
notFound: string;
128-
};
136+
messages?: AutocompleteMessages;
129137

130138
/**
131139
* Callback for when the active state changes (controlled).
@@ -203,7 +211,14 @@ function hasItem<T extends Item>(
203211

204212
const ESCAPE_REGEXP = /[.*+?^${}()|[\]\\]/g;
205213

206-
const defaultMessages = {
214+
const defaultMessages: Required<AutocompleteMessages> = {
215+
infiniteScrollInitialLoad:
216+
'{0} item loaded. Reach the last item to load more.',
217+
infiniteScrollInitialLoadPlural:
218+
'{0} items loaded. Reach the last item to load more.',
219+
infiniteScrollOnLoad: 'Loading more items.',
220+
infiniteScrollOnLoaded: '{0} item loaded.',
221+
infiniteScrollOnLoadedPlural: '{0} items loaded.',
207222
listCount: '{0} option available.',
208223
listCountPlural: '{0} options available.',
209224
loading: 'Loading...',
@@ -227,7 +242,7 @@ function AutocompleteInner<T extends Item>(
227242
items: externalItems,
228243
loadingState,
229244
menuTrigger = 'input',
230-
messages,
245+
messages: externalMessages,
231246
onActiveChange,
232247
onChange,
233248
onItemsChange,
@@ -239,9 +254,9 @@ function AutocompleteInner<T extends Item>(
239254
}: IProps<T>,
240255
ref: React.Ref<HTMLInputElement>
241256
) {
242-
messages = {
257+
const messages = {
243258
...defaultMessages,
244-
...(messages ?? {}),
259+
...(externalMessages ?? {}),
245260
};
246261

247262
const [items, , isItemsUncontrolled] = useControlledState({
@@ -513,6 +528,15 @@ function AutocompleteInner<T extends Item>(
513528
const optionCount = collection.getItems().length;
514529
const lastSize = useRef(optionCount);
515530

531+
const InfiniteScrollFeedback = useInfiniteScroll({
532+
active,
533+
announcer: announcerAPI,
534+
collection,
535+
loadingState,
536+
messages,
537+
onLoadMore,
538+
});
539+
516540
useEffect(() => {
517541
// Only announces the number of options available when the menu is open
518542
// if there is no item with focus, with the exception of Voice Over
@@ -736,6 +760,8 @@ function AutocompleteInner<T extends Item>(
736760
)}
737761
</Collection>
738762
</AutocompleteContext.Provider>
763+
764+
<InfiniteScrollFeedback />
739765
</div>
740766
</Overlay>
741767
)}

packages/clay-autocomplete/src/__tests__/IncrementalInteractions.tsx

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
*/
55

66
import ClayAutocomplete from '..';
7-
import {cleanup, fireEvent, render} from '@testing-library/react';
7+
import {NetworkStatus} from '@clayui/data-provider';
8+
import {cleanup, fireEvent, render, waitFor} from '@testing-library/react';
89
import userEvent from '@testing-library/user-event';
910
import React from 'react';
1011

@@ -753,4 +754,107 @@ describe('Autocomplete incremental interactions', () => {
753754
expect(onClickMock).toHaveBeenCalled();
754755
});
755756
});
757+
758+
describe('Infinite scroll interactions', () => {
759+
it('calls onLoadMore when last item is active using keyboard', async () => {
760+
const onLoadMore = jest.fn();
761+
762+
const {getByRole} = render(
763+
<ClayAutocomplete onLoadMore={onLoadMore}>
764+
{Array(20)
765+
.fill(0)
766+
.map((_, index) => (
767+
<ClayAutocomplete.Item
768+
key={index}
769+
textValue={`Item ${index + 1}`}
770+
>
771+
Item {index + 1}
772+
</ClayAutocomplete.Item>
773+
))}
774+
</ClayAutocomplete>
775+
);
776+
777+
const combobox = getByRole('combobox');
778+
779+
userEvent.click(combobox);
780+
781+
expect(onLoadMore).not.toHaveBeenCalled();
782+
783+
// Navigates to the last item
784+
userEvent.type(combobox, '{arrowup}');
785+
786+
await waitFor(() => {
787+
expect(onLoadMore).toHaveBeenCalled();
788+
});
789+
});
790+
791+
it('announces count of initially loaded items and when more items are loaded', async () => {
792+
const initialCount = 10;
793+
const step = 5;
794+
795+
const TestComponent = () => {
796+
const [count, setCount] = React.useState(initialCount);
797+
const [networkStatus, setNetworkStatus] =
798+
React.useState<NetworkStatus>(NetworkStatus.Unused);
799+
800+
const onLoadMore = jest.fn().mockImplementation(() => {
801+
return new Promise<void>((resolve) => {
802+
setNetworkStatus(NetworkStatus.Loading);
803+
804+
setTimeout(() => {
805+
setCount((count) => count + step);
806+
setNetworkStatus(NetworkStatus.Unused);
807+
resolve();
808+
}, 100);
809+
});
810+
});
811+
812+
return (
813+
<ClayAutocomplete
814+
loadingState={networkStatus}
815+
onLoadMore={onLoadMore}
816+
>
817+
{Array(count)
818+
.fill(0)
819+
.map((_, index) => (
820+
<ClayAutocomplete.Item
821+
key={index}
822+
textValue={`Item ${index + 1}`}
823+
>
824+
Item {index + 1}
825+
</ClayAutocomplete.Item>
826+
))}
827+
</ClayAutocomplete>
828+
);
829+
};
830+
831+
const {getAllByRole, getByRole} = render(<TestComponent />);
832+
833+
const combobox = getByRole('combobox');
834+
835+
userEvent.click(combobox);
836+
837+
// Display the listbox
838+
userEvent.type(combobox, '{arrowdown}');
839+
840+
const [announcer] = getAllByRole('log');
841+
842+
await waitFor(() => {
843+
expect(announcer?.innerHTML).toContain(
844+
`${initialCount} items loaded. Reach the last item to load more.`
845+
);
846+
});
847+
848+
// Navigates to the last item
849+
userEvent.type(combobox, '{arrowup}');
850+
851+
await waitFor(() => {
852+
expect(announcer?.innerHTML).toContain(`Loading more items.`);
853+
854+
expect(announcer?.innerHTML).toContain(
855+
`${initialCount + step} items loaded.`
856+
);
857+
});
858+
});
859+
});
756860
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* SPDX-FileCopyrightText: © 2025 Liferay, Inc. <https://liferay.com>
3+
* SPDX-License-Identifier: BSD-3-Clause
4+
*/
5+
6+
import {AnnouncerAPI} from '@clayui/core';
7+
import {CollectionState} from '@clayui/core/src/collection/types';
8+
import {NetworkStatus} from '@clayui/data-provider';
9+
import LoadingIndicator from '@clayui/loading-indicator';
10+
import {sub} from '@clayui/shared';
11+
import React, {RefObject, useCallback, useEffect, useRef} from 'react';
12+
13+
import {AutocompleteMessages} from './Autocomplete';
14+
15+
interface IProps {
16+
/**
17+
* Reference to the announcer API.
18+
*/
19+
announcer: RefObject<AnnouncerAPI>;
20+
21+
/**
22+
* Flag to indicate if the autocomplete is active (i.e., the menu is open)
23+
*/
24+
active: boolean;
25+
26+
/**
27+
* The collection state of the autocomplete items.
28+
*/
29+
collection: CollectionState;
30+
31+
/**
32+
* Loading state of the autocomplete.
33+
*/
34+
loadingState?: number;
35+
36+
/**
37+
* Localized messages for the autocomplete.
38+
*/
39+
messages: Required<AutocompleteMessages>;
40+
41+
/**
42+
* Callback function to load more items.
43+
*/
44+
onLoadMore?: () => Promise<any> | null;
45+
}
46+
47+
export function useInfiniteScroll({
48+
active,
49+
announcer,
50+
collection,
51+
loadingState = NetworkStatus.Unused,
52+
messages,
53+
onLoadMore,
54+
}: IProps) {
55+
const currentCount = collection.getSize();
56+
const isInfiniteScrollEnabled = Boolean(onLoadMore);
57+
const isLoading = Boolean(loadingState < NetworkStatus.Unused);
58+
59+
const isInitialLoadAnnouncementPending = useRef<boolean>(
60+
isInfiniteScrollEnabled
61+
);
62+
const lastCountAnnounced = useRef<number | null>(null);
63+
64+
useEffect(() => {
65+
if (active) {
66+
if (isLoading) {
67+
announcer.current?.announce(messages.infiniteScrollOnLoad);
68+
lastCountAnnounced.current = null;
69+
} else if (lastCountAnnounced.current !== currentCount) {
70+
const singular = isInitialLoadAnnouncementPending.current
71+
? messages.infiniteScrollInitialLoad
72+
: messages.infiniteScrollOnLoaded;
73+
74+
const plural = isInitialLoadAnnouncementPending.current
75+
? messages.infiniteScrollInitialLoadPlural
76+
: messages.infiniteScrollOnLoadedPlural;
77+
78+
const message = sub(currentCount === 1 ? singular : plural, [
79+
currentCount,
80+
]);
81+
82+
announcer.current?.announce(message);
83+
lastCountAnnounced.current = currentCount;
84+
isInitialLoadAnnouncementPending.current = false;
85+
}
86+
} else {
87+
isInitialLoadAnnouncementPending.current = isInfiniteScrollEnabled;
88+
}
89+
}, [active, isLoading, currentCount]);
90+
91+
const InfiniteScrollFeedback = useCallback(
92+
() =>
93+
isInfiniteScrollEnabled && isLoading ? (
94+
<LoadingIndicator className="my-2" size="sm" />
95+
) : null,
96+
[isInfiniteScrollEnabled, isLoading]
97+
);
98+
99+
return InfiniteScrollFeedback;
100+
}

0 commit comments

Comments
 (0)