Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(multi-select): add onListScrollBottom #7044

Merged
merged 2 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
79 changes: 79 additions & 0 deletions src/components/select/multi-select/components.test-pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,85 @@ export const MultiSelectLazyLoadTwiceComponent = (
);
};

export const MultiSelectWithInfiniteScrollComponent = (
props: Partial<MultiSelectProps>,
) => {
const preventLoading = useRef(false);
const preventLazyLoading = useRef(false);
const lazyLoadingCounter = useRef(0);
const [value, setValue] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const asyncList = [
<Option text="Amber" value="amber" key="Amber" />,
<Option text="Black" value="black" key="Black" />,
<Option text="Blue" value="blue" key="Blue" />,
<Option text="Brown" value="brown" key="Brown" />,
<Option text="Green" value="green" key="Green" />,
];
const getLazyLoaded = () => {
const counter = lazyLoadingCounter.current;
return [
<Option
text={`Lazy Loaded A${counter}`}
value={`lazyA${counter}`}
key={`lazyA${counter}`}
/>,
<Option
text={`Lazy Loaded B${counter}`}
value={`lazyB${counter}`}
key={`lazyB${counter}`}
/>,
<Option
text={`Lazy Loaded C${counter}`}
value={`lazyC${counter}`}
key={`lazyC${counter}`}
/>,
];
};
const [optionList, setOptionList] = useState<React.ReactElement[]>([]);
function onChangeHandler(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.target.value as unknown as string[]);
}

function loadList() {
if (preventLoading.current) {
return;
}
preventLoading.current = true;
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setOptionList(asyncList);
}, 2000);
}
function onLazyLoading() {
if (preventLazyLoading.current) {
return;
}
preventLazyLoading.current = true;
setIsLoading(true);
setTimeout(() => {
preventLazyLoading.current = false;
lazyLoadingCounter.current += 1;
setIsLoading(false);
setOptionList((prevList) => [...prevList, ...getLazyLoaded()]);
}, 2000);
}
return (
<MultiSelect
label="color"
value={value}
onChange={onChangeHandler}
onOpen={() => loadList()}
isLoading={isLoading}
onListScrollBottom={onLazyLoading}
{...props}
>
{optionList}
</MultiSelect>
);
};

export const MultiSelectObjectAsValueComponent = (
props: Partial<MultiSelectProps>,
) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default {
"onChange",
"onChangeDeferred",
"onFilterChange",
"onListScrollBottom",
"onOpen",
"onBlur",
"onClick",
Expand Down Expand Up @@ -300,6 +301,85 @@ export const MultiSelectLazyLoadTwiceComponent = (
);
};

export const MultiSelectWithInfiniteScrollComponent = (
props: Partial<MultiSelectProps>,
) => {
const preventLoading = useRef(false);
const preventLazyLoading = useRef(false);
const lazyLoadingCounter = useRef(0);
const [value, setValue] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const asyncList = [
<Option text="Amber" value="amber" key="Amber" />,
<Option text="Black" value="black" key="Black" />,
<Option text="Blue" value="blue" key="Blue" />,
<Option text="Brown" value="brown" key="Brown" />,
<Option text="Green" value="green" key="Green" />,
];
const getLazyLoaded = () => {
const counter = lazyLoadingCounter.current;
return [
<Option
text={`Lazy Loaded A${counter}`}
value={`lazyA${counter}`}
key={`lazyA${counter}`}
/>,
<Option
text={`Lazy Loaded B${counter}`}
value={`lazyB${counter}`}
key={`lazyB${counter}`}
/>,
<Option
text={`Lazy Loaded C${counter}`}
value={`lazyC${counter}`}
key={`lazyC${counter}`}
/>,
];
};
const [optionList, setOptionList] = useState<React.ReactElement[]>([]);
function onChangeHandler(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.target.value as unknown as string[]);
}

function loadList() {
if (preventLoading.current) {
return;
}
preventLoading.current = true;
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
setOptionList(asyncList);
}, 2000);
}
function onLazyLoading() {
if (preventLazyLoading.current) {
return;
}
preventLazyLoading.current = true;
setIsLoading(true);
setTimeout(() => {
preventLazyLoading.current = false;
lazyLoadingCounter.current += 1;
setIsLoading(false);
setOptionList((prevList) => [...prevList, ...getLazyLoaded()]);
}, 2000);
}
return (
<MultiSelect
label="color"
value={value}
onChange={onChangeHandler}
onOpen={() => loadList()}
isLoading={isLoading}
onListScrollBottom={onLazyLoading}
{...props}
>
{optionList}
</MultiSelect>
);
};

export const MultiSelectObjectAsValueComponent = (
props: Partial<MultiSelectProps>,
) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface MultiSelectProps
onFilterChange?: (filterText: string) => void;
/** A custom callback for when the dropdown menu opens */
onOpen?: () => void;
/** A callback that is triggered when a user scrolls to the bottom of the list */
onListScrollBottom?: () => void;
/** If true the Component opens on focus */
openOnFocus?: boolean;
/** SelectList table header, should consist of multiple th elements.
Expand Down Expand Up @@ -124,6 +126,7 @@ export const MultiSelect = React.forwardRef<HTMLInputElement, MultiSelectProps>(
noResultsMessage,
placeholder,
isLoading,
onListScrollBottom,
tableHeader,
multiColumn,
tooltipPosition,
Expand Down Expand Up @@ -693,6 +696,7 @@ export const MultiSelect = React.forwardRef<HTMLInputElement, MultiSelectProps>(
highlightedValue={highlightedValue}
noResultsMessage={noResultsMessage}
isLoading={isLoading}
onListScrollBottom={onListScrollBottom}
tableHeader={tableHeader}
multiColumn={multiColumn}
listPlacement={listWidth !== undefined ? placement : listPlacement}
Expand Down
7 changes: 7 additions & 0 deletions src/components/select/multi-select/multi-select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ See [Colors](../?path=/docs/documentation-colors--docs) for more information on

<Canvas of={MultiSelectStories.WithCustomColoredPills} />

### Infinite scroll example

The `isLoading` prop in combination with the `onListScrollBottom` prop can be used to implement infinite scroll.
This prop will be called every time a user scrolls to the bottom of the list.

<Canvas of={MultiSelectStories.WithInfiniteScroll} />

### With custom maxWidth

In this example the `maxWidth` prop is 50%.
Expand Down
122 changes: 122 additions & 0 deletions src/components/select/multi-select/multi-select.pw.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MultiSelectDefaultValueComponent,
MultiSelectMaxOptionsComponent,
MultiSelectWithLazyLoadingComponent,
MultiSelectWithInfiniteScrollComponent,
MultiSelectLazyLoadTwiceComponent,
MultiSelectObjectAsValueComponent,
MultiSelectMultiColumnsComponent,
Expand Down Expand Up @@ -675,6 +676,58 @@ test.describe("MultiSelect component", () => {
);
});

test("should render a lazy loaded option when the infinite scroll prop is set", async ({
mount,
page,
}) => {
await mount(<MultiSelectWithInfiniteScrollComponent />);

const option = "Lazy Loaded A1";
const selectListWrapperElement = selectListWrapper(page);
await dropdownButton(page).click();
await expect(selectListWrapperElement).toBeVisible();
await Promise.all(
[0, 1, 2].map((i) => expect(loader(page, i)).toBeVisible()),
);
await expect(selectOptionByText(page, option)).toHaveCount(0);
await page.waitForTimeout(2000);
await selectListScrollableWrapper(page).evaluate((wrapper) => {
wrapper.scrollBy(0, 500);
});
await page.waitForTimeout(250);
await Promise.all(
[0, 1, 2].map((i) => expect(loader(page, i)).not.toBeVisible()),
);
await expect(await selectOptionByText(page, option)).toBeVisible();
});

test("the list should not change scroll position when the lazy-loaded options appear", async ({
mount,
page,
}) => {
await mount(<MultiSelectWithInfiniteScrollComponent />);

// open the select list and choose an option
const inputElement = commonDataElementInputPreview(page);
await inputElement.focus();
await inputElement.press("ArrowDown");
const firstOption = selectOptionByText(page, "Amber");
await firstOption.waitFor();
await firstOption.click();

const scrollableWrapper = selectListScrollableWrapper(page);
await scrollableWrapper.evaluate((wrapper) => wrapper.scrollBy(0, 500));
const scrollPositionBeforeLoad = await scrollableWrapper.evaluate(
(element) => element.scrollTop,
);

await selectOptionByText(page, "Lazy Loaded A1").waitFor();
const scrollPositionAfterLoad = await scrollableWrapper.evaluate(
(element) => element.scrollTop,
);
await expect(scrollPositionAfterLoad).toBe(scrollPositionBeforeLoad);
});

test("should list options when value is set and select list is opened again", async ({
mount,
page,
Expand Down Expand Up @@ -1260,6 +1313,59 @@ test.describe("Check events for MultiSelect component", () => {
await expect(callbackArguments.length).toBe(1);
await expect(callbackArguments[0]).toBe(text);
});

test("should call onListScrollBottom event when the list is scrolled to the bottom", async ({
mount,
page,
}) => {
let callbackCount = 0;
const callback = () => {
callbackCount += 1;
};
await mount(<MultiSelectComponent onListScrollBottom={callback} />);

await dropdownButton(page).click();
await selectListScrollableWrapper(page).evaluate((wrapper) =>
wrapper.scrollBy(0, 500),
);
await page.waitForTimeout(250);
await expect(callbackCount).toBe(1);
});

test("should not call onListScrollBottom callback when an option is clicked", async ({
mount,
page,
}) => {
let callbackCount = 0;
const callback = () => {
callbackCount += 1;
};
await mount(<MultiSelectComponent onListScrollBottom={callback} />);

await dropdownButton(page).click();
await selectOption(page, positionOfElement("first")).click();
expect(callbackCount).toBe(0);
});

test("should not be called when an option is clicked and list is re-opened", async ({
mount,
page,
}) => {
let callbackCount = 0;
const callback = () => {
callbackCount += 1;
};

await mount(<MultiSelectComponent onListScrollBottom={callback} />);

await dropdownButton(page).click();
await selectListScrollableWrapper(page).evaluate((wrapper) =>
wrapper.scrollBy(0, 500),
);
await selectOption(page, positionOfElement("first")).click();
await dropdownButton(page).click();
expect(callbackCount).toBe(1);
});
});

test.describe("Check virtual scrolling", () => {
Expand Down Expand Up @@ -1789,6 +1895,22 @@ test.describe("Accessibility tests for MultiSelect component", () => {
await checkAccessibility(page);
});

test("should pass accessibility tests with onListScrollBottom prop", async ({
mount,
page,
}) => {
await mount(<MultiSelectWithInfiniteScrollComponent />);

await dropdownButton(page).click();
await checkAccessibility(page);
// wait for content to finish loading before scrolling
await expect(selectOptionByText(page, "Amber")).toBeVisible();
await selectListScrollableWrapper(page).evaluate((wrapper) =>
wrapper.scrollBy(0, 500),
);
await checkAccessibility(page, undefined, "scrollable-region-focusable");
});

test("should pass accessibility tests with openOnFocus prop", async ({
mount,
page,
Expand Down
Loading
Loading