Add color to object icon picker in data model (#19368)

Closes [#2291](https://github.com/twentyhq/core-team-issues/issues/2291)
This commit is contained in:
Abdul Rahman
2026-04-08 06:57:08 +05:30
committed by GitHub
parent 5c8b6e395b
commit a0484f686a
12 changed files with 337 additions and 79 deletions
@@ -1,39 +1,52 @@
import { isDefined } from 'twenty-shared/utils';
import { css } from '@linaria/core';
import { styled } from '@linaria/react';
import React, { type ReactNode, useCallback, useMemo, useState } from 'react';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
import { ICON_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/ui/input/components/constants/IconPickerDropdownContentWidth';
import { IconPickerScrollEffect } from '@/ui/input/effect-components/IconPickerScrollEffect';
import {
ICON_PICKER_DEFAULT_VISIBLE_COUNT,
iconPickerVisibleCountState,
} from '@/ui/input/states/iconPickerVisibleCountState';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { IconApps, type IconComponent, useIcons } from 'twenty-ui/display';
import React, {
type ReactNode,
useCallback,
useContext,
useMemo,
useState,
} from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
ColorSample,
IconApps,
type IconComponent,
useIcons,
} from 'twenty-ui/display';
import {
IconButton,
type IconButtonSize,
type IconButtonVariant,
LightIconButton,
} from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { type ThemeColor } from 'twenty-ui/theme';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { ICON_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/ui/input/components/constants/IconPickerDropdownContentWidth';
import { ThemeColorPickerMenu } from '@/ui/input/components/ThemeColorPickerMenu';
import { IconPickerScrollEffect } from '@/ui/input/effect-components/IconPickerScrollEffect';
import {
ICON_PICKER_DEFAULT_VISIBLE_COUNT,
iconPickerVisibleCountState,
} from '@/ui/input/states/iconPickerVisibleCountState';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { type DropdownOffset } from '@/ui/layout/dropdown/types/DropdownOffset';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { ClickOutsideListenerContext } from '@/ui/utilities/pointer-event/contexts/ClickOutsideListenerContext';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyStateValue';
import { arrayToChunks } from '~/utils/array/arrayToChunks';
export type IconPickerProps = {
disabled?: boolean;
@@ -50,6 +63,96 @@ export type IconPickerProps = {
dropdownWidth?: number;
dropdownOffset?: DropdownOffset;
maxIconsVisible?: number;
iconColorPicker?: {
selectedColor: ThemeColor;
onColorChange: (color: ThemeColor) => void;
};
};
const StyledIconPickerSearchRow = styled.div`
align-items: center;
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing[2]};
width: 100%;
`;
type IconPickerSearchRowProps = {
closeDropdown: (dropdownId: string) => void;
dropdownWidth: number | undefined;
iconColorPicker: IconPickerProps['iconColorPicker'];
iconColorPickerDropdownId: string;
onSearchChange: (searchString: string) => void;
};
const IconPickerSearchRow = ({
closeDropdown,
dropdownWidth,
iconColorPicker,
iconColorPickerDropdownId,
onSearchChange,
}: IconPickerSearchRowProps) => {
const searchInput = (
<DropdownMenuSearchInput
placeholder={t`Search icon`}
autoFocus
onChange={(event) => {
onSearchChange(event.target.value);
}}
/>
);
if (!isDefined(iconColorPicker)) {
return searchInput;
}
return (
<StyledIconPickerSearchRow>
{searchInput}
<ClickOutsideListenerContext.Provider
value={{
excludedClickOutsideId: iconColorPickerDropdownId,
}}
>
<Dropdown
dropdownId={iconColorPickerDropdownId}
dropdownOffset={{
x: 24,
y: -24,
}}
dropdownPlacement="right-start"
clickableComponent={
<LightIconButton
accent="secondary"
Icon={() => (
<ColorSample
colorName={iconColorPicker.selectedColor}
variant="circle"
/>
)}
size="small"
/>
}
dropdownComponents={
<DropdownContent
widthInPixels={
dropdownWidth || ICON_PICKER_DROPDOWN_CONTENT_WIDTH
}
>
<ThemeColorPickerMenu
selectedColor={iconColorPicker.selectedColor}
onSelectColor={(nextColor) => {
iconColorPicker.onColorChange(nextColor);
closeDropdown(iconColorPickerDropdownId);
}}
/>
</DropdownContent>
}
/>
</ClickOutsideListenerContext.Provider>
</StyledIconPickerSearchRow>
);
};
const StyledMenuIconItemsContainer = styled.div`
@@ -130,6 +233,7 @@ type IconPickerIconProps = {
selectedIconKey?: string;
Icon: IconComponent;
focusedIconKey?: string;
color?: ThemeColor;
};
const IconPickerIcon = ({
@@ -138,7 +242,10 @@ const IconPickerIcon = ({
selectedIconKey,
Icon,
focusedIconKey,
color,
}: IconPickerIconProps) => {
const { theme } = useContext(ThemeContext);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
iconKey,
@@ -154,7 +261,13 @@ const IconPickerIcon = ({
title={iconKey}
isSelected={iconKey === selectedIconKey || !!selectedItemId}
isFocused={iconKey === focusedIconKey}
Icon={Icon}
Icon={(iconProps) => (
<Icon
// oxlint-disable-next-line react/jsx-props-no-spreading
{...iconProps}
color={isDefined(color) ? theme.color[color] : iconProps.color}
/>
)}
onClick={onSelect}
/>
</SelectableListItem>
@@ -177,6 +290,7 @@ export const IconPicker = ({
dropdownWidth,
dropdownOffset,
maxIconsVisible,
iconColorPicker,
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
@@ -284,7 +398,23 @@ export const IconPicker = ({
[matchingSearchIconKeys],
);
const icon = selectedIconKey ? getIcon(selectedIconKey) : IconApps;
const { theme } = useContext(ThemeContext);
const BaseIcon = selectedIconKey ? getIcon(selectedIconKey) : IconApps;
const displayIcon: IconComponent = !isDefined(iconColorPicker)
? BaseIcon
: (iconProps) => (
<BaseIcon
className={iconProps.className}
color={theme.color[iconColorPicker.selectedColor]}
size={iconProps.size}
stroke={iconProps.stroke}
style={iconProps.style}
/>
);
const iconColorPickerDropdownId = `${dropdownId}-icon-color-picker`;
const selectableListInstanceId = 'icon-list';
@@ -307,12 +437,15 @@ export const IconPicker = ({
<Dropdown
dropdownId={dropdownId}
dropdownOffset={dropdownOffset}
excludedClickOutsideIds={
isDefined(iconColorPicker) ? [iconColorPickerDropdownId] : undefined
}
clickableComponent={
clickableComponent ?? (
<IconButton
ariaLabel={t`Click to select icon ${iconAriaLabel}`}
disabled={disabled}
Icon={icon}
Icon={displayIcon}
variant={variant}
size={size}
/>
@@ -330,12 +463,12 @@ export const IconPicker = ({
selectableItemIdMatrix={iconKeys2d}
focusId={dropdownId}
>
<DropdownMenuSearchInput
placeholder={t`Search icon`}
autoFocus
onChange={(event) => {
setSearchString(event.target.value);
}}
<IconPickerSearchRow
closeDropdown={closeDropdown}
dropdownWidth={dropdownWidth}
iconColorPicker={iconColorPicker}
iconColorPickerDropdownId={iconColorPickerDropdownId}
onSearchChange={setSearchString}
/>
<DropdownMenuSeparator />
<div
@@ -355,6 +488,7 @@ export const IconPicker = ({
selectedIconKey={selectedIconKey}
Icon={getIcon(iconKey)}
focusedIconKey={focusedIconKey}
color={iconColorPicker?.selectedColor}
/>
))}
</StyledMenuIconItemsContainer>
@@ -0,0 +1,55 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import {
DEFAULT_COLOR_LABELS,
MenuItemSelectColor,
} from 'twenty-ui/navigation';
import { type ThemeColor, MAIN_COLOR_NAMES } from 'twenty-ui/theme';
type ThemeColorPickerMenuProps = {
selectedColor: ThemeColor;
onSelectColor: (color: ThemeColor) => void;
};
export const ThemeColorPickerMenu = ({
selectedColor,
onSelectColor,
}: ThemeColorPickerMenuProps) => {
const [searchValue, setSearchValue] = useState('');
const query = searchValue.trim().toLowerCase();
const filteredColorNames = isNonEmptyString(query)
? MAIN_COLOR_NAMES.filter(
(colorName) =>
colorName.toLowerCase().includes(query) ||
(DEFAULT_COLOR_LABELS[colorName] ?? '').toLowerCase().includes(query),
)
: MAIN_COLOR_NAMES;
return (
<>
<DropdownMenuSearchInput
placeholder={t`Search colors...`}
value={searchValue}
onChange={(event) => setSearchValue(event.target.value)}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredColorNames.map((colorName) => (
<MenuItemSelectColor
key={colorName}
onClick={() => onSelectColor(colorName)}
color={colorName}
selected={colorName === selectedColor}
colorLabels={DEFAULT_COLOR_LABELS}
/>
))}
</DropdownMenuItemsContainer>
</>
);
};