From 9051ac9ee46675838b88ab029f35ca569918d459 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Fri, 28 Feb 2025 18:51:15 -0800 Subject: [PATCH 1/3] Added temp AvatarGroup --- .../.storybook/storybook.requires.js | 1 + .../AvatarGroup/AvatarGroup.constants.ts | 35 ++++++ .../AvatarGroup/AvatarGroup.stories.tsx | 116 ++++++++++++++++++ .../AvatarGroup/AvatarGroup.test.tsx | 75 +++++++++++ .../components/AvatarGroup/AvatarGroup.tsx | 86 +++++++++++++ .../AvatarGroup/AvatarGroup.types.ts | 58 +++++++++ .../AvatarGroup/AvatarGroup.utilities.ts | 46 +++++++ .../src/components/AvatarGroup/README.md | 95 ++++++++++++++ .../src/components/AvatarGroup/index.ts | 1 + .../src/shared/enums.ts | 1 + 10 files changed, 514 insertions(+) create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.types.ts create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.utilities.ts create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/README.md create mode 100644 packages/design-system-react-native/src/components/AvatarGroup/index.ts diff --git a/apps/storybook-react-native/.storybook/storybook.requires.js b/apps/storybook-react-native/.storybook/storybook.requires.js index 40df03d32..49ea23c65 100644 --- a/apps/storybook-react-native/.storybook/storybook.requires.js +++ b/apps/storybook-react-native/.storybook/storybook.requires.js @@ -48,6 +48,7 @@ try { const getStories = () => { return { "./../../packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.stories.tsx"), + "./../../packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx"), "./../../packages/design-system-react-native/src/components/AvatarIcon/AvatarIcon.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarIcon/AvatarIcon.stories.tsx"), "./../../packages/design-system-react-native/src/components/Button/Button.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/Button.stories.tsx"), "./../../packages/design-system-react-native/src/components/Button/variants/ButtonPrimary/ButtonPrimary.stories.tsx": require("../../../packages/design-system-react-native/src/components/Button/variants/ButtonPrimary/ButtonPrimary.stories.tsx"), diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts new file mode 100644 index 000000000..fd09c403f --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts @@ -0,0 +1,35 @@ +import type { AvatarGroupProps } from './AvatarGroup.types'; +import { AvatarGroupSize } from '../../shared/enums'; +import { TextVariant } from '../Text'; + +// Mappings +export const MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS: Record< + AvatarGroupSize, + number +> = { + [AvatarGroupSize.Xs]: -6, + [AvatarGroupSize.Sm]: -10, + [AvatarGroupSize.Md]: -14, + [AvatarGroupSize.Lg]: -18, + [AvatarGroupSize.Xl]: -22, +}; + +export const MAP_AVATARGROUP_SIZE_OVERFLOWTEXT_TEXTVARIANT: Record< + AvatarGroupSize, + TextVariant +> = { + [AvatarGroupSize.Xs]: TextVariant.BodyXs, + [AvatarGroupSize.Sm]: TextVariant.BodySm, + [AvatarGroupSize.Md]: TextVariant.BodyMd, + [AvatarGroupSize.Lg]: TextVariant.HeadingMd, + [AvatarGroupSize.Xl]: TextVariant.HeadingMd, +}; + +// Defaults +export const DEFAULT_AVATARGROUP_PROPS: Required< + Pick +> = { + size: AvatarGroupSize.Md, + max: 4, + isReverse: false, +}; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx new file mode 100644 index 000000000..328f9443e --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from '@storybook/react-native'; +import { View } from 'react-native'; + +import { AvatarGroupSize } from '../../shared/enums'; +import { AvatarFaviconProps } from '../AvatarFavicon'; +import { AvatarIconProps, AvatarIconSeverity } from '../AvatarIcon'; +import { IconName } from '../Icon'; +import AvatarGroup from './AvatarGroup'; +import { DEFAULT_AVATARGROUP_PROPS } from './AvatarGroup.constants'; +import type { AvatarGroupProps } from './AvatarGroup.types'; +import { AvatarGroupVariant } from './AvatarGroup.types'; + +const meta: Meta = { + title: 'Components/AvatarGroup', + component: AvatarGroup, + argTypes: { + variant: { + control: 'select', + options: AvatarGroupVariant, + }, + size: { + control: 'select', + options: AvatarGroupSize, + }, + max: { + control: 'number', + }, + isReverse: { + control: 'boolean', + }, + twClassName: { + control: 'text', + }, + }, +}; + +export default meta; + +type Story = StoryObj; +const avatarFaviconPropsArr: AvatarFaviconProps[] = [ + { + src: { + uri: 'https://www.coinbase.com/favicon.ico', + }, + }, + { + src: { + uri: 'https://www.myetherwallet.com/favicon.ico', + }, + }, + { + src: { + uri: 'https://electrum.org/favicon.ico', + }, + }, + { + src: { + uri: 'https://www.blockchain.com/static/favicon.ico', + }, + }, + { + src: { + uri: 'https://trezor.io/favicon.ico', + }, + }, + { + src: { + uri: 'https://trezor.io/favicon.ico', + }, + }, + { + src: { + uri: 'https://metamask.github.io/test-dapp/metamask-fox.svg', + }, + }, +]; +const avatarIconPropsArr: AvatarIconProps[] = [ + { + iconName: IconName.Arrow2Left, + severity: AvatarIconSeverity.Default, + }, + { + iconName: IconName.Arrow2Up, + severity: AvatarIconSeverity.Error, + }, + { + iconName: IconName.Arrow2UpRight, + severity: AvatarIconSeverity.Info, + }, + { + iconName: IconName.Arrow2Right, + severity: AvatarIconSeverity.Success, + }, + { + iconName: IconName.Arrow2Down, + severity: AvatarIconSeverity.Warning, + }, +]; + +export const Default: Story = { + args: { + variant: AvatarGroupVariant.Favicon, + size: DEFAULT_AVATARGROUP_PROPS.size, + max: DEFAULT_AVATARGROUP_PROPS.max, + isReverse: DEFAULT_AVATARGROUP_PROPS.isReverse, + twClassName: '', + }, + render: (args) => { + switch (args.variant) { + case AvatarGroupVariant.Favicon: + return ; + case AvatarGroupVariant.Icon: + return ; + } + }, +}; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx new file mode 100644 index 000000000..b52134e8b --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx @@ -0,0 +1,75 @@ +// External dependencies +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// Internal dependencies +import AvatarGroup from './AvatarGroup'; +import { + SAMPLE_AVATARGROUP_PROPS, + AVATARGROUP_AVATAR_CONTAINER_TESTID, + AVATARGROUP_AVATAR_TESTID, + AVATARGROUP_OVERFLOWCOUNTER_TESTID, +} from './AvatarGroup.constants'; + +describe('AvatarGroup', () => { + const renderComponent = (props = {}) => + render(); + + it('should render AvatarGroup component', () => { + const { toJSON } = renderComponent(); + expect(toJSON()).toMatchSnapshot(); + }); + + it('should render the correct number of avatars', () => { + const { getAllByTestId } = renderComponent(); + const avatars = getAllByTestId(AVATARGROUP_AVATAR_TESTID); + expect(avatars.length).toBe(SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars); + }); + + it('should render the overflow counter when there are more avatars than the max limit', () => { + const { getByTestId } = renderComponent(); + const overflowCounter = getByTestId(AVATARGROUP_OVERFLOWCOUNTER_TESTID); + expect(overflowCounter).toBeDefined(); + const overflowCounterNumber = SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars + ? SAMPLE_AVATARGROUP_PROPS.avatarPropsList.length - + SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars + : SAMPLE_AVATARGROUP_PROPS.avatarPropsList.length; + expect(overflowCounter.props.children).toBe(`+${overflowCounterNumber}`); + }); + + it('should not render the overflow counter when there are fewer or equal avatars than the max limit', () => { + const { queryByTestId } = renderComponent({ + avatarPropsList: SAMPLE_AVATARGROUP_PROPS.avatarPropsList.slice( + 0, + SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars, + ), + }); + const overflowCounter = queryByTestId(AVATARGROUP_OVERFLOWCOUNTER_TESTID); + expect(overflowCounter).toBeNull(); + }); + + it('should apply the correct space between avatars if custom spaceBetweenAvatars was given', () => { + const spaceBetweenAvatars = -10; + + const { getByTestId } = renderComponent({ + spaceBetweenAvatars, + }); + + const firstAvatarContainer = getByTestId( + `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-0`, + ); + const secondAvatarContainer = getByTestId( + `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-1`, + ); + const thirdAvatarContainer = getByTestId( + `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-2`, + ); + expect(firstAvatarContainer.props.style.marginLeft).toBe(0); + expect(secondAvatarContainer.props.style.marginLeft).toBe( + spaceBetweenAvatars, + ); + expect(thirdAvatarContainer.props.style.marginLeft).toBe( + spaceBetweenAvatars, + ); + }); +}); diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx new file mode 100644 index 000000000..f00c164ec --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx @@ -0,0 +1,86 @@ +import React, { useCallback } from 'react'; +import { View } from 'react-native'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; + +import AvatarFavicon, { AvatarFaviconProps } from '../AvatarFavicon'; +import AvatarIcon, { AvatarIconProps } from '../AvatarIcon'; +import Text, { TextColor } from '../Text'; +import { + MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS, + MAP_AVATARGROUP_SIZE_OVERFLOWTEXT_TEXTVARIANT, + DEFAULT_AVATARGROUP_PROPS, +} from './AvatarGroup.constants'; +import { AvatarGroupProps, AvatarGroupVariant } from './AvatarGroup.types'; + +const AvatarGroup = ({ + variant, + avatarPropsArr, + size = DEFAULT_AVATARGROUP_PROPS.size, + max = DEFAULT_AVATARGROUP_PROPS.max, + isReverse = DEFAULT_AVATARGROUP_PROPS.isReverse, + style, + twClassName, + ...props +}: AvatarGroupProps) => { + const tw = useTailwind(); + const overflowCounter = avatarPropsArr.length - max; + const avatarNegativeSpacing = MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]; + const shouldRenderOverflowCounter = overflowCounter > 0; + + const renderAvatarList = useCallback( + () => + avatarPropsArr.slice(0, max).map((avatarProps, index) => { + const marginLeft = index === 0 ? 0 : avatarNegativeSpacing; + switch (variant) { + case AvatarGroupVariant.Favicon: + return ( + + ); + case AvatarGroupVariant.Icon: + return ( + + ); + default: + throw new Error( + `Invalid Avatar Variant: ${variant}. Expected one of: ${Object.values(AvatarGroupVariant).join(', ')}`, + ); + } + }), + [avatarPropsArr, avatarNegativeSpacing, max, size], + ); + + return ( + + {renderAvatarList()} + {shouldRenderOverflowCounter && ( + + {`+${overflowCounter}`} + + )} + + ); +}; + +export default AvatarGroup; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.types.ts b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.types.ts new file mode 100644 index 000000000..40fb22e4b --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.types.ts @@ -0,0 +1,58 @@ +import { ViewProps } from 'react-native'; + +import { AvatarGroupSize } from '../../shared/enums'; +import { AvatarFaviconProps } from '../AvatarFavicon'; +import { AvatarIconProps } from '../AvatarIcon'; + +/** + * AvatarGroup variants. + */ +export enum AvatarGroupVariant { + Favicon = 'Favicon', + Icon = 'Icon', +} + +type BaseAvatarGroupProps = { + /** + * Optional enum to select between Avatar Group sizes. + * @default AvatarGroupSize.Md + */ + size?: AvatarGroupSize; + /** + * Optional enum to select max number of Avatars visible, + * before the overflow counter being displayed + * @default 4 + */ + max?: number; + /** + * Optional prop to reverse the direction of the AvatarGroup + */ + isReverse?: boolean; + /** + * Optional prop to add twrnc overriding classNames. + */ + twClassName?: string; +} & ViewProps; + +/** + * AvatarGroup props. + */ +export type AvatarGroupProps = BaseAvatarGroupProps & + ( + | { + variant: AvatarGroupVariant.Favicon; + /** + * A list of Avatars to be horizontally stacked. + * Note: AvatarGroupProps's size prop will overwrite each individual avatarProp's size. + */ + avatarPropsArr: AvatarFaviconProps[]; + } + | { + variant: AvatarGroupVariant.Icon; + /** + * A list of Avatars to be horizontally stacked. + * Note: AvatarGroupProps's size prop will overwrite each individual avatarProp's size. + */ + avatarPropsArr: AvatarIconProps[]; + } + ); diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.utilities.ts b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.utilities.ts new file mode 100644 index 000000000..d0e5e6703 --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.utilities.ts @@ -0,0 +1,46 @@ +/* eslint-disable jsdoc/check-param-names */ +/* eslint-disable jsdoc/require-param */ +import { DEFAULT_AVATARBASE_PROPS } from './AvatarBase.constants'; +import type { AvatarBaseProps } from './AvatarBase.types'; +import { TWCLASSMAP_AVATARBASE_SIZE_SHAPE } from './AvatarBase.constants'; +import { AvatarBaseShape } from '../../shared/enums'; + +/** + * Generates a Tailwind class name string for the base container of an avatar. + * + * This function constructs a class name string based on the avatar's `size`, + * `shape`, and optional additional Tailwind class names. + * + * @param size - The size of the avatar, defaulting to `DEFAULT_AVATARBASE_PROPS.size`. + * @param shape - The shape of the avatar, either `circle` or another defined shape. + * @param twClassName - Additional Tailwind class names for customization. + * @returns A string of Tailwind class names representing the avatar's container styles. + * + * Example: + * ``` + * const classNames = generateAvatarBaseContainerClassNames({ + * size: 48, + * shape: 'circle', + * twClassName: 'border border-gray-500', + * }); + * + * console.log(classNames); + * // Output: "items-center justify-center overflow-hidden bg-background-muted h-[48px] w-[48px] rounded-full border border-gray-500" + * ``` + */ +export const generateAvatarBaseContainerClassNames = ({ + size = DEFAULT_AVATARBASE_PROPS.size, + shape = DEFAULT_AVATARBASE_PROPS.shape, + twClassName = '', +}: Partial): string => { + const baseStyle = 'items-center justify-center overflow-hidden'; + const fallbackBackgroundStyle = 'bg-background-muted'; + const sizeStyle = `h-[${size}px] w-[${size}px]`; + const shapeStyle = + shape === AvatarBaseShape.Circle + ? 'rounded-full' + : TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]; + + const mergedClassnames = `${baseStyle} ${fallbackBackgroundStyle} ${sizeStyle} ${shapeStyle} ${twClassName}`; + return mergedClassnames; +}; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/README.md b/packages/design-system-react-native/src/components/AvatarGroup/README.md new file mode 100644 index 000000000..98658b414 --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/README.md @@ -0,0 +1,95 @@ +# AvatarGroup + +AvatarGroup is a component that horizontally stacks multiple Avatar components. + +## AvatarGroup Props + +This component extends React Native's [ViewProps](https://reactnative.dev/docs/view) component. + +### `avatarPropsList` + +A list of Avatars to be horizontally stacked. +Note: AvatarGroup's `size` and `includesBorder` prop will overwrite each individual avatar's `size` and `includesBorder` prop. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| `AvatarProps[]` | Yes | + +### `size` + +Optional enum to select between Avatar Group sizes. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| [`AvatarSize`](../Avatar/Avatar.types.ts) | No | `AvatarSize.Xs` | + +### `maxStackedAvatars` + +Optional enum to select the maximum number of Avatars visible before the overflow counter is displayed. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| `number` | No | 4 | + +### `includesBorder` + +Optional boolean to include a border or not. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| `boolean` | No | `false` | + +### `spaceBetweenAvatars` + +Optional enum to configure the space between avatars. +Note: + +- Negative values for this prop will result in the Avatars moving closer to each other, positive values for this prop will result in the Avatars moving away from each other. +- The default values of the space between avatars depend on the size. +- Please refer to the constants file for the mappings. + +| TYPE | REQUIRED | +| :-------------------------------------------------- | :------------------------------------------------------ | +| `number` | No | + +## Usage + +```javascript +// Passing list of AvatarProps to avatarPropsList +const avatarPropsList: AvatarProps[] = [ + { + variant: AvatarVariant.Network, + name: SAMPLE_AVATARNETWORK_NAME, + imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, + }, + { + variant: AvatarVariant.Network, + name: SAMPLE_AVATARNETWORK_NAME, + imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, + }, + { + variant: AvatarVariant.Network, + name: SAMPLE_AVATARNETWORK_NAME, + imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, + }, +]; +; + +// Configuring different Avatar sizes +; +; +; +; +; + +// Configuring max number of stacked Avatars +; + +// Configuring border inclusion +; +; + +// Configuring space between Avatars +; +; +``` diff --git a/packages/design-system-react-native/src/components/AvatarGroup/index.ts b/packages/design-system-react-native/src/components/AvatarGroup/index.ts new file mode 100644 index 000000000..20c5443b2 --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/index.ts @@ -0,0 +1 @@ +export { default } from './AvatarGroup'; diff --git a/packages/design-system-react-native/src/shared/enums.ts b/packages/design-system-react-native/src/shared/enums.ts index e0ae6203c..8fd8bfb05 100644 --- a/packages/design-system-react-native/src/shared/enums.ts +++ b/packages/design-system-react-native/src/shared/enums.ts @@ -25,6 +25,7 @@ export enum AvatarSize { } export { AvatarSize as AvatarBaseSize }; export { AvatarSize as AvatarFaviconSize }; +export { AvatarSize as AvatarGroupSize }; export { AvatarSize as AvatarIconSize }; /** From 326fcf7fe1dcf576d7e3171e44ca7fd6e2df2729 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Wed, 5 Mar 2025 00:26:52 -0800 Subject: [PATCH 2/3] Added AvatarGroup to dsrn --- .../AvatarAccount/AvatarAccount.constants.ts | 16 + .../AvatarAccount/AvatarAccount.stories.tsx | 32 +- .../AvatarAccount/AvatarAccount.test.tsx | 15 +- .../AvatarFavicon/AvatarFavicon.constants.ts | 11 + .../AvatarFavicon/AvatarFavicon.stories.tsx | 22 +- .../AvatarFavicon/AvatarFavicon.test.tsx | 6 +- .../AvatarFavicon/AvatarFavicon.tsx | 4 + .../AvatarGroup/AvatarGroup.constants.ts | 155 +++++++- .../AvatarGroup/AvatarGroup.stories.tsx | 186 ++++++---- .../AvatarGroup/AvatarGroup.test.tsx | 330 +++++++++++++++--- .../components/AvatarGroup/AvatarGroup.tsx | 72 +++- .../AvatarGroup/AvatarGroup.types.ts | 28 +- .../AvatarGroup/AvatarGroup.utilities.ts | 94 +++-- .../src/components/AvatarGroup/README.md | 307 ++++++++++++---- .../AvatarNetwork/AvatarNetwork.constants.ts | 11 + .../AvatarNetwork/AvatarNetwork.stories.tsx | 22 +- .../AvatarNetwork/AvatarNetwork.test.tsx | 7 +- .../AvatarNetwork/AvatarNetwork.tsx | 4 + .../AvatarToken/AvatarToken.constants.ts | 11 + .../AvatarToken/AvatarToken.stories.tsx | 22 +- .../AvatarToken/AvatarToken.test.tsx | 6 +- .../components/AvatarToken/AvatarToken.tsx | 4 + .../AvatarBase/AvatarBase.constants.ts | 18 +- .../primitives/AvatarBase/AvatarBase.test.tsx | 35 +- .../src/primitives/AvatarBase/AvatarBase.tsx | 8 +- .../primitives/AvatarBase/AvatarBase.types.ts | 12 + .../AvatarBase/AvatarBase.utilities.ts | 28 +- 27 files changed, 1156 insertions(+), 310 deletions(-) diff --git a/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.constants.ts b/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.constants.ts index ebe7ab45b..1307a8b8e 100644 --- a/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.constants.ts +++ b/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.constants.ts @@ -10,3 +10,19 @@ export const DEFAULT_AVATARACCOUNT_PROPS: Required< shape: AvatarBaseShape.Circle, variant: AvatarAccountVariant.Jazzicon, }; + +// Sample account addresses +export const SAMPLE_AVATARACCOUNT_ADDRESSES = [ + '0x9Cbf7c41B7787F6c621115010D3B044029FE2Ce8', + '0xb9b81f6bd23B953c5257C3b5E2F0c03B07E944eB', + '0x360507dfEC4Bf0c03495f91154A78C672599F308', + '0x50cA820Ff810F7687E7d0aDb23A830e3ac6032C3', + '0x840C9Eb73729E626673714D6E4dA8afc8Ccc90d3', + '0xCA0361BE89B7d47a6233d1875F0727ddeAB23377', + '0xD78CBcA88eCd65c6128511e46a518CDc6c66fC74', + '0xCFc8b1d1031ef2ecce3a98d5D79ce4D75Edb06bA', + '0xDe53fa2E659b6134991bB56F64B691990e5C44E7', + '0x8AceA3A9748294d1B5C25a08EFE37b756AafDFdd', + '0xEC5CE72f2e18B0017C88F7B12d3308119C5Cf129', + '0xeC56Da21c90Af6b50E4Ba5ec252bD97e735290fc', +]; diff --git a/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx b/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx index ae84c1514..7f535cbf1 100644 --- a/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx +++ b/packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx @@ -2,10 +2,12 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { View } from 'react-native'; import AvatarAccount from './AvatarAccount'; -import { DEFAULT_AVATARACCOUNT_PROPS } from './AvatarAccount.constants'; +import { + DEFAULT_AVATARACCOUNT_PROPS, + SAMPLE_AVATARACCOUNT_ADDRESSES, +} from './AvatarAccount.constants'; import type { AvatarAccountProps } from './AvatarAccount.types'; import { AvatarSize } from '../../shared/enums'; -import { IconName } from '../Icon'; import { AvatarAccountVariant } from './AvatarAccount.types'; const meta: Meta = { @@ -29,20 +31,6 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const sampleAccountAddresses = [ - '0x9Cbf7c41B7787F6c621115010D3B044029FE2Ce8', - '0xb9b81f6bd23B953c5257C3b5E2F0c03B07E944eB', - '0x360507dfEC4Bf0c03495f91154A78C672599F308', - '0x50cA820Ff810F7687E7d0aDb23A830e3ac6032C3', - '0x840C9Eb73729E626673714D6E4dA8afc8Ccc90d3', - '0xCA0361BE89B7d47a6233d1875F0727ddeAB23377', - '0xD78CBcA88eCd65c6128511e46a518CDc6c66fC74', - '0xCFc8b1d1031ef2ecce3a98d5D79ce4D75Edb06bA', - '0xDe53fa2E659b6134991bB56F64B691990e5C44E7', - '0x8AceA3A9748294d1B5C25a08EFE37b756AafDFdd', - '0xEC5CE72f2e18B0017C88F7B12d3308119C5Cf129', - '0xeC56Da21c90Af6b50E4Ba5ec252bD97e735290fc', -]; export const Default: Story = { args: { size: DEFAULT_AVATARACCOUNT_PROPS.size, @@ -50,7 +38,9 @@ export const Default: Story = { twClassName: '', }, render: (args) => { - return ; + return ( + + ); }, }; @@ -62,12 +52,12 @@ export const Sizes: Story = { ))} @@ -86,7 +76,7 @@ export const Variants: Story = { variantKey as keyof typeof AvatarAccountVariant ] } - address={sampleAccountAddresses[0]} + address={SAMPLE_AVATARACCOUNT_ADDRESSES[0]} /> ))} @@ -96,7 +86,7 @@ export const Variants: Story = { export const SampleAddresses: Story = { render: () => ( - {sampleAccountAddresses.map((address) => ( + {SAMPLE_AVATARACCOUNT_ADDRESSES.map((address) => ( { it('renders Jazzicon by default when no variant is provided', () => { - const address = '0x12345'; + const address = SAMPLE_AVATARACCOUNT_ADDRESSES[0]; const { getByTestId, queryByTestId } = render( , @@ -20,7 +23,7 @@ describe('AvatarAccount', () => { }); it('renders Blockies when variant is blockies', () => { - const address = '0xabcdef'; + const address = SAMPLE_AVATARACCOUNT_ADDRESSES[0]; const { getByTestId, queryByTestId } = render( { }); it('respects the default size and shape', () => { - const address = '0xlmnop'; + const address = SAMPLE_AVATARACCOUNT_ADDRESSES[0]; const { getByTestId } = render( , ); @@ -49,7 +52,7 @@ describe('AvatarAccount', () => { }); it('overrides the size if provided', () => { - const address = '0x999999'; + const address = SAMPLE_AVATARACCOUNT_ADDRESSES[0]; const { getByTestId } = render( { }); it('passes additional props to AvatarBase', () => { - const address = '0x67890'; + const address = SAMPLE_AVATARACCOUNT_ADDRESSES[0]; const customStyle = { margin: 10 }; const { getByTestId } = render( ; const storyImageSource: ImageSourcePropType = { - uri: 'https://metamask.github.io/test-dapp/metamask-fox.svg', + uri: SAMPLE_AVATARFAVICON_URIS[0], }; export const Default: Story = { @@ -48,3 +51,18 @@ export const Sizes: Story = { ), }; + +export const SampleFavicons: Story = { + render: () => ( + + {SAMPLE_AVATARFAVICON_URIS.map((faviconUri) => ( + + ))} + + ), +}; diff --git a/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.test.tsx b/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.test.tsx index 256318079..75c00ac0e 100644 --- a/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.test.tsx +++ b/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.test.tsx @@ -53,7 +53,7 @@ describe('AvatarFavicon Component', () => { const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('updates fallback text on svg error when fallbackText is provided', () => { @@ -76,7 +76,7 @@ describe('AvatarFavicon Component', () => { expect(onSvgErrorMock).toHaveBeenCalledTimes(1); expect(onSvgErrorMock).toHaveBeenCalledWith(errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('computes backupFallbackText from name when fallbackText is not provided', () => { @@ -96,7 +96,7 @@ describe('AvatarFavicon Component', () => { fireEvent(imageOrSvg, 'onImageError', errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual('E'); + expect(avatarBase.props.children[1].props.children).toStrictEqual('E'); }); it('passes additional AvatarBase props correctly', () => { diff --git a/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.tsx b/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.tsx index 6de333315..1e5351311 100644 --- a/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.tsx +++ b/packages/design-system-react-native/src/components/AvatarFavicon/AvatarFavicon.tsx @@ -12,6 +12,8 @@ const AvatarFavicon = ({ shape = DEFAULT_AVATARFAVICON_PROPS.shape, fallbackText, fallbackTextProps, + hasBorder, + hasSolidBackgroundColor, twClassName, testID, style, @@ -45,6 +47,8 @@ const AvatarFavicon = ({ fallbackText={finalFallbackText} fallbackTextProps={fallbackTextProps} twClassName={twClassName} + hasBorder={hasBorder} + hasSolidBackgroundColor={hasSolidBackgroundColor} testID={testID} style={style} > diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts index fd09c403f..7a3503eea 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts @@ -1,6 +1,14 @@ -import type { AvatarGroupProps } from './AvatarGroup.types'; import { AvatarGroupSize } from '../../shared/enums'; import { TextVariant } from '../Text'; +import { AvatarAccountProps, AvatarAccountVariant } from '../AvatarAccount'; +import { SAMPLE_AVATARACCOUNT_ADDRESSES } from '../AvatarAccount/AvatarAccount.constants'; +import { AvatarFaviconProps } from '../AvatarFavicon'; +import { SAMPLE_AVATARFAVICON_URIS } from '../AvatarFavicon/AvatarFavicon.constants'; +import { AvatarNetworkProps } from '../AvatarNetwork'; +import { SAMPLE_AVATARNETWORK_URIS } from '../AvatarNetwork/AvatarNetwork.constants'; +import { AvatarTokenProps } from '../AvatarToken'; +import { SAMPLE_AVATARTOKEN_URIS } from '../AvatarToken/AvatarToken.constants'; +import type { AvatarGroupProps } from './AvatarGroup.types'; // Mappings export const MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS: Record< @@ -33,3 +41,148 @@ export const DEFAULT_AVATARGROUP_PROPS: Required< max: 4, isReverse: false, }; + +// Sample consts +export const SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR: AvatarAccountProps[] = [ + { + variant: AvatarAccountVariant.Jazzicon, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[0], + }, + { + variant: AvatarAccountVariant.Blockies, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[1], + }, + { + variant: AvatarAccountVariant.Jazzicon, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[2], + }, + { + variant: AvatarAccountVariant.Jazzicon, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[3], + }, + { + variant: AvatarAccountVariant.Jazzicon, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[4], + }, + { + variant: AvatarAccountVariant.Blockies, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[5], + }, + { + variant: AvatarAccountVariant.Blockies, + address: SAMPLE_AVATARACCOUNT_ADDRESSES[6], + }, +]; +export const SAMPLE_AVATARGROUP_AVATARFAVICONPROPSARR: AvatarFaviconProps[] = [ + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[0], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[1], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[2], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[3], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[4], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[5], + }, + }, + { + src: { + uri: SAMPLE_AVATARFAVICON_URIS[6], + }, + }, +]; + +export const SAMPLE_AVATARGROUP_AVATARNETWORKPROPSARR: AvatarNetworkProps[] = [ + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[0], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[1], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[2], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[3], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[4], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[5], + }, + }, + { + src: { + uri: SAMPLE_AVATARNETWORK_URIS[6], + }, + }, +]; + +export const SAMPLE_AVATARGROUP_AVATARTOKENPROPSARR: AvatarTokenProps[] = [ + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[0], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[1], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[2], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[3], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[4], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[5], + }, + }, + { + src: { + uri: SAMPLE_AVATARTOKEN_URIS[6], + }, + }, +]; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx index 328f9443e..a7831df49 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx @@ -1,12 +1,15 @@ +import { ScrollView, View } from 'react-native'; import type { Meta, StoryObj } from '@storybook/react-native'; -import { View } from 'react-native'; import { AvatarGroupSize } from '../../shared/enums'; -import { AvatarFaviconProps } from '../AvatarFavicon'; -import { AvatarIconProps, AvatarIconSeverity } from '../AvatarIcon'; -import { IconName } from '../Icon'; import AvatarGroup from './AvatarGroup'; -import { DEFAULT_AVATARGROUP_PROPS } from './AvatarGroup.constants'; +import { + DEFAULT_AVATARGROUP_PROPS, + SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR, + SAMPLE_AVATARGROUP_AVATARFAVICONPROPSARR, + SAMPLE_AVATARGROUP_AVATARNETWORKPROPSARR, + SAMPLE_AVATARGROUP_AVATARTOKENPROPSARR, +} from './AvatarGroup.constants'; import type { AvatarGroupProps } from './AvatarGroup.types'; import { AvatarGroupVariant } from './AvatarGroup.types'; @@ -37,65 +40,46 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const avatarFaviconPropsArr: AvatarFaviconProps[] = [ - { - src: { - uri: 'https://www.coinbase.com/favicon.ico', - }, - }, - { - src: { - uri: 'https://www.myetherwallet.com/favicon.ico', - }, - }, - { - src: { - uri: 'https://electrum.org/favicon.ico', - }, - }, - { - src: { - uri: 'https://www.blockchain.com/static/favicon.ico', - }, - }, - { - src: { - uri: 'https://trezor.io/favicon.ico', - }, - }, - { - src: { - uri: 'https://trezor.io/favicon.ico', - }, - }, - { - src: { - uri: 'https://metamask.github.io/test-dapp/metamask-fox.svg', - }, - }, -]; -const avatarIconPropsArr: AvatarIconProps[] = [ - { - iconName: IconName.Arrow2Left, - severity: AvatarIconSeverity.Default, - }, - { - iconName: IconName.Arrow2Up, - severity: AvatarIconSeverity.Error, - }, - { - iconName: IconName.Arrow2UpRight, - severity: AvatarIconSeverity.Info, - }, - { - iconName: IconName.Arrow2Right, - severity: AvatarIconSeverity.Success, - }, - { - iconName: IconName.Arrow2Down, - severity: AvatarIconSeverity.Warning, - }, -]; + +const AvatarGroupStory: React.FC> = ({ + variant, + ...props +}) => { + switch (variant) { + case AvatarGroupVariant.Account: + return ( + + ); + case AvatarGroupVariant.Favicon: + return ( + + ); + case AvatarGroupVariant.Network: + return ( + + ); + case AvatarGroupVariant.Token: + return ( + + ); + } +}; export const Default: Story = { args: { @@ -105,12 +89,70 @@ export const Default: Story = { isReverse: DEFAULT_AVATARGROUP_PROPS.isReverse, twClassName: '', }, - render: (args) => { - switch (args.variant) { - case AvatarGroupVariant.Favicon: - return ; - case AvatarGroupVariant.Icon: - return ; - } - }, + render: (args) => , +}; + +export const Variants: Story = { + render: () => ( + + {Object.keys(AvatarGroupVariant).map((variantKey) => ( + + ))} + + ), +}; + +export const Sizes: Story = { + render: () => ( + + {Object.keys(AvatarGroupSize).map((sizeKey) => ( + + + + + + + ))} + + ), +}; + +export const IsReverse: Story = { + render: () => ( + + + + + + + + + + + + + + + + + + + ), }; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx index b52134e8b..3dddf847f 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx @@ -1,75 +1,289 @@ -// External dependencies -import React from 'react'; import { render } from '@testing-library/react-native'; -// Internal dependencies +import Text from '../../components/Text'; +import { + MAP_AVATARBASE_SIZE_BORDERWIDTH, + TWCLASSMAP_AVATARBASE_SIZE_SHAPE, +} from '../../primitives/AvatarBase/AvatarBase.constants'; +import { AvatarGroupSize } from '../../shared/enums'; +import { + generateAvatarGroupContainerClassNames, + generateAvatarGroupOverflowTextContainerClassNames, +} from './AvatarGroup.utilities'; import AvatarGroup from './AvatarGroup'; import { - SAMPLE_AVATARGROUP_PROPS, - AVATARGROUP_AVATAR_CONTAINER_TESTID, - AVATARGROUP_AVATAR_TESTID, - AVATARGROUP_OVERFLOWCOUNTER_TESTID, + MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS, + DEFAULT_AVATARGROUP_PROPS, + SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR, + SAMPLE_AVATARGROUP_AVATARFAVICONPROPSARR, + SAMPLE_AVATARGROUP_AVATARNETWORKPROPSARR, + SAMPLE_AVATARGROUP_AVATARTOKENPROPSARR, } from './AvatarGroup.constants'; +import { AvatarGroupVariant } from './AvatarGroup.types'; describe('AvatarGroup', () => { - const renderComponent = (props = {}) => - render(); + describe('generateAvatarGroupContainerClassNames', () => { + it('returns correct class names for default state', () => { + const classNames = generateAvatarGroupContainerClassNames({}); + expect(classNames).toContain('flex-row'); + expect(classNames).toContain( + `gap-[${MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[DEFAULT_AVATARGROUP_PROPS.size]}px]`, + ); + }); - it('should render AvatarGroup component', () => { - const { toJSON } = renderComponent(); - expect(toJSON()).toMatchSnapshot(); - }); + it('applies reverse row style when isReverse is true', () => { + const classNames = generateAvatarGroupContainerClassNames({ + isReverse: true, + }); + expect(classNames).toContain('flex-row-reverse'); + }); - it('should render the correct number of avatars', () => { - const { getAllByTestId } = renderComponent(); - const avatars = getAllByTestId(AVATARGROUP_AVATAR_TESTID); - expect(avatars.length).toBe(SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars); - }); + it('applies correct spacing for given size', () => { + Object.values(AvatarGroupSize).forEach((size) => { + const expectedGap = `gap-[${MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]}px]`; + const classNames = generateAvatarGroupContainerClassNames({ size }); + expect(classNames).toContain(expectedGap); + }); + }); - it('should render the overflow counter when there are more avatars than the max limit', () => { - const { getByTestId } = renderComponent(); - const overflowCounter = getByTestId(AVATARGROUP_OVERFLOWCOUNTER_TESTID); - expect(overflowCounter).toBeDefined(); - const overflowCounterNumber = SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars - ? SAMPLE_AVATARGROUP_PROPS.avatarPropsList.length - - SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars - : SAMPLE_AVATARGROUP_PROPS.avatarPropsList.length; - expect(overflowCounter.props.children).toBe(`+${overflowCounterNumber}`); + it('appends additional Tailwind class names', () => { + const classNames = generateAvatarGroupContainerClassNames({ + twClassName: 'justify-start', + }); + expect(classNames).toContain('justify-start'); + }); + + it('applies all styles together correctly', () => { + const size = AvatarGroupSize.Sm; + const classNames = generateAvatarGroupContainerClassNames({ + size, + isReverse: true, + twClassName: 'items-center', + }); + expect(classNames).toContain('flex-row-reverse'); + expect(classNames).toContain( + `gap-[${MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]}px]`, + ); + expect(classNames).toContain('items-center'); + }); }); + describe('generateAvatarGroupOverflowTextContainerClassNames', () => { + it('returns correct class names for default state', () => { + const classNames = generateAvatarGroupOverflowTextContainerClassNames({}); + expect(classNames).toContain( + 'bg-icon-default items-center justify-center overflow-hidden', + ); + expect(classNames).toContain( + `w-[${Number(DEFAULT_AVATARGROUP_PROPS.size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[DEFAULT_AVATARGROUP_PROPS.size] * 2}px]`, + ); + expect(classNames).toContain('border-background-default'); + expect(classNames).toContain( + `border-[${MAP_AVATARBASE_SIZE_BORDERWIDTH[DEFAULT_AVATARGROUP_PROPS.size]}px]`, + ); + expect(classNames).toContain('rounded-full'); // Default shape + }); + + it('applies correct size and border width for given size', () => { + Object.values(AvatarGroupSize).forEach((size) => { + const expectedTotalSize = + Number(size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[size] * 2; + const expectedBorderWidth = MAP_AVATARBASE_SIZE_BORDERWIDTH[size]; - it('should not render the overflow counter when there are fewer or equal avatars than the max limit', () => { - const { queryByTestId } = renderComponent({ - avatarPropsList: SAMPLE_AVATARGROUP_PROPS.avatarPropsList.slice( - 0, - SAMPLE_AVATARGROUP_PROPS.maxStackedAvatars, - ), + const classNames = generateAvatarGroupOverflowTextContainerClassNames({ + size, + }); + expect(classNames).toContain(`w-[${expectedTotalSize}px]`); + expect(classNames).toContain(`h-[${expectedTotalSize}px]`); + expect(classNames).toContain(`border-[${expectedBorderWidth}px]`); + }); + }); + + it('applies correct border radius for network variant', () => { + Object.values(AvatarGroupSize).forEach((size) => { + const expectedBorderRadius = TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]; + + const classNames = generateAvatarGroupOverflowTextContainerClassNames({ + size, + variant: AvatarGroupVariant.Network, + }); + expect(classNames).toContain(expectedBorderRadius); + }); + }); + + it('applies rounded-full for non-network variants', () => { + const classNames = generateAvatarGroupOverflowTextContainerClassNames({ + variant: AvatarGroupVariant.Token, + }); + expect(classNames).toContain('rounded-full'); + }); + + it('applies all styles together correctly', () => { + const size = AvatarGroupSize.Lg; + const expectedTotalSize = + Number(size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[size] * 2; + const expectedBorderWidth = MAP_AVATARBASE_SIZE_BORDERWIDTH[size]; + const expectedBorderRadius = TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]; + + const classNames = generateAvatarGroupOverflowTextContainerClassNames({ + size, + variant: AvatarGroupVariant.Network, + }); + + expect(classNames).toContain( + 'bg-icon-default items-center justify-center overflow-hidden', + ); + expect(classNames).toContain(`w-[${expectedTotalSize}px]`); + expect(classNames).toContain(`h-[${expectedTotalSize}px]`); + expect(classNames).toContain(`border-[${expectedBorderWidth}px]`); + expect(classNames).toContain(expectedBorderRadius); }); - const overflowCounter = queryByTestId(AVATARGROUP_OVERFLOWCOUNTER_TESTID); - expect(overflowCounter).toBeNull(); }); + describe('AvatarGroup Component', () => { + it('renders with default props and no overflow', () => { + const { getByTestId, queryByTestId } = render( + , + ); + + const container = getByTestId('avatar-group'); + expect(container).toBeDefined(); + + const overflowText = queryByTestId('avatar-overflow-text'); + expect(overflowText).toBeNull(); + + expect(container.children.length).toStrictEqual( + SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR.length, + ); + }); - it('should apply the correct space between avatars if custom spaceBetweenAvatars was given', () => { - const spaceBetweenAvatars = -10; - - const { getByTestId } = renderComponent({ - spaceBetweenAvatars, - }); - - const firstAvatarContainer = getByTestId( - `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-0`, - ); - const secondAvatarContainer = getByTestId( - `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-1`, - ); - const thirdAvatarContainer = getByTestId( - `${AVATARGROUP_AVATAR_CONTAINER_TESTID}-2`, - ); - expect(firstAvatarContainer.props.style.marginLeft).toBe(0); - expect(secondAvatarContainer.props.style.marginLeft).toBe( - spaceBetweenAvatars, - ); - expect(thirdAvatarContainer.props.style.marginLeft).toBe( - spaceBetweenAvatars, - ); + it('renders overflow if array length exceeds `max`', () => { + const { getByTestId } = render( + , + ); + const container = getByTestId('avatar-group'); + // 4 Avatars + 1 OverflowText + expect(container.children.length).toStrictEqual( + DEFAULT_AVATARGROUP_PROPS.max + 1, + ); + + const overflowText = getByTestId('avatar-overflow-text'); + expect(overflowText).toBeDefined(); + expect(overflowText.props.children).toStrictEqual( + `+${SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR.length - DEFAULT_AVATARGROUP_PROPS.max}`, + ); + }); + + it('renders the Account variant correctly', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId('avatar-group'); + expect(container).toBeDefined(); + + expect(container.children.length).toStrictEqual( + SAMPLE_AVATARGROUP_AVATARACCOUNTPROPSARR.length, + ); + }); + + it('renders the Favicon variant correctly', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId('avatar-group'); + expect(container).toBeDefined(); + + expect(container.children.length).toStrictEqual( + SAMPLE_AVATARGROUP_AVATARFAVICONPROPSARR.length, + ); + }); + + it('renders the Network variant correctly', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId('avatar-group'); + expect(container).toBeDefined(); + + expect(container.children.length).toStrictEqual( + SAMPLE_AVATARGROUP_AVATARNETWORKPROPSARR.length, + ); + }); + + it('renders the Token variant correctly', () => { + const { getByTestId } = render( + , + ); + + const container = getByTestId('avatar-group'); + expect(container).toBeDefined(); + + expect(container.children.length).toStrictEqual( + SAMPLE_AVATARGROUP_AVATARTOKENPROPSARR.length, + ); + }); + + it('throws an error for an invalid variant', () => { + const invalidVariant = 'InvalidVariant' as unknown as AvatarGroupVariant; + + expect(() => + render( + , + ), + ).toThrow('Invalid Avatar Variant: InvalidVariant.'); + }); + + it('applies custom style and passes additional props to container', () => { + const customStyle = { margin: 20 }; + const { getByTestId } = render( + , + ); + const container = getByTestId('avatar-group'); + expect(container.props.style[1]).toStrictEqual(customStyle); + + expect(container.props.accessibilityLabel).toStrictEqual( + 'avatar-group-container', + ); + }); }); }); diff --git a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx index f00c164ec..eda234cc8 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx @@ -1,16 +1,21 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View } from 'react-native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import AvatarAccount, { AvatarAccountProps } from '../AvatarAccount'; import AvatarFavicon, { AvatarFaviconProps } from '../AvatarFavicon'; -import AvatarIcon, { AvatarIconProps } from '../AvatarIcon'; +import AvatarNetwork, { AvatarNetworkProps } from '../AvatarNetwork'; +import AvatarToken, { AvatarTokenProps } from '../AvatarToken'; import Text, { TextColor } from '../Text'; import { - MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS, MAP_AVATARGROUP_SIZE_OVERFLOWTEXT_TEXTVARIANT, DEFAULT_AVATARGROUP_PROPS, } from './AvatarGroup.constants'; import { AvatarGroupProps, AvatarGroupVariant } from './AvatarGroup.types'; +import { + generateAvatarGroupContainerClassNames, + generateAvatarGroupOverflowTextContainerClassNames, +} from './AvatarGroup.utilities'; const AvatarGroup = ({ variant, @@ -24,14 +29,36 @@ const AvatarGroup = ({ }: AvatarGroupProps) => { const tw = useTailwind(); const overflowCounter = avatarPropsArr.length - max; - const avatarNegativeSpacing = MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]; const shouldRenderOverflowCounter = overflowCounter > 0; + const twContainerClassNames = useMemo(() => { + return generateAvatarGroupContainerClassNames({ + size, + isReverse, + twClassName, + }); + }, [size, isReverse, twClassName]); + const twOverflowTextContainerClassNames = useMemo(() => { + return generateAvatarGroupOverflowTextContainerClassNames({ + size, + variant, + }); + }, [size, variant]); const renderAvatarList = useCallback( () => avatarPropsArr.slice(0, max).map((avatarProps, index) => { - const marginLeft = index === 0 ? 0 : avatarNegativeSpacing; switch (variant) { + case AvatarGroupVariant.Account: + return ( + + ); case AvatarGroupVariant.Favicon: return ( + ); + case AvatarGroupVariant.Network: + return ( + ); - case AvatarGroupVariant.Icon: + case AvatarGroupVariant.Token: return ( - ); default: @@ -56,22 +98,14 @@ const AvatarGroup = ({ ); } }), - [avatarPropsArr, avatarNegativeSpacing, max, size], + [avatarPropsArr, max, size, tw], ); return ( - + {renderAvatarList()} {shouldRenderOverflowCounter && ( - + ): string => { - const baseStyle = 'items-center justify-center overflow-hidden'; - const fallbackBackgroundStyle = 'bg-background-muted'; - const sizeStyle = `h-[${size}px] w-[${size}px]`; - const shapeStyle = - shape === AvatarBaseShape.Circle - ? 'rounded-full' - : TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]; +}: Partial): string => { + const rowStyle = isReverse ? 'flex-row-reverse' : 'flex-row'; + const gapStyle = `gap-[${MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]}px]`; + + return `${rowStyle} ${gapStyle} ${twClassName}`.trim(); +}; - const mergedClassnames = `${baseStyle} ${fallbackBackgroundStyle} ${sizeStyle} ${shapeStyle} ${twClassName}`; - return mergedClassnames; +/** + * Generates a Tailwind class name string for the overflow text container in an avatar group. + * + * This function constructs a class name string based on the avatar group's `size`, + * `variant`, and default styling rules. + * + * @param size - The size of the avatar group, determining dimensions and border width. + * @param variant - The variant of the avatar group, affecting border radius. + * @returns A string of Tailwind class names representing the avatar group's overflow text container styles. + * + * Example: + * ``` + * const classNames = generateAvatarGroupOverflowTextContainerClassNames({ + * size: 48, + * variant: 'network', + * }); + * + * console.log(classNames); + * // Output: "bg-icon-default items-center justify-center overflow-hidden w-[52px] h-[52px] rounded-md border-background-default border-[2px]" + * ``` + */ +export const generateAvatarGroupOverflowTextContainerClassNames = ({ + size = DEFAULT_AVATARGROUP_PROPS.size, + variant, +}: Partial): string => { + const baseStyle = + 'bg-icon-default items-center justify-center overflow-hidden'; + const totalSize = Number(size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[size] * 2; + const sizeStyle = `w-[${totalSize}px] h-[${totalSize}px]`; + const borderColorStyle = 'border-background-default'; + const borderWidthStyle = `border-[${MAP_AVATARBASE_SIZE_BORDERWIDTH[size]}px]`; + const borderRadiusStyle = + variant === AvatarGroupVariant.Network + ? TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size] + : 'rounded-full'; + return `${baseStyle} ${sizeStyle} ${borderRadiusStyle} ${borderColorStyle} ${borderWidthStyle}`; }; diff --git a/packages/design-system-react-native/src/components/AvatarGroup/README.md b/packages/design-system-react-native/src/components/AvatarGroup/README.md index 98658b414..915e547df 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/README.md +++ b/packages/design-system-react-native/src/components/AvatarGroup/README.md @@ -1,95 +1,262 @@ # AvatarGroup -AvatarGroup is a component that horizontally stacks multiple Avatar components. +The `AvatarGroup` component is designed to display a collection of avatars in a compact and structured layout. It supports different avatar variants and provides an overflow indicator when the number of avatars exceeds a specified limit. -## AvatarGroup Props +--- -This component extends React Native's [ViewProps](https://reactnative.dev/docs/view) component. +## Props -### `avatarPropsList` +### `variant` (Required) -A list of Avatars to be horizontally stacked. -Note: AvatarGroup's `size` and `includesBorder` prop will overwrite each individual avatar's `size` and `includesBorder` prop. +Determines the type of avatars used within the group. -| TYPE | REQUIRED | -| :-------------------------------------------------- | :------------------------------------------------------ | -| `AvatarProps[]` | Yes | +| TYPE | REQUIRED | DEFAULT | +| :------------------- | :------- | :------ | +| `AvatarGroupVariant` | Yes | `N/A` | + +Available variants: + +- `Account` +- `Favicon` +- `Network` +- `Token` + +--- + +### `avatarPropsArr` (Required) + +An array of props for each avatar within the group. + +| TYPE | REQUIRED | DEFAULT | +| :------------------- | :------- | :------ | +| `Array` | Yes | `N/A` | + +Each avatar follows the prop structure of the corresponding variant component (`AvatarAccount`, `AvatarFavicon`, `AvatarNetwork`, `AvatarToken`). + +--- ### `size` -Optional enum to select between Avatar Group sizes. +Optional prop to control the size of the avatars in the group. + +| TYPE | REQUIRED | DEFAULT | +| :----------- | :------- | :-------------- | +| `AvatarSize` | No | `AvatarSize.Md` | + +Available sizes: + +- `AvatarSize.Xs` +- `AvatarSize.Sm` +- `AvatarSize.Md` +- `AvatarSize.Lg` +- `AvatarSize.Xl` + +--- + +### `max` -| TYPE | REQUIRED | DEFAULT | -| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | -| [`AvatarSize`](../Avatar/Avatar.types.ts) | No | `AvatarSize.Xs` | +Determines the maximum number of avatars to display before showing an overflow indicator. -### `maxStackedAvatars` +| TYPE | REQUIRED | DEFAULT | +| :------- | :------- | :------ | +| `number` | No | `4` | -Optional enum to select the maximum number of Avatars visible before the overflow counter is displayed. +--- -| TYPE | REQUIRED | DEFAULT | -| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | -| `number` | No | 4 | +### `isReverse` -### `includesBorder` +Optional prop to reverse the order of avatar stacking. -Optional boolean to include a border or not. +| TYPE | REQUIRED | DEFAULT | +| :-------- | :------- | :------ | +| `boolean` | No | `false` | -| TYPE | REQUIRED | DEFAULT | -| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | -| `boolean` | No | `false` | +--- -### `spaceBetweenAvatars` +### `twClassName` -Optional enum to configure the space between avatars. -Note: +Optional prop to add `twrnc` overriding class names. -- Negative values for this prop will result in the Avatars moving closer to each other, positive values for this prop will result in the Avatars moving away from each other. -- The default values of the space between avatars depend on the size. -- Please refer to the constants file for the mappings. +| TYPE | REQUIRED | DEFAULT | +| :------- | :------- | :------ | +| `string` | No | `''` | -| TYPE | REQUIRED | -| :-------------------------------------------------- | :------------------------------------------------------ | -| `number` | No | +--- + +### `style` + +Optional prop to control the style of the avatar group container. + +| TYPE | REQUIRED | DEFAULT | +| :--------------------- | :------- | :------ | +| `StyleProp` | No | `null` | + +--- ## Usage -```javascript -// Passing list of AvatarProps to avatarPropsList -const avatarPropsList: AvatarProps[] = [ - { - variant: AvatarVariant.Network, - name: SAMPLE_AVATARNETWORK_NAME, - imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, - }, - { - variant: AvatarVariant.Network, - name: SAMPLE_AVATARNETWORK_NAME, - imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, - }, - { - variant: AvatarVariant.Network, - name: SAMPLE_AVATARNETWORK_NAME, - imageSource: SAMPLE_AVATARNETWORK_IMAGESOURCE_REMOTE, - }, -]; -; - -// Configuring different Avatar sizes -; -; -; -; -; - -// Configuring max number of stacked Avatars -; - -// Configuring border inclusion -; -; - -// Configuring space between Avatars -; -; +Below are examples illustrating how to structure the `avatarPropsArr` based on each avatar variant. Note that the data shown is purely illustrative. + +### Account Avatars + +```tsx +import React from 'react'; +import AvatarGroup, { + AvatarGroupVariant, +} from '@metamask/design-system-react-native'; +import { AvatarAccountVariant } from '@metamask/design-system-react-native'; + +; +``` + +--- + +### Favicon Avatars + +```tsx +import React from 'react'; +import AvatarGroup, { + AvatarGroupVariant, +} from '@metamask/design-system-react-native'; + +; +``` + +--- + +### Network Avatars + +```tsx +import React from 'react'; +import AvatarGroup, { + AvatarGroupVariant, +} from '@metamask/design-system-react-native'; + +; +``` + +--- + +### Token Avatars + +```tsx +import React from 'react'; +import AvatarGroup, { + AvatarGroupVariant, +} from '@metamask/design-system-react-native'; + +; +``` + +--- + +### Displaying More Avatars with Overflow + +```tsx + ``` + +If more than `max` avatars are provided, an overflow counter (e.g., `+1`) will be displayed. + +--- + +### Changing Avatar Size + +```tsx +import AvatarGroup, { + AvatarGroupVariant, + AvatarGroupSize, +} from '@metamask/design-system-react-native'; +; +``` + +--- + +## Notes + +- `AvatarGroup` ensures consistent avatar alignment and spacing. +- Overflow avatars are indicated with a counter. +- It supports different avatar types based on the selected variant. + +--- + +## Contributing + +1. Add tests for new features. +2. Update this README for any changes to the API. +3. Follow the design system's coding guidelines. + +--- + +For questions, refer to the [React Native documentation](https://reactnative.dev/docs) or contact the maintainers of the design system. diff --git a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.constants.ts b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.constants.ts index 7549c20d0..38aa64c62 100644 --- a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.constants.ts +++ b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.constants.ts @@ -10,3 +10,14 @@ export const DEFAULT_AVATARNETWORK_PROPS: Required< width: '100%', height: '100%', }; + +// Sample Network URIs +export const SAMPLE_AVATARNETWORK_URIS = [ + 'https://cryptologos.cc/logos/cardano-ada-logo.svg', + 'https://cryptologos.cc/logos/chainlink-link-logo.svg', + 'https://cryptologos.cc/logos/uniswap-uni-logo.svg', + 'https://cryptologos.cc/logos/flare-flr-logo.svg', + 'https://cryptologos.cc/logos/ethereum-eth-logo.svg', + 'https://cryptologos.cc/logos/solana-sol-logo.svg', + 'https://cryptologos.cc/logos/tether-usdt-logo.svg', +]; diff --git a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx index d1c020d4d..4140dd61d 100644 --- a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx +++ b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx @@ -2,7 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ImageSourcePropType, View } from 'react-native'; import AvatarNetwork from './AvatarNetwork'; -import { DEFAULT_AVATARNETWORK_PROPS } from './AvatarNetwork.constants'; +import { + DEFAULT_AVATARNETWORK_PROPS, + SAMPLE_AVATARNETWORK_URIS, +} from './AvatarNetwork.constants'; import type { AvatarNetworkProps } from './AvatarNetwork.types'; import { AvatarSize } from '../../shared/enums'; @@ -24,7 +27,7 @@ export default meta; type Story = StoryObj; const storyImageSource: ImageSourcePropType = { - uri: 'https://cryptologos.cc/logos/ethereum-eth-logo.svg', + uri: SAMPLE_AVATARNETWORK_URIS[0], }; export const Default: Story = { @@ -48,3 +51,18 @@ export const Sizes: Story = { ), }; + +export const SampleNetworks: Story = { + render: () => ( + + {SAMPLE_AVATARNETWORK_URIS.map((networkUri) => ( + + ))} + + ), +}; diff --git a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.test.tsx b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.test.tsx index 238d66edf..ede4ff0ed 100644 --- a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.test.tsx +++ b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.test.tsx @@ -52,8 +52,7 @@ describe('AvatarNetwork Component', () => { expect(onImageErrorMock).toHaveBeenCalledWith(errorEvent); const avatarBase = getByTestId('avatar-base'); - - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('updates fallback text on svg error when fallbackText is provided', () => { @@ -76,7 +75,7 @@ describe('AvatarNetwork Component', () => { expect(onSvgErrorMock).toHaveBeenCalledTimes(1); expect(onSvgErrorMock).toHaveBeenCalledWith(errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('computes backupFallbackText from name when fallbackText is not provided', () => { @@ -96,7 +95,7 @@ describe('AvatarNetwork Component', () => { fireEvent(imageOrSvg, 'onImageError', errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual('E'); + expect(avatarBase.props.children[1].props.children).toStrictEqual('E'); }); it('passes additional AvatarBase props correctly', () => { diff --git a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.tsx b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.tsx index 498a153b0..b0be693a4 100644 --- a/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.tsx +++ b/packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.tsx @@ -12,6 +12,8 @@ const AvatarNetwork = ({ shape = DEFAULT_AVATARNETWORK_PROPS.shape, fallbackText, fallbackTextProps, + hasBorder, + hasSolidBackgroundColor, twClassName, testID, style, @@ -44,6 +46,8 @@ const AvatarNetwork = ({ shape={shape} fallbackText={finalFallbackText} fallbackTextProps={fallbackTextProps} + hasBorder={hasBorder} + hasSolidBackgroundColor={hasSolidBackgroundColor} twClassName={twClassName} testID={testID} style={style} diff --git a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.constants.ts b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.constants.ts index 914ceeab4..b58ec0fe2 100644 --- a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.constants.ts +++ b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.constants.ts @@ -10,3 +10,14 @@ export const DEFAULT_AVATARTOKEN_PROPS: Required< width: '100%', height: '100%', }; + +// Sample Token URIs +export const SAMPLE_AVATARTOKEN_URIS = [ + 'https://cryptologos.cc/logos/ethereum-eth-logo.svg', + 'https://cryptologos.cc/logos/solana-sol-logo.svg', + 'https://cryptologos.cc/logos/tether-usdt-logo.svg', + 'https://cryptologos.cc/logos/cardano-ada-logo.svg', + 'https://cryptologos.cc/logos/chainlink-link-logo.svg', + 'https://cryptologos.cc/logos/uniswap-uni-logo.svg', + 'https://cryptologos.cc/logos/flare-flr-logo.svg', +]; diff --git a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx index 41003ff40..204fd0443 100644 --- a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx +++ b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx @@ -2,7 +2,10 @@ import type { Meta, StoryObj } from '@storybook/react-native'; import { ImageSourcePropType, View } from 'react-native'; import AvatarToken from './AvatarToken'; -import { DEFAULT_AVATARTOKEN_PROPS } from './AvatarToken.constants'; +import { + DEFAULT_AVATARTOKEN_PROPS, + SAMPLE_AVATARTOKEN_URIS, +} from './AvatarToken.constants'; import type { AvatarTokenProps } from './AvatarToken.types'; import { AvatarSize } from '../../shared/enums'; @@ -24,7 +27,7 @@ export default meta; type Story = StoryObj; const storyImageSource: ImageSourcePropType = { - uri: 'https://cryptologos.cc/logos/ethereum-eth-logo.svg', + uri: SAMPLE_AVATARTOKEN_URIS[0], }; export const Default: Story = { @@ -48,3 +51,18 @@ export const Sizes: Story = { ), }; + +export const SampleTokens: Story = { + render: () => ( + + {SAMPLE_AVATARTOKEN_URIS.map((tokenUri) => ( + + ))} + + ), +}; diff --git a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.test.tsx b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.test.tsx index cf18bd733..22adba498 100644 --- a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.test.tsx +++ b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.test.tsx @@ -53,7 +53,7 @@ describe('AvatarToken Component', () => { const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('updates fallback text on svg error when fallbackText is provided', () => { @@ -76,7 +76,7 @@ describe('AvatarToken Component', () => { expect(onSvgErrorMock).toHaveBeenCalledTimes(1); expect(onSvgErrorMock).toHaveBeenCalledWith(errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual(fallback); + expect(avatarBase.props.children[1].props.children).toStrictEqual(fallback); }); it('computes backupFallbackText from name when fallbackText is not provided', () => { @@ -96,7 +96,7 @@ describe('AvatarToken Component', () => { fireEvent(imageOrSvg, 'onImageError', errorEvent); const avatarBase = getByTestId('avatar-base'); - expect(avatarBase.props.children.props.children).toStrictEqual('E'); + expect(avatarBase.props.children[1].props.children).toStrictEqual('E'); }); it('passes additional AvatarBase props correctly', () => { diff --git a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.tsx b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.tsx index ba58b55a2..efa964f69 100644 --- a/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.tsx +++ b/packages/design-system-react-native/src/components/AvatarToken/AvatarToken.tsx @@ -12,6 +12,8 @@ const AvatarToken = ({ shape = DEFAULT_AVATARTOKEN_PROPS.shape, fallbackText, fallbackTextProps, + hasBorder, + hasSolidBackgroundColor, twClassName, testID, style, @@ -44,6 +46,8 @@ const AvatarToken = ({ shape={shape} fallbackText={finalFallbackText} fallbackTextProps={fallbackTextProps} + hasBorder={hasBorder} + hasSolidBackgroundColor={hasSolidBackgroundColor} twClassName={twClassName} testID={testID} style={style} diff --git a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.constants.ts b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.constants.ts index affd2e1a2..9bb1702fb 100644 --- a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.constants.ts +++ b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.constants.ts @@ -11,10 +11,24 @@ export const TWCLASSMAP_AVATARBASE_SIZE_SHAPE: Record = [AvatarBaseSize.Lg]: 'rounded-[10px]', [AvatarBaseSize.Xl]: 'rounded-xl', }; +export const MAP_AVATARBASE_SIZE_BORDERWIDTH: Record = { + [AvatarBaseSize.Xs]: 1, + [AvatarBaseSize.Sm]: 1, + [AvatarBaseSize.Md]: 1, + [AvatarBaseSize.Lg]: 2, + [AvatarBaseSize.Xl]: 2, +}; // Defaults export const DEFAULT_AVATARBASE_PROPS: Required< - Pick + Pick< + AvatarBaseProps, + | 'size' + | 'shape' + | 'fallbackTextProps' + | 'hasBorder' + | 'hasSolidBackgroundColor' + > > = { size: AvatarBaseSize.Md, shape: AvatarBaseShape.Circle, @@ -24,4 +38,6 @@ export const DEFAULT_AVATARBASE_PROPS: Required< fontWeight: FontWeight.Medium, twClassName: 'uppercase', }, + hasBorder: false, + hasSolidBackgroundColor: false, }; diff --git a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.test.tsx b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.test.tsx index c500303e0..1ecc8d998 100644 --- a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.test.tsx +++ b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.test.tsx @@ -6,6 +6,7 @@ import { generateAvatarBaseContainerClassNames } from './AvatarBase.utilities'; import { DEFAULT_AVATARBASE_PROPS, TWCLASSMAP_AVATARBASE_SIZE_SHAPE, + MAP_AVATARBASE_SIZE_BORDERWIDTH, } from './AvatarBase.constants'; import AvatarBase from './AvatarBase'; @@ -16,10 +17,11 @@ describe('AvatarBase', () => { expect(classNames).toContain( 'items-center justify-center overflow-hidden', ); - expect(classNames).toContain('bg-background-muted'); + expect(classNames).toContain('bg-background-default'); expect(classNames).toContain(`h-[${DEFAULT_AVATARBASE_PROPS.size}px]`); expect(classNames).toContain(`w-[${DEFAULT_AVATARBASE_PROPS.size}px]`); expect(classNames).toContain('rounded-full'); // Default shape + expect(classNames).not.toContain('border'); // No border by default }); it('applies correct shape class when circle', () => { @@ -48,6 +50,22 @@ describe('AvatarBase', () => { }); }); + it('applies correct size styles with border', () => { + Object.values(AvatarBaseSize).forEach((size) => { + const borderWidth = MAP_AVATARBASE_SIZE_BORDERWIDTH[size]; + const expectedSize = borderWidth * 2 + Number(size); + const classNames = generateAvatarBaseContainerClassNames({ + size, + hasBorder: true, + }); + expect(classNames).toContain(`h-[${expectedSize}px]`); + expect(classNames).toContain(`w-[${expectedSize}px]`); + expect(classNames).toContain( + `border-background-default border-[${borderWidth}px]`, + ); + }); + }); + it('appends additional Tailwind class names', () => { const classNames = generateAvatarBaseContainerClassNames({ twClassName: 'shadow-lg ring-2', @@ -56,19 +74,24 @@ describe('AvatarBase', () => { }); it('applies all styles together correctly', () => { + const size = AvatarBaseSize.Lg; + const borderWidth = MAP_AVATARBASE_SIZE_BORDERWIDTH[size]; + const expectedSize = borderWidth * 2 + Number(size); const classNames = generateAvatarBaseContainerClassNames({ - size: AvatarBaseSize.Md, + size, shape: AvatarBaseShape.Square, + hasBorder: true, twClassName: 'border border-blue-500', }); expect(classNames).toContain( 'items-center justify-center overflow-hidden', ); - expect(classNames).toContain('bg-background-muted'); - expect(classNames).toContain(`h-[${AvatarBaseSize.Md}px]`); - expect(classNames).toContain(`w-[${AvatarBaseSize.Md}px]`); + expect(classNames).toContain('bg-background-default'); + expect(classNames).toContain(`h-[${expectedSize}px]`); + expect(classNames).toContain(`w-[${expectedSize}px]`); + expect(classNames).toContain(TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]); expect(classNames).toContain( - TWCLASSMAP_AVATARBASE_SIZE_SHAPE[AvatarBaseSize.Md], + `border-background-default border-[${borderWidth}px]`, ); expect(classNames).toContain('border border-blue-500'); }); diff --git a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.tsx b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.tsx index 74cf7a384..d2ecb7787 100644 --- a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.tsx +++ b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.tsx @@ -14,6 +14,8 @@ const AvatarBase = ({ shape = DEFAULT_AVATARBASE_PROPS.shape, fallbackText, fallbackTextProps, + hasBorder = DEFAULT_AVATARBASE_PROPS.hasBorder, + hasSolidBackgroundColor = DEFAULT_AVATARBASE_PROPS.hasSolidBackgroundColor, twClassName = '', style, ...props @@ -23,12 +25,16 @@ const AvatarBase = ({ return generateAvatarBaseContainerClassNames({ size, shape, + hasBorder, twClassName, }); - }, [size, shape, twClassName]); + }, [size, shape, hasBorder, twClassName]); return ( + {fallbackText ? ( ; + /** + * Optional prop to include the border with the Avatar. + * For internal use only + * @default false + */ + hasBorder?: boolean; + /** + * Optional prop to make sure there's no transparency in the background color + * For internal use only + * @default false + */ + hasSolidBackgroundColor?: boolean; /** * Optional prop to add twrnc overriding classNames. */ diff --git a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.utilities.ts b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.utilities.ts index d0e5e6703..b88a1b9c3 100644 --- a/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.utilities.ts +++ b/packages/design-system-react-native/src/primitives/AvatarBase/AvatarBase.utilities.ts @@ -2,17 +2,21 @@ /* eslint-disable jsdoc/require-param */ import { DEFAULT_AVATARBASE_PROPS } from './AvatarBase.constants'; import type { AvatarBaseProps } from './AvatarBase.types'; -import { TWCLASSMAP_AVATARBASE_SIZE_SHAPE } from './AvatarBase.constants'; +import { + TWCLASSMAP_AVATARBASE_SIZE_SHAPE, + MAP_AVATARBASE_SIZE_BORDERWIDTH, +} from './AvatarBase.constants'; import { AvatarBaseShape } from '../../shared/enums'; /** * Generates a Tailwind class name string for the base container of an avatar. * * This function constructs a class name string based on the avatar's `size`, - * `shape`, and optional additional Tailwind class names. + * `shape`, `hasBorder`, and optional additional Tailwind class names. * * @param size - The size of the avatar, defaulting to `DEFAULT_AVATARBASE_PROPS.size`. * @param shape - The shape of the avatar, either `circle` or another defined shape. + * @param hasBorder - A boolean indicating whether the avatar has a border. * @param twClassName - Additional Tailwind class names for customization. * @returns A string of Tailwind class names representing the avatar's container styles. * @@ -21,26 +25,36 @@ import { AvatarBaseShape } from '../../shared/enums'; * const classNames = generateAvatarBaseContainerClassNames({ * size: 48, * shape: 'circle', - * twClassName: 'border border-gray-500', + * hasBorder: true, + * twClassName: 'shadow-md', * }); * * console.log(classNames); - * // Output: "items-center justify-center overflow-hidden bg-background-muted h-[48px] w-[48px] rounded-full border border-gray-500" + * // Output: "items-center justify-center overflow-hidden bg-background-default h-[52px] w-[52px] rounded-full border-background-default border-[2px] shadow-md" * ``` */ export const generateAvatarBaseContainerClassNames = ({ size = DEFAULT_AVATARBASE_PROPS.size, shape = DEFAULT_AVATARBASE_PROPS.shape, + hasBorder = DEFAULT_AVATARBASE_PROPS.hasBorder, twClassName = '', }: Partial): string => { const baseStyle = 'items-center justify-center overflow-hidden'; - const fallbackBackgroundStyle = 'bg-background-muted'; - const sizeStyle = `h-[${size}px] w-[${size}px]`; + const fallbackBackgroundStyle = 'bg-background-default'; + const totalSize = hasBorder + ? Number(size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[size] * 2 + : Number(size); + Number(size) + MAP_AVATARBASE_SIZE_BORDERWIDTH[size] * 2; + const sizeStyle = `h-[${totalSize}px] w-[${totalSize}px]`; const shapeStyle = shape === AvatarBaseShape.Circle ? 'rounded-full' : TWCLASSMAP_AVATARBASE_SIZE_SHAPE[size]; + const borderStyle = hasBorder + ? `border-background-default border-[${MAP_AVATARBASE_SIZE_BORDERWIDTH[size]}px]` + : ''; - const mergedClassnames = `${baseStyle} ${fallbackBackgroundStyle} ${sizeStyle} ${shapeStyle} ${twClassName}`; + const mergedClassnames = + `${baseStyle} ${fallbackBackgroundStyle} ${sizeStyle} ${shapeStyle} ${borderStyle} ${twClassName}`.trim(); return mergedClassnames; }; From dde29f412fbed28a082334228cce2759852e6a37 Mon Sep 17 00:00:00 2001 From: Brian Nguyen Date: Wed, 5 Mar 2025 00:29:36 -0800 Subject: [PATCH 3/3] Updated indexes --- .../src/components/AvatarGroup/index.ts | 3 +++ packages/design-system-react-native/src/index.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/design-system-react-native/src/components/AvatarGroup/index.ts b/packages/design-system-react-native/src/components/AvatarGroup/index.ts index 20c5443b2..4be35874a 100644 --- a/packages/design-system-react-native/src/components/AvatarGroup/index.ts +++ b/packages/design-system-react-native/src/components/AvatarGroup/index.ts @@ -1 +1,4 @@ export { default } from './AvatarGroup'; +export type { AvatarGroupProps } from './AvatarGroup.types'; +export { AvatarGroupSize } from '../../shared/enums'; +export { AvatarGroupVariant } from './AvatarGroup.types'; diff --git a/packages/design-system-react-native/src/index.ts b/packages/design-system-react-native/src/index.ts index ff959ac71..4178c86d4 100644 --- a/packages/design-system-react-native/src/index.ts +++ b/packages/design-system-react-native/src/index.ts @@ -19,6 +19,14 @@ export { AvatarFaviconSize, } from './components/AvatarFavicon'; +import AvatarGroupComponent from './components/AvatarGroup'; +export const AvatarGroup = withThemeProvider(AvatarGroupComponent); +export { + AvatarGroupProps, + AvatarGroupSize, + AvatarGroupVariant, +} from './components/AvatarGroup'; + import AvatarIconComponent from './components/AvatarIcon'; export const AvatarIcon = withThemeProvider(AvatarIconComponent); export {