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:
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user