Skip to content

Commit

Permalink
feat(multi-select): add onListScrollBottom
Browse files Browse the repository at this point in the history
adds the onListScrollBottom callback, to be called when a user scrolls to the bottom of the select
list

fix #6752
  • Loading branch information
tomdavies73 committed Dec 5, 2024
1 parent 6675b86 commit daeb37a
Show file tree
Hide file tree
Showing 7 changed files with 401 additions and 0 deletions.
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
80 changes: 80 additions & 0 deletions src/components/select/multi-select/multi-select-test.stories.tsx
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
4 changes: 4 additions & 0 deletions src/components/select/multi-select/multi-select.component.tsx
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

0 comments on commit daeb37a

Please sign in to comment.