diff --git a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx index 12ce5bd92f..f3442c676b 100644 --- a/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx +++ b/packages/twenty-front/src/modules/ui/input/components/IconPicker.tsx @@ -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 (
- + - { - setSearchString(event.target.value); - }} - /> - -
- - - {matchingSearchIconKeys.map((iconKey) => ( - { - onChange({ iconKey, Icon: getIcon(iconKey) }); - closeDropdown(dropdownId); - }} - selectedIconKey={selectedIconKey} - Icon={getIcon(iconKey)} - focusedIconKey={focusedIconKey} - /> - ))} - - -
-
- + { + setSearchString(event.target.value); + }} + /> + +
+ + + {matchingSearchIconKeys.map((iconKey) => ( + { + onChange({ iconKey, Icon: getIcon(iconKey) }); + closeDropdown(dropdownId); + }} + selectedIconKey={selectedIconKey} + Icon={getIcon(iconKey)} + focusedIconKey={focusedIconKey} + /> + ))} + + + + {isLoadingMore ? t`Loading more...` : null} + + +
+ + + } onClickOutside={onClickOutside} onClose={() => { onClose?.(); setSearchString(''); + resetIconPickerVisibleCount(); }} onOpen={onOpen} /> diff --git a/packages/twenty-front/src/modules/ui/input/hooks/IconPickerScrollEffect.tsx b/packages/twenty-front/src/modules/ui/input/hooks/IconPickerScrollEffect.tsx new file mode 100644 index 0000000000..a2e8740297 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/hooks/IconPickerScrollEffect.tsx @@ -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; +}; diff --git a/packages/twenty-front/src/modules/ui/input/states/iconPickerVisibleCountState.ts b/packages/twenty-front/src/modules/ui/input/states/iconPickerVisibleCountState.ts new file mode 100644 index 0000000000..130df01d2b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/input/states/iconPickerVisibleCountState.ts @@ -0,0 +1,6 @@ +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export const iconPickerVisibleCountState = createFamilyState({ + key: 'iconPickerVisibleCountState', + defaultValue: 25, +});