Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
421b5d4
chore(storybook): LPD-55597 Add infinite scrolling examples
limaagabriel Nov 27, 2025
58b4e47
feat(@clayui/autocomplete): LPD-55597 Add infinite scrolling SR annou…
limaagabriel Nov 27, 2025
3a5bd11
feat(@clayui/autocomplete): LPD-55597 Add infinite scrolling trigger
limaagabriel Nov 27, 2025
70fbbaf
feat(@clayui/autocomplete): LPD-55597 Allow infinite scrolling to aut…
limaagabriel Nov 28, 2025
73bbecf
feat(@clayui/autocomplete): LPD-55597 Only announce when infinite scr…
limaagabriel Nov 28, 2025
871edc1
feat(@clayui/autocomplete): LPD-55597 Change infinite scrolling so it…
limaagabriel Nov 28, 2025
2ae4a85
docs(@clayui/autocomplete): LPD-55597 Add docstrings to the added props
limaagabriel Nov 28, 2025
3ef8d49
chore(@clayui/autocomplete): LPD-55597 Rename variables for better cl…
limaagabriel Nov 28, 2025
6a45768
chore(@clayui/autocomplete): LPD-55597 Fix type check and remove trig…
limaagabriel Nov 28, 2025
aa1be84
feat(@clayui/autocomplete): LPD-55597 Improve referential stability
limaagabriel Dec 1, 2025
1c0d8d3
chore(@clayui/autocomplete): LPD-55597 Add infinite scrolling tests
limaagabriel Dec 2, 2025
3c9aae4
feat(@clayui/autocomplete): LPD-55597 Avoid announcing initial load m…
limaagabriel Dec 2, 2025
015669c
chore(@clayui/autocomplete): LPD-55597 Simplify onLoad message and re…
limaagabriel Dec 4, 2025
b10b0d0
chore(@clayui/autocomplete): LPD-55597 Remove batchLoadCount prop
limaagabriel Dec 4, 2025
07163e1
feat(@clayui/autocomplete): LPD-55597 Remove unwanted auto scrolling
limaagabriel Dec 5, 2025
35e3854
chore(@clayui/autocomplete): LPD-55597 Simplify announcement-related …
limaagabriel Dec 5, 2025
697c1de
feat(@clayui/autocomplete): LPD-55597 Remove custom infinite scroll t…
limaagabriel Dec 5, 2025
5e49a25
feat(@clayui/autocomplete): LPD-55597 Rollback to use Collection inif…
limaagabriel Dec 5, 2025
c118a44
chore(@clayui/autocomplete): LPD-55597 Simplify announcement-related …
limaagabriel Dec 5, 2025
ebd0dda
chore(@clayui/autocomplete): LPD-55597 Sort variable definitions
limaagabriel Dec 5, 2025
394a85f
chore(@clayui/autocomplete): Fix case where navigating to the last it…
limaagabriel Dec 5, 2025
ffedef7
feat(@clayui/autocomplete): LPD-55597 Remove code to load more while …
limaagabriel Dec 5, 2025
9b5be97
chore(@clayui/autocomplete): LPD-55597 Replace usage of magic number …
limaagabriel Dec 5, 2025
cc51f2c
feat(@clayui/autocomplete): LPD-55597 Enable infinite scroll for case…
limaagabriel Dec 5, 2025
9c38154
chore(@clayui/autocomplete): LPD-55597 Add comments to the tests to i…
limaagabriel Dec 5, 2025
f558fd6
chore(@clayui/autocomplete): LPD-55597 Add data-provider package to t…
limaagabriel Dec 5, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/clay-autocomplete/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
],
"dependencies": {
"@clayui/core": "^3.155.0",
"@clayui/data-provider": "^3.151.0",
"@clayui/drop-down": "^3.155.0",
"@clayui/form": "^3.151.0",
"@clayui/loading-indicator": "^3.144.1",
Expand Down
46 changes: 36 additions & 10 deletions packages/clay-autocomplete/src/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';

import {AutocompleteContext} from './Context';
import Item from './Item';
import {useInfiniteScroll} from './useInfiniteScroll';

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

export type AutocompleteMessages = {
infiniteScrollInitialLoad?: string;
infiniteScrollInitialLoadPlural?: string;
infiniteScrollOnLoad?: string;
infiniteScrollOnLoaded?: string;
infiniteScrollOnLoadedPlural?: string;
listCount?: string;
listCountPlural?: string;
loading: string;
notFound: string;
};

export interface IProps<T>
extends Omit<
React.HTMLAttributes<HTMLInputElement>,
Expand Down Expand Up @@ -120,12 +133,7 @@ export interface IProps<T>
/**
* Messages for the Autocomplete.
*/
messages?: {
listCount?: string;
listCountPlural?: string;
loading: string;
notFound: string;
};
messages?: AutocompleteMessages;

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

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

const defaultMessages = {
const defaultMessages: Required<AutocompleteMessages> = {
infiniteScrollInitialLoad:
'{0} item loaded. Reach the last item to load more.',
infiniteScrollInitialLoadPlural:
'{0} items loaded. Reach the last item to load more.',
infiniteScrollOnLoad: 'Loading more items.',
infiniteScrollOnLoaded: '{0} item loaded.',
infiniteScrollOnLoadedPlural: '{0} items loaded.',
listCount: '{0} option available.',
listCountPlural: '{0} options available.',
loading: 'Loading...',
Expand All @@ -227,7 +242,7 @@ function AutocompleteInner<T extends Item>(
items: externalItems,
loadingState,
menuTrigger = 'input',
messages,
messages: externalMessages,
onActiveChange,
onChange,
onItemsChange,
Expand All @@ -239,9 +254,9 @@ function AutocompleteInner<T extends Item>(
}: IProps<T>,
ref: React.Ref<HTMLInputElement>
) {
messages = {
const messages = {
...defaultMessages,
...(messages ?? {}),
...(externalMessages ?? {}),
};

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

const InfiniteScrollFeedback = useInfiniteScroll({
active,
announcer: announcerAPI,
collection,
loadingState,
messages,
onLoadMore,
});

useEffect(() => {
// Only announces the number of options available when the menu is open
// if there is no item with focus, with the exception of Voice Over
Expand Down Expand Up @@ -736,6 +760,8 @@ function AutocompleteInner<T extends Item>(
)}
</Collection>
</AutocompleteContext.Provider>

<InfiniteScrollFeedback />
</div>
</Overlay>
)}
Expand Down
106 changes: 105 additions & 1 deletion packages/clay-autocomplete/src/__tests__/IncrementalInteractions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
*/

import ClayAutocomplete from '..';
import {cleanup, fireEvent, render} from '@testing-library/react';
import {NetworkStatus} from '@clayui/data-provider';
import {cleanup, fireEvent, render, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

Expand Down Expand Up @@ -753,4 +754,107 @@ describe('Autocomplete incremental interactions', () => {
expect(onClickMock).toHaveBeenCalled();
});
});

describe('Infinite scroll interactions', () => {
it('calls onLoadMore when last item is active using keyboard', async () => {
const onLoadMore = jest.fn();

const {getByRole} = render(
<ClayAutocomplete onLoadMore={onLoadMore}>
{Array(20)
.fill(0)
.map((_, index) => (
<ClayAutocomplete.Item
key={index}
textValue={`Item ${index + 1}`}
>
Item {index + 1}
</ClayAutocomplete.Item>
))}
</ClayAutocomplete>
);

const combobox = getByRole('combobox');

userEvent.click(combobox);

expect(onLoadMore).not.toHaveBeenCalled();

// Navigates to the last item
userEvent.type(combobox, '{arrowup}');

await waitFor(() => {
expect(onLoadMore).toHaveBeenCalled();
});
});

it('announces count of initially loaded items and when more items are loaded', async () => {
const initialCount = 10;
const step = 5;

const TestComponent = () => {
const [count, setCount] = React.useState(initialCount);
const [networkStatus, setNetworkStatus] =
React.useState<NetworkStatus>(NetworkStatus.Unused);

const onLoadMore = jest.fn().mockImplementation(() => {
return new Promise<void>((resolve) => {
setNetworkStatus(NetworkStatus.Loading);

setTimeout(() => {
setCount((count) => count + step);
setNetworkStatus(NetworkStatus.Unused);
resolve();
}, 100);
});
});

return (
<ClayAutocomplete
loadingState={networkStatus}
onLoadMore={onLoadMore}
>
{Array(count)
.fill(0)
.map((_, index) => (
<ClayAutocomplete.Item
key={index}
textValue={`Item ${index + 1}`}
>
Item {index + 1}
</ClayAutocomplete.Item>
))}
</ClayAutocomplete>
);
};

const {getAllByRole, getByRole} = render(<TestComponent />);

const combobox = getByRole('combobox');

userEvent.click(combobox);

// Display the listbox
userEvent.type(combobox, '{arrowdown}');

const [announcer] = getAllByRole('log');

await waitFor(() => {
expect(announcer?.innerHTML).toContain(
`${initialCount} items loaded. Reach the last item to load more.`
);
});

// Navigates to the last item
userEvent.type(combobox, '{arrowup}');

await waitFor(() => {
expect(announcer?.innerHTML).toContain(`Loading more items.`);

expect(announcer?.innerHTML).toContain(
`${initialCount + step} items loaded.`
);
});
});
});
});
100 changes: 100 additions & 0 deletions packages/clay-autocomplete/src/useInfiniteScroll.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* SPDX-FileCopyrightText: © 2025 Liferay, Inc. <https://liferay.com>
* SPDX-License-Identifier: BSD-3-Clause
*/

import {AnnouncerAPI} from '@clayui/core';
import {CollectionState} from '@clayui/core/src/collection/types';
import {NetworkStatus} from '@clayui/data-provider';
import LoadingIndicator from '@clayui/loading-indicator';
import {sub} from '@clayui/shared';
import React, {RefObject, useCallback, useEffect, useRef} from 'react';

import {AutocompleteMessages} from './Autocomplete';

interface IProps {
/**
* Reference to the announcer API.
*/
announcer: RefObject<AnnouncerAPI>;

/**
* Flag to indicate if the autocomplete is active (i.e., the menu is open)
*/
active: boolean;

/**
* The collection state of the autocomplete items.
*/
collection: CollectionState;

/**
* Loading state of the autocomplete.
*/
loadingState?: number;

/**
* Localized messages for the autocomplete.
*/
messages: Required<AutocompleteMessages>;

/**
* Callback function to load more items.
*/
onLoadMore?: () => Promise<any> | null;
}

export function useInfiniteScroll({
active,
announcer,
collection,
loadingState = NetworkStatus.Unused,
messages,
onLoadMore,
}: IProps) {
const currentCount = collection.getSize();
const isInfiniteScrollEnabled = Boolean(onLoadMore);
const isLoading = Boolean(loadingState < NetworkStatus.Unused);

const isInitialLoadAnnouncementPending = useRef<boolean>(
isInfiniteScrollEnabled
);
const lastCountAnnounced = useRef<number | null>(null);

useEffect(() => {
if (active) {
if (isLoading) {
announcer.current?.announce(messages.infiniteScrollOnLoad);
lastCountAnnounced.current = null;
} else if (lastCountAnnounced.current !== currentCount) {
const singular = isInitialLoadAnnouncementPending.current
? messages.infiniteScrollInitialLoad
: messages.infiniteScrollOnLoaded;

const plural = isInitialLoadAnnouncementPending.current
? messages.infiniteScrollInitialLoadPlural
: messages.infiniteScrollOnLoadedPlural;

const message = sub(currentCount === 1 ? singular : plural, [
currentCount,
]);

announcer.current?.announce(message);
lastCountAnnounced.current = currentCount;
isInitialLoadAnnouncementPending.current = false;
}
} else {
isInitialLoadAnnouncementPending.current = isInfiniteScrollEnabled;
}
}, [active, isLoading, currentCount]);

const InfiniteScrollFeedback = useCallback(
() =>
isInfiniteScrollEnabled && isLoading ? (
<LoadingIndicator className="my-2" size="sm" />
) : null,
[isInfiniteScrollEnabled, isLoading]
);

return InfiniteScrollFeedback;
}
Loading
Loading