diff --git a/src/components/Avatar.tsx b/src/components/Avatar.tsx index 5c288098a503..657d43306627 100644 --- a/src/components/Avatar.tsx +++ b/src/components/Avatar.tsx @@ -139,4 +139,5 @@ function Avatar({ Avatar.displayName = 'Avatar'; +export type {AvatarProps}; export default Avatar; diff --git a/src/components/AvatarSelector.tsx b/src/components/AvatarSelector.tsx new file mode 100644 index 000000000000..bf059e0e197f --- /dev/null +++ b/src/components/AvatarSelector.tsx @@ -0,0 +1,81 @@ +import React, {useState} from 'react'; +import {View} from 'react-native'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog'; +import {CUSTOM_AVATAR_CATALOG} from '@libs/Avatars/CustomAvatarCatalog'; +import type {AvatarSizeName} from '@styles/utils'; +import CONST from '@src/CONST'; +import Avatar from './Avatar'; +import {PressableWithFeedback} from './Pressable'; +import Text from './Text'; + +type AvatarSelectorProps = { + /** Currently selected avatar ID */ + selectedID?: keyof typeof ALL_CUSTOM_AVATARS; + + /** Called when an avatar is selected */ + onSelect: (id: keyof typeof ALL_CUSTOM_AVATARS) => void; + + /** Optional: size of avatars in grid */ + size?: AvatarSizeName; + + /** Optional label to display above the grid */ + label?: string; +}; + +/** + * AvatarSelector — renders a grid of selectable avatars. + * Note: This component should be placed inside a ScrollView. + */ +function AvatarSelector({selectedID, onSelect, label, size = CONST.AVATAR_SIZE.MEDIUM}: AvatarSelectorProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const [selected, setSelected] = useState(selectedID); + + const handleSelect = (id: keyof typeof ALL_CUSTOM_AVATARS) => { + setSelected(id); + onSelect(id); + }; + + return ( + <> + {!!label && ( + + {label} + + )} + + {CUSTOM_AVATAR_CATALOG.map(({id, local}) => { + const isSelected = selected === id; + + return ( + handleSelect(id)} + style={[styles.avatarSelectorWrapper, isSelected && {borderColor: theme.success, borderWidth: 2}]} + > + + + ); + })} + + + ); +} + +AvatarSelector.displayName = 'AvatarSelector'; + +export type {AvatarSelectorProps}; +export default AvatarSelector; diff --git a/src/libs/Avatars/CustomAvatarCatalog.ts b/src/libs/Avatars/CustomAvatarCatalog.ts index edb8344c6d12..d88a5992ddac 100644 --- a/src/libs/Avatars/CustomAvatarCatalog.ts +++ b/src/libs/Avatars/CustomAvatarCatalog.ts @@ -116,13 +116,78 @@ const SEASON_F1: Record = { 'wrenches-pink600': {local: SeasonF1.WrenchesPink600, url: `${CDN_SEASON_F1}/wrenches-pink600.png`}, }; +const DISPLAY_ORDER = [ + 'car-blue100', + 'default-avatar_1', + 'helmet-blue400', + 'default-avatar_13', + 'default-avatar_7', + 'podium-blue400', + 'flag-blue600', + 'default-avatar_19', + 'car-green100', + 'default-avatar_2', + 'helmet-green400', + 'default-avatar_14', + 'default-avatar_8', + 'tire-green400', + 'champagne-green400', + 'default-avatar_20', + 'car-yellow100', + 'default-avatar_3', + 'helmet-yellow400', + 'default-avatar_15', + 'default-avatar_9', + 'medal-yellow400', + 'trophy-yellow600', + 'default-avatar_21', + 'car-tangerine100', + 'default-avatar_4', + 'helmet-tangerine400', + 'default-avatar_16', + 'default-avatar_10', + 'gasoline-tangerine400', + 'cone-tangerine700', + 'default-avatar_22', + 'car-pink100', + 'default-avatar_5', + 'helmet-pink400', + 'default-avatar_17', + 'default-avatar_11', + 'steeringwheel-pink400', + 'wrenches-pink600', + 'default-avatar_23', + 'car-ice100', + 'default-avatar_6', + 'helmet-ice400', + 'default-avatar_18', + 'default-avatar_12', + 'speedometer-ice400', + 'stopwatch-ice600', + 'default-avatar_24', +] as const satisfies readonly CustomAvatarID[]; + const ALL_CUSTOM_AVATARS: Record = { ...DEFAULTS, ...SEASON_F1, }; +const buildOrderedAvatars = (): Array<{id: CustomAvatarID} & AvatarEntry> => { + const allIDS = Object.keys(ALL_CUSTOM_AVATARS) as CustomAvatarID[]; + const explicit = DISPLAY_ORDER.filter((id) => id in ALL_CUSTOM_AVATARS); + const explicitSet = new Set(explicit); + const leftovers = allIDS.filter((id) => !explicitSet.has(id)).sort(); + const finalIDOrder = [...explicit, ...leftovers]; + return finalIDOrder.map((id) => ({ + id, + ...ALL_CUSTOM_AVATARS[id], + })); +}; + +const CUSTOM_AVATAR_CATALOG = buildOrderedAvatars(); + const getAvatarLocal = (id: CustomAvatarID) => ALL_CUSTOM_AVATARS[id].local; const getAvatarURL = (id: CustomAvatarID) => ALL_CUSTOM_AVATARS[id].url; -export {ALL_CUSTOM_AVATARS, getAvatarLocal, getAvatarURL}; +export {ALL_CUSTOM_AVATARS, CUSTOM_AVATAR_CATALOG, getAvatarLocal, getAvatarURL}; export type {DefaultAvatarIDs, SeasonF1AvatarIDs, CustomAvatarID}; diff --git a/src/stories/Avatar.stories.tsx b/src/stories/Avatar.stories.tsx new file mode 100644 index 000000000000..546a2ea661f1 --- /dev/null +++ b/src/stories/Avatar.stories.tsx @@ -0,0 +1,61 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import type {Meta, StoryFn} from '@storybook/react'; +import React from 'react'; +import {View} from 'react-native'; +import type {AvatarProps} from '@components/Avatar'; +import Avatar from '@components/Avatar'; +import * as Expensicons from '@components/Icon/Expensicons'; +import {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog'; +import CONST from '@src/CONST'; + +const AVATAR_URL = ALL_CUSTOM_AVATARS['car-blue100'].url; + +type AvatarStory = StoryFn; + +const story: Meta = { + title: 'Components/Avatar', + component: Avatar, +}; + +function Template(props: AvatarProps) { + return ( + + + + ); +} + +const Default: AvatarStory = Template.bind({}); +Default.args = { + type: CONST.ICON_TYPE_AVATAR, + source: AVATAR_URL, + name: 'John Doe', + size: CONST.AVATAR_SIZE.DEFAULT, +}; + +const WorkspaceAvatar: AvatarStory = Template.bind({}); +WorkspaceAvatar.args = { + type: CONST.ICON_TYPE_WORKSPACE, + name: 'Cathy’s Croissants', + avatarID: 'policy_123', + size: CONST.AVATAR_SIZE.LARGE, +}; + +const FallbackAvatar: AvatarStory = Template.bind({}); +FallbackAvatar.args = { + type: CONST.ICON_TYPE_AVATAR, + fallbackIcon: Expensicons.FallbackAvatar, + name: 'Offline User', + size: CONST.AVATAR_SIZE.DEFAULT, +}; + +const SmallAvatar: AvatarStory = Template.bind({}); +SmallAvatar.args = { + type: CONST.ICON_TYPE_AVATAR, + source: AVATAR_URL, + name: 'Jane', + size: CONST.AVATAR_SIZE.SMALL, +}; + +export default story; +export {Default, WorkspaceAvatar, FallbackAvatar, SmallAvatar}; diff --git a/src/stories/AvatarSelector.stories.tsx b/src/stories/AvatarSelector.stories.tsx new file mode 100644 index 000000000000..e37ac62c1439 --- /dev/null +++ b/src/stories/AvatarSelector.stories.tsx @@ -0,0 +1,60 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import type {Meta, StoryFn} from '@storybook/react'; +import React, {useState} from 'react'; +import type {AvatarSelectorProps} from '@components/AvatarSelector'; +import AvatarSelector from '@components/AvatarSelector'; +import CONST from '@src/CONST'; + +/** + * We use the Component Story Format for writing stories. Follow the docs here: + * + * https://storybook.js.org/docs/react/writing-stories/introduction#component-story-format + */ + +type AvatarSelectorStory = StoryFn; + +const story: Meta = { + title: 'Components/AvatarSelector', + component: AvatarSelector, +}; + +function Template(props: AvatarSelectorProps) { + const [selected, setSelected] = useState(props.selectedID); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ( + + ); +} + +const Default: AvatarSelectorStory = Template.bind({}); +Default.args = { + selectedID: undefined, + label: 'Or choose an avatar', +}; + +const WithPreselectedAvatar: AvatarSelectorStory = Template.bind({}); +WithPreselectedAvatar.args = { + selectedID: 'default-avatar_3', + label: 'With preselected avatar', +}; + +const LargeAvatars: AvatarSelectorStory = Template.bind({}); +LargeAvatars.args = { + selectedID: 'helmet-blue400', + size: CONST.AVATAR_SIZE.LARGE, + label: 'Large avatars', +}; + +const SmallAvatars: AvatarSelectorStory = Template.bind({}); +SmallAvatars.args = { + size: CONST.AVATAR_SIZE.SMALL, + label: 'Small avatars', +}; + +export default story; +export {Default, WithPreselectedAvatar, LargeAvatars, SmallAvatars}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 49afc0f74c3e..b8bcdc8d4b24 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -5173,6 +5173,24 @@ const staticStyles = (theme: ThemeColors) => marginLeft: 4, }, + avatarSelectorWrapper: { + margin: 5, + borderRadius: 50, + padding: 3, + borderWidth: 2, + borderColor: 'transparent', + }, + + avatarSelectorContainer: { + alignItems: 'center', + justifyContent: 'center', + }, + + avatarSelectorListContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + expenseWidgetRadius: { borderRadius: variables.componentBorderRadiusNormal, }, diff --git a/tests/ui/AvatarSelector.test.tsx b/tests/ui/AvatarSelector.test.tsx new file mode 100644 index 000000000000..fc7f4da77ced --- /dev/null +++ b/tests/ui/AvatarSelector.test.tsx @@ -0,0 +1,86 @@ +import {fireEvent, render, screen} from '@testing-library/react-native'; +import React from 'react'; +import AvatarSelector from '@components/AvatarSelector'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {ALL_CUSTOM_AVATARS} from '@libs/Avatars/CustomAvatarCatalog'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +TestHelper.setupApp(); + +describe('AvatarSelector', () => { + const onSelectMock = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const renderAvatarSelector = (props = {}) => { + return render( + + + , + ); + }; + + it('renders all avatars from catalog', async () => { + renderAvatarSelector(); + await waitForBatchedUpdates(); + + // Check that all avatars are rendered + const avatars = Object.keys(ALL_CUSTOM_AVATARS); + avatars.forEach((id) => { + expect(screen.getByTestId(`AvatarSelector_${id}`)).toBeOnTheScreen(); + }); + }); + + it('calls onSelect when avatar is pressed', async () => { + renderAvatarSelector(); + await waitForBatchedUpdates(); + + const avatars = Object.keys(ALL_CUSTOM_AVATARS); + const firstAvatarId = avatars.at(0); + const firstAvatar = screen.getByTestId(`AvatarSelector_${firstAvatarId}`); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-non-null-asserted-optional-chain + fireEvent.press(firstAvatar.parent?.parent!); + await waitForBatchedUpdates(); + + expect(onSelectMock).toHaveBeenCalledWith(firstAvatarId); + expect(onSelectMock).toHaveBeenCalledTimes(1); + }); + + it('renders with label when provided', async () => { + const label = 'Choose an avatar'; + renderAvatarSelector({label}); + await waitForBatchedUpdates(); + + expect(screen.getByText(label)).toBeOnTheScreen(); + }); + + it('does not render label when not provided', async () => { + renderAvatarSelector(); + await waitForBatchedUpdates(); + + // Label should not exist when not provided + const text = screen.queryByText('Choose an avatar'); + expect(text).not.toBeOnTheScreen(); + }); + + it('shows selected avatar with different styling', async () => { + const avatars = Object.keys(ALL_CUSTOM_AVATARS); + const selectedId = avatars.at(0) as keyof typeof ALL_CUSTOM_AVATARS; + + renderAvatarSelector({selectedID: selectedId}); + await waitForBatchedUpdates(); + + const selectedAvatar = screen.getByTestId(`AvatarSelector_${selectedId}`); + expect(selectedAvatar).toBeOnTheScreen(); + }); +});