diff --git a/apps/storybook-react-native/.storybook/storybook.requires.js b/apps/storybook-react-native/.storybook/storybook.requires.js index 4c3bfecab..62310b11c 100644 --- a/apps/storybook-react-native/.storybook/storybook.requires.js +++ b/apps/storybook-react-native/.storybook/storybook.requires.js @@ -49,6 +49,7 @@ const getStories = () => { return { "./../../packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarAccount/AvatarAccount.stories.tsx"), "./../../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/AvatarNetwork/AvatarNetwork.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarNetwork/AvatarNetwork.stories.tsx"), "./../../packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx": require("../../../packages/design-system-react-native/src/components/AvatarToken/AvatarToken.stories.tsx"), 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 new file mode 100644 index 000000000..7a3503eea --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.constants.ts @@ -0,0 +1,188 @@ +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< + 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, +}; + +// 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 new file mode 100644 index 000000000..a7831df49 --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.stories.tsx @@ -0,0 +1,158 @@ +import { ScrollView, View } from 'react-native'; +import type { Meta, StoryObj } from '@storybook/react-native'; + +import { AvatarGroupSize } from '../../shared/enums'; +import AvatarGroup from './AvatarGroup'; +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'; + +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 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: { + variant: AvatarGroupVariant.Favicon, + size: DEFAULT_AVATARGROUP_PROPS.size, + max: DEFAULT_AVATARGROUP_PROPS.max, + isReverse: DEFAULT_AVATARGROUP_PROPS.isReverse, + twClassName: '', + }, + 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 new file mode 100644 index 000000000..3dddf847f --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.test.tsx @@ -0,0 +1,289 @@ +import { render } from '@testing-library/react-native'; + +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 { + 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', () => { + 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('applies reverse row style when isReverse is true', () => { + const classNames = generateAvatarGroupContainerClassNames({ + isReverse: true, + }); + expect(classNames).toContain('flex-row-reverse'); + }); + + 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('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]; + + 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); + }); + }); + 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('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 new file mode 100644 index 000000000..eda234cc8 --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.tsx @@ -0,0 +1,120 @@ +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 AvatarNetwork, { AvatarNetworkProps } from '../AvatarNetwork'; +import AvatarToken, { AvatarTokenProps } from '../AvatarToken'; +import Text, { TextColor } from '../Text'; +import { + 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, + 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 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) => { + switch (variant) { + case AvatarGroupVariant.Account: + return ( + + ); + case AvatarGroupVariant.Favicon: + return ( + + ); + case AvatarGroupVariant.Network: + return ( + + ); + case AvatarGroupVariant.Token: + return ( + + ); + default: + throw new Error( + `Invalid Avatar Variant: ${variant}. Expected one of: ${Object.values(AvatarGroupVariant).join(', ')}`, + ); + } + }), + [avatarPropsArr, max, size, tw], + ); + + 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..8fdc72bba --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.types.ts @@ -0,0 +1,78 @@ +import { ViewProps } from 'react-native'; + +import { AvatarGroupSize } from '../../shared/enums'; +import { AvatarAccountProps } from '../AvatarAccount'; +import { AvatarFaviconProps } from '../AvatarFavicon'; +import { AvatarNetworkProps } from '../AvatarNetwork'; +import { AvatarTokenProps } from '../AvatarToken'; + +/** + * AvatarGroup variants. + */ +export enum AvatarGroupVariant { + Account = 'Account', + Favicon = 'Favicon', + Network = 'Network', + Token = 'Token', +} + +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.Account; + /** + * A list of Avatars to be horizontally stacked. + * Note: AvatarGroupProps's size prop will overwrite each individual avatarProp's size. + */ + avatarPropsArr: AvatarAccountProps[]; + } + | { + 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.Network; + /** + * A list of Avatars to be horizontally stacked. + * Note: AvatarGroupProps's size prop will overwrite each individual avatarProp's size. + */ + avatarPropsArr: AvatarNetworkProps[]; + } + | { + variant: AvatarGroupVariant.Token; + /** + * A list of Avatars to be horizontally stacked. + * Note: AvatarGroupProps's size prop will overwrite each individual avatarProp's size. + */ + avatarPropsArr: AvatarTokenProps[]; + } + ); 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..d99d9038f --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/AvatarGroup.utilities.ts @@ -0,0 +1,84 @@ +/* eslint-disable jsdoc/check-param-names */ +/* eslint-disable jsdoc/require-param */ +import { + DEFAULT_AVATARGROUP_PROPS, + MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS, +} from './AvatarGroup.constants'; +import type { AvatarGroupProps } from './AvatarGroup.types'; +import { AvatarGroupVariant } from './AvatarGroup.types'; +import { + TWCLASSMAP_AVATARBASE_SIZE_SHAPE, + MAP_AVATARBASE_SIZE_BORDERWIDTH, +} from '../../primitives/AvatarBase/AvatarBase.constants'; + +/** + * Generates a Tailwind class name string for the container of an avatar group. + * + * This function constructs a class name string based on the avatar group's `size`, + * `isReverse`, and optional additional Tailwind class names. + * + * @param size - The size of the avatar group, determining spacing between avatars. + * @param isReverse - A boolean indicating whether the avatar group should be reversed in order. + * @param twClassName - Additional Tailwind class names for customization. + * @returns A string of Tailwind class names representing the avatar group's container styles. + * + * Example: + * ``` + * const classNames = generateAvatarGroupContainerClassNames({ + * size: 'md', + * isReverse: true, + * twClassName: 'justify-center', + * }); + * + * console.log(classNames); + * // Output: "flex-row-reverse gap-[8px] justify-center" + * ``` + */ +export const generateAvatarGroupContainerClassNames = ({ + size = DEFAULT_AVATARGROUP_PROPS.size, + isReverse = DEFAULT_AVATARGROUP_PROPS.isReverse, + twClassName = '', +}: Partial): string => { + const rowStyle = isReverse ? 'flex-row-reverse' : 'flex-row'; + const gapStyle = `gap-[${MAP_AVATARGROUP_SIZE_SPACEBETWEENAVATARS[size]}px]`; + + return `${rowStyle} ${gapStyle} ${twClassName}`.trim(); +}; + +/** + * 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 new file mode 100644 index 000000000..915e547df --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/README.md @@ -0,0 +1,262 @@ +# AvatarGroup + +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. + +--- + +## Props + +### `variant` (Required) + +Determines the type of avatars used within the group. + +| 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 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` + +Determines the maximum number of avatars to display before showing an overflow indicator. + +| TYPE | REQUIRED | DEFAULT | +| :------- | :------- | :------ | +| `number` | No | `4` | + +--- + +### `isReverse` + +Optional prop to reverse the order of avatar stacking. + +| TYPE | REQUIRED | DEFAULT | +| :-------- | :------- | :------ | +| `boolean` | No | `false` | + +--- + +### `twClassName` + +Optional prop to add `twrnc` overriding class names. + +| TYPE | REQUIRED | DEFAULT | +| :------- | :------- | :------ | +| `string` | No | `''` | + +--- + +### `style` + +Optional prop to control the style of the avatar group container. + +| TYPE | REQUIRED | DEFAULT | +| :--------------------- | :------- | :------ | +| `StyleProp` | No | `null` | + +--- + +## Usage + +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/AvatarGroup/index.ts b/packages/design-system-react-native/src/components/AvatarGroup/index.ts new file mode 100644 index 000000000..4be35874a --- /dev/null +++ b/packages/design-system-react-native/src/components/AvatarGroup/index.ts @@ -0,0 +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/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/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 { 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; }; diff --git a/packages/design-system-react-native/src/shared/enums.ts b/packages/design-system-react-native/src/shared/enums.ts index 5b58db924..79979ab03 100644 --- a/packages/design-system-react-native/src/shared/enums.ts +++ b/packages/design-system-react-native/src/shared/enums.ts @@ -26,6 +26,7 @@ export enum AvatarSize { export { AvatarSize as AvatarAccountSize }; export { AvatarSize as AvatarBaseSize }; export { AvatarSize as AvatarFaviconSize }; +export { AvatarSize as AvatarGroupSize }; export { AvatarSize as AvatarIconSize }; export { AvatarSize as AvatarNetworkSize }; export { AvatarSize as AvatarTokenSize };