Feat/14419 added infinite scroll icon picker (#14792)

## 📝 Summary
Added **infinite scroll support** in the **IconPicker** dropdown by
extending `DropdownMenuItemsContainer` with an optional `onScroll`
handler.

Fixes #14419  

---

## 🔄 Changes
- Updated `DropdownMenuItemsContainer` to accept an optional `onScroll`
prop.
- Implemented lazy-loading of icons in `IconPicker` (loads 50 more icons
when the user scrolls).
- **Note**: Infinite scroll is **disabled** if a `maxIconsVisible` prop
is passed — in that case the list is static.
- No new UI elements were added

---
##  How it Works
- Scroll event is captured via `onScroll`.
- When the user scrolls near the bottom:
  ```ts
  target.scrollTop + target.clientHeight >= target.scrollHeight - 10

---

## Demo ->

[Screencast from 2025-09-30
20-47-03.webm](https://github.com/user-attachments/assets/d8906ebd-3cf1-4a89-a10d-a049b098c94f)



Attached screen recording of infinite scroll in action.

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Saurav Jain
2025-10-07 20:50:51 +05:30
committed by GitHub
parent 9aaed28dcf
commit 9f9c2d85ca
3 changed files with 157 additions and 42 deletions
@@ -14,8 +14,10 @@ import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useRecoilValue, useResetRecoilState } from 'recoil';
import { IconApps, type IconComponent, useIcons } from 'twenty-ui/display';
import {
IconButton,
@@ -23,6 +25,8 @@ import {
type IconButtonVariant,
LightIconButton,
} from 'twenty-ui/input';
import { IconPickerScrollEffect } from '../hooks/IconPickerScrollEffect';
import { iconPickerVisibleCountState } from '../states/iconPickerVisibleCountState';
export type IconPickerProps = {
disabled?: boolean;
@@ -60,6 +64,14 @@ const StyledLightIconButton = styled(LightIconButton)<{
: 'transparent'};
`;
const StyledLoadingMore = styled.div`
display: flex;
justify-content: center;
align-items: center;
height: 40px;
font-size: 14px;
`;
const StyledMatrixItem = styled.div`
width: 32px;
height: 32px;
@@ -124,7 +136,7 @@ export const IconPicker = ({
clickableComponent,
dropdownWidth,
dropdownOffset,
maxIconsVisible = 25,
maxIconsVisible,
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
@@ -144,8 +156,38 @@ export const IconPicker = ({
const { closeDropdown } = useCloseDropdown();
const iconPickerVisibleCount =
useRecoilValue(iconPickerVisibleCountState(dropdownId)) ?? maxIconsVisible;
const resetIconPickerVisibleCount = useResetRecoilState(
iconPickerVisibleCountState(dropdownId),
);
const { getIcons, getIcon } = useIcons();
const icons = getIcons();
const totalMatchingIconsCount = useMemo(() => {
if (!icons) return 0;
return Object.keys(icons).filter((iconKey) => {
const iconLabel = convertIconKeyToLabel(iconKey)
.toLowerCase()
.replace('icon ', '')
.replace(/\s/g, '');
const searchLower = searchString.toLowerCase().trim().replace(/\s/g, '');
return (
iconKey === searchLower ||
iconLabel === searchLower ||
iconKey.startsWith(searchLower) ||
iconLabel.startsWith(searchLower) ||
iconKey.includes(searchLower) ||
iconLabel.includes(searchLower)
);
}).length;
}, [icons, searchString]);
const matchingSearchIconKeys = useMemo(() => {
if (icons == null) return [];
const scoreIconMatch = (iconKey: string, searchString: string) => {
@@ -186,9 +228,9 @@ export const IconPicker = ({
...filteredAndSortedIconKeys.filter(
(iconKey) => iconKey !== selectedIconKey,
),
].slice(0, maxIconsVisible)
: filteredAndSortedIconKeys.slice(0, maxIconsVisible);
}, [icons, searchString, selectedIconKey, maxIconsVisible]);
].slice(0, iconPickerVisibleCount)
: filteredAndSortedIconKeys.slice(0, iconPickerVisibleCount);
}, [icons, searchString, selectedIconKey, iconPickerVisibleCount]);
const iconKeys2d = useMemo(
() => arrayToChunks(matchingSearchIconKeys.slice(), 5),
@@ -205,6 +247,10 @@ export const IconPicker = ({
selectableListInstanceId,
) ?? undefined;
const isLoadingMore =
iconPickerVisibleCount !== undefined &&
iconPickerVisibleCount < totalMatchingIconsCount;
return (
<div className={className}>
<Dropdown
@@ -226,51 +272,63 @@ export const IconPicker = ({
)
}
dropdownComponents={
<DropdownContent
widthInPixels={dropdownWidth || ICON_PICKER_DROPDOWN_CONTENT_WIDTH}
>
<SelectableList
selectableListInstanceId={selectableListInstanceId}
selectableItemIdMatrix={iconKeys2d}
focusId={dropdownId}
<ScrollWrapper componentInstanceId="icon-picker-scroll">
<DropdownContent
widthInPixels={
dropdownWidth || ICON_PICKER_DROPDOWN_CONTENT_WIDTH
}
>
<DropdownMenuSearchInput
placeholder={t`Search icon`}
autoFocus
onChange={(event) => {
setSearchString(event.target.value);
}}
/>
<DropdownMenuSeparator />
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
<SelectableList
selectableListInstanceId={selectableListInstanceId}
selectableItemIdMatrix={iconKeys2d}
focusId={dropdownId}
>
<DropdownMenuItemsContainer>
<StyledMenuIconItemsContainer>
{matchingSearchIconKeys.map((iconKey) => (
<IconPickerIcon
key={iconKey}
iconKey={iconKey}
onClick={() => {
onChange({ iconKey, Icon: getIcon(iconKey) });
closeDropdown(dropdownId);
}}
selectedIconKey={selectedIconKey}
Icon={getIcon(iconKey)}
focusedIconKey={focusedIconKey}
/>
))}
</StyledMenuIconItemsContainer>
</DropdownMenuItemsContainer>
</div>
</SelectableList>
</DropdownContent>
<DropdownMenuSearchInput
placeholder={t`Search icon`}
autoFocus
onChange={(event) => {
setSearchString(event.target.value);
}}
/>
<DropdownMenuSeparator />
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<DropdownMenuItemsContainer hasMaxHeight>
<StyledMenuIconItemsContainer>
{matchingSearchIconKeys.map((iconKey) => (
<IconPickerIcon
key={iconKey}
iconKey={iconKey}
onClick={() => {
onChange({ iconKey, Icon: getIcon(iconKey) });
closeDropdown(dropdownId);
}}
selectedIconKey={selectedIconKey}
Icon={getIcon(iconKey)}
focusedIconKey={focusedIconKey}
/>
))}
</StyledMenuIconItemsContainer>
<IconPickerScrollEffect
sentinelId="icon-picker-scroll-sentinel"
dropdownId={dropdownId}
/>
<StyledLoadingMore id="icon-picker-scroll-sentinel">
{isLoadingMore ? t`Loading more...` : null}
</StyledLoadingMore>
</DropdownMenuItemsContainer>
</div>
</SelectableList>
</DropdownContent>
</ScrollWrapper>
}
onClickOutside={onClickOutside}
onClose={() => {
onClose?.();
setSearchString('');
resetIconPickerVisibleCount();
}}
onOpen={onOpen}
/>
@@ -0,0 +1,51 @@
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { iconPickerVisibleCountState } from '../states/iconPickerVisibleCountState';
type IconPickerScrollEffectProps = {
sentinelId: string;
dropdownId: string;
};
export const IconPickerScrollEffect = ({
sentinelId,
dropdownId,
}: IconPickerScrollEffectProps) => {
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
const setIconPickerVisibleCount = useSetRecoilState(
iconPickerVisibleCountState(dropdownId),
);
useEffect(() => {
const element = document.querySelector(
`#${sentinelId}`,
) as HTMLElement | null;
if (!element) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIconPickerVisibleCount((previousCount) => previousCount + 25);
}
});
},
{
root: scrollWrapperHTMLElement,
rootMargin: '10px',
threshold: 1.0,
},
);
observer.observe(element);
return () => {
observer.disconnect();
};
}, [sentinelId, scrollWrapperHTMLElement, setIconPickerVisibleCount]);
return null;
};
@@ -0,0 +1,6 @@
import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState';
export const iconPickerVisibleCountState = createFamilyState<number, string>({
key: 'iconPickerVisibleCountState',
defaultValue: 25,
});