From befddee705ddeb9c2f7775480249852eb516a7d4 Mon Sep 17 00:00:00 2001 From: georgewrmarshall <george.marshall@consensys.net> Date: Thu, 30 Jan 2025 16:07:07 -0800 Subject: [PATCH] chore: adding initial avatar network component --- .../avatar-base/AvatarBase.constants.ts | 12 +- .../avatar-base/AvatarBase.stories.tsx | 93 +++++++++---- .../avatar-base/AvatarBase.test.tsx | 36 +++-- .../src/components/avatar-base/AvatarBase.tsx | 21 ++- .../avatar-base/AvatarBase.types.ts | 16 +++ .../src/components/avatar-base/README.mdx | 27 +++- .../src/components/avatar-base/index.ts | 3 + .../avatar-network/AvatarNetwork.constants.ts | 16 +++ .../avatar-network/AvatarNetwork.stories.tsx | 113 ++++++++++++++++ .../avatar-network/AvatarNetwork.test.tsx | 127 ++++++++++++++++++ .../avatar-network/AvatarNetwork.tsx | 53 ++++++++ .../avatar-network/AvatarNetwork.types.ts | 45 +++++++ .../src/components/avatar-network/README.mdx | 88 ++++++++++++ .../src/components/avatar-network/index.ts | 3 + .../src/components/index.ts | 8 ++ .../src/components/text/Text.test.tsx | 6 +- .../src/components/text/Text.tsx | 3 +- 17 files changed, 608 insertions(+), 62 deletions(-) create mode 100644 packages/design-system-react/src/components/avatar-base/index.ts create mode 100644 packages/design-system-react/src/components/avatar-network/AvatarNetwork.constants.ts create mode 100644 packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx create mode 100644 packages/design-system-react/src/components/avatar-network/AvatarNetwork.test.tsx create mode 100644 packages/design-system-react/src/components/avatar-network/AvatarNetwork.tsx create mode 100644 packages/design-system-react/src/components/avatar-network/AvatarNetwork.types.ts create mode 100644 packages/design-system-react/src/components/avatar-network/README.mdx create mode 100644 packages/design-system-react/src/components/avatar-network/index.ts diff --git a/packages/design-system-react/src/components/avatar-base/AvatarBase.constants.ts b/packages/design-system-react/src/components/avatar-base/AvatarBase.constants.ts index ecee4674..031fe76a 100644 --- a/packages/design-system-react/src/components/avatar-base/AvatarBase.constants.ts +++ b/packages/design-system-react/src/components/avatar-base/AvatarBase.constants.ts @@ -1,9 +1,9 @@ import { AvatarBaseSize } from './AvatarBase.types'; -export const AVATAR_BASE_SIZE_DIMENSIONS: Record<AvatarBaseSize, string> = { - [AvatarBaseSize.Xs]: 'h-4 w-4 text-s-body-xs', - [AvatarBaseSize.Sm]: 'h-6 w-6 text-s-body-sm', - [AvatarBaseSize.Md]: 'h-8 w-8 text-s-body-md', - [AvatarBaseSize.Lg]: 'h-10 w-10 text-s-body-lg', - [AvatarBaseSize.Xl]: 'h-12 w-12 text-s-body-lg', +export const AVATAR_BASE_SIZE_CLASS_MAP: Record<AvatarBaseSize, string> = { + [AvatarBaseSize.Xs]: 'h-4 w-4', + [AvatarBaseSize.Sm]: 'h-6 w-6', + [AvatarBaseSize.Md]: 'h-8 w-8', + [AvatarBaseSize.Lg]: 'h-10 w-10', + [AvatarBaseSize.Xl]: 'h-12 w-12', }; diff --git a/packages/design-system-react/src/components/avatar-base/AvatarBase.stories.tsx b/packages/design-system-react/src/components/avatar-base/AvatarBase.stories.tsx index f227f534..1f677578 100644 --- a/packages/design-system-react/src/components/avatar-base/AvatarBase.stories.tsx +++ b/packages/design-system-react/src/components/avatar-base/AvatarBase.stories.tsx @@ -1,9 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import React from 'react'; -import { Icon, IconName, IconSize } from '..'; +import { Icon, IconName, IconSize, Text, TextVariant, TextColor } from '..'; import { AvatarBase } from './AvatarBase'; -import { AvatarBaseSize } from './AvatarBase.types'; +import { AvatarBaseSize, AvatarBaseShape } from './AvatarBase.types'; import README from './README.mdx'; const meta: Meta<typeof AvatarBase> = { @@ -31,6 +31,12 @@ const meta: Meta<typeof AvatarBase> = { mapping: AvatarBaseSize, description: 'Optional prop to control the size of the AvatarBase', }, + shape: { + control: 'select', + options: Object.keys(AvatarBaseShape), + mapping: AvatarBaseShape, + description: 'Optional prop to control the shape of the AvatarBase', + }, }, }; @@ -38,19 +44,66 @@ export default meta; type Story = StoryObj<typeof AvatarBase>; export const Default: Story = { + render: (args) => ( + <AvatarBase {...args}> + <Text>{args.children}</Text> + </AvatarBase> + ), args: { children: 'A', }, }; -export const Size: Story = { +export const Shape: Story = { render: () => ( <div className="flex gap-2 items-center"> - <AvatarBase size={AvatarBaseSize.Xs}>Xs</AvatarBase> - <AvatarBase size={AvatarBaseSize.Sm}>Sm</AvatarBase> - <AvatarBase size={AvatarBaseSize.Md}>Md</AvatarBase> - <AvatarBase size={AvatarBaseSize.Lg}>Lg</AvatarBase> - <AvatarBase size={AvatarBaseSize.Xl}>Xl</AvatarBase> + <AvatarBase shape={AvatarBaseShape.Circle}> + <Text variant={TextVariant.BodySm}>C</Text> + </AvatarBase> + <AvatarBase shape={AvatarBaseShape.Square}> + <Text variant={TextVariant.BodySm}>S</Text> + </AvatarBase> + </div> + ), +}; + +export const Size: Story = { + render: () => ( + <div className="flex flex-col gap-2"> + <div className="flex gap-2 items-center"> + <AvatarBase size={AvatarBaseSize.Xs}> + <Text variant={TextVariant.BodyXs}>Xs</Text> + </AvatarBase> + <AvatarBase size={AvatarBaseSize.Sm}> + <Text variant={TextVariant.BodyXs}>Sm</Text> + </AvatarBase> + <AvatarBase size={AvatarBaseSize.Md}> + <Text variant={TextVariant.BodySm}>Md</Text> + </AvatarBase> + <AvatarBase size={AvatarBaseSize.Lg}> + <Text variant={TextVariant.BodyMd}>Lg</Text> + </AvatarBase> + <AvatarBase size={AvatarBaseSize.Xl}> + <Text variant={TextVariant.BodyMd}>Xl</Text> + </AvatarBase> + </div> + <div className="flex gap-2 items-center"> + <AvatarBase shape={AvatarBaseShape.Square} size={AvatarBaseSize.Xs}> + <Text variant={TextVariant.BodyXs}>Xs</Text> + </AvatarBase> + <AvatarBase shape={AvatarBaseShape.Square} size={AvatarBaseSize.Sm}> + <Text variant={TextVariant.BodyXs}>Sm</Text> + </AvatarBase> + <AvatarBase shape={AvatarBaseShape.Square} size={AvatarBaseSize.Md}> + <Text variant={TextVariant.BodySm}>Md</Text> + </AvatarBase> + <AvatarBase shape={AvatarBaseShape.Square} size={AvatarBaseSize.Lg}> + <Text variant={TextVariant.BodyMd}>Lg</Text> + </AvatarBase> + <AvatarBase shape={AvatarBaseShape.Square} size={AvatarBaseSize.Xl}> + <Text variant={TextVariant.BodyMd}>Xl</Text> + </AvatarBase> + </div> </div> ), }; @@ -59,7 +112,9 @@ export const Children: Story = { render: () => ( <div className="flex gap-2 items-center"> {/* Text */} - <AvatarBase>A</AvatarBase> + <AvatarBase> + <Text>A</Text> + </AvatarBase> {/* Image */} <AvatarBase> <img @@ -70,25 +125,7 @@ export const Children: Story = { </AvatarBase> {/* Icon */} <AvatarBase> - <Icon - name={IconName.User} - size={IconSize.Sm} - className="text-inherit" - /> - </AvatarBase> - </div> - ), -}; - -export const ClassName: Story = { - render: () => ( - <div className="flex gap-2"> - <AvatarBase className="bg-success-default text-success-inverse"> - S - </AvatarBase> - <AvatarBase className="bg-error-default text-error-inverse">E</AvatarBase> - <AvatarBase className="bg-warning-default text-warning-inverse"> - W + <Icon name={IconName.User} size={IconSize.Sm} /> </AvatarBase> </div> ), diff --git a/packages/design-system-react/src/components/avatar-base/AvatarBase.test.tsx b/packages/design-system-react/src/components/avatar-base/AvatarBase.test.tsx index a9cb8d51..cda65dd1 100644 --- a/packages/design-system-react/src/components/avatar-base/AvatarBase.test.tsx +++ b/packages/design-system-react/src/components/avatar-base/AvatarBase.test.tsx @@ -2,23 +2,15 @@ import { render, screen } from '@testing-library/react'; import React from 'react'; import { AvatarBase } from './AvatarBase'; -import { AVATAR_BASE_SIZE_DIMENSIONS } from './AvatarBase.constants'; -import { AvatarBaseSize } from './AvatarBase.types'; +import { AVATAR_BASE_SIZE_CLASS_MAP } from './AvatarBase.constants'; +import { AvatarBaseSize, AvatarBaseShape } from './AvatarBase.types'; describe('AvatarBase', () => { it('renders with default styles', () => { render(<AvatarBase>A</AvatarBase>); const avatar = screen.getByText('A'); - expect(avatar).toHaveClass( - 'inline-flex', - 'items-center', - 'justify-center', - 'rounded-full', - 'bg-background-alternative', - 'text-default', - 'uppercase', - ); + expect(avatar).toBeInTheDocument(); }); it('applies size classes correctly', () => { @@ -26,7 +18,7 @@ describe('AvatarBase', () => { <AvatarBase size={AvatarBaseSize.Xs}>A</AvatarBase>, ); - Object.entries(AVATAR_BASE_SIZE_DIMENSIONS).forEach(([size, classes]) => { + Object.entries(AVATAR_BASE_SIZE_CLASS_MAP).forEach(([size, classes]) => { rerender(<AvatarBase size={size as AvatarBaseSize}>A</AvatarBase>); const avatar = screen.getByText('A'); const classArray = classes.split(' '); @@ -71,4 +63,24 @@ describe('AvatarBase', () => { const avatar = screen.getByText('A'); expect(avatar).toHaveStyle({ backgroundColor: 'red' }); }); + + it('applies correct shape classes', () => { + const { rerender } = render( + <AvatarBase shape={AvatarBaseShape.Circle}>A</AvatarBase>, + ); + + let avatar = screen.getByText('A'); + expect(avatar).toHaveClass('rounded-full'); + + rerender(<AvatarBase shape={AvatarBaseShape.Square}>A</AvatarBase>); + avatar = screen.getByText('A'); + expect(avatar).toHaveClass('rounded-lg'); + }); + + it('uses circle shape by default', () => { + render(<AvatarBase>A</AvatarBase>); + + const avatar = screen.getByText('A'); + expect(avatar).toHaveClass('rounded-full'); + }); }); diff --git a/packages/design-system-react/src/components/avatar-base/AvatarBase.tsx b/packages/design-system-react/src/components/avatar-base/AvatarBase.tsx index fceb953d..0aa79077 100644 --- a/packages/design-system-react/src/components/avatar-base/AvatarBase.tsx +++ b/packages/design-system-react/src/components/avatar-base/AvatarBase.tsx @@ -2,13 +2,21 @@ import { Slot } from '@radix-ui/react-slot'; import React from 'react'; import { twMerge } from '../../utils/tw-merge'; -import { AVATAR_BASE_SIZE_DIMENSIONS } from './AvatarBase.constants'; +import { AVATAR_BASE_SIZE_CLASS_MAP } from './AvatarBase.constants'; import type { AvatarBaseProps } from './AvatarBase.types'; -import { AvatarBaseSize } from './AvatarBase.types'; +import { AvatarBaseShape, AvatarBaseSize } from './AvatarBase.types'; export const AvatarBase = React.forwardRef<HTMLDivElement, AvatarBaseProps>( ( - { children, className, size = AvatarBaseSize.Md, asChild, style, ...props }, + { + children, + className, + size = AvatarBaseSize.Md, + shape = AvatarBaseShape.Circle, + asChild, + style, + ...props + }, ref, ) => { const Component = asChild ? Slot : 'div'; @@ -16,12 +24,11 @@ export const AvatarBase = React.forwardRef<HTMLDivElement, AvatarBaseProps>( const mergedClassName = twMerge( // Base styles 'inline-flex items-center justify-center', - 'rounded-full', - 'bg-background-alternative', - 'text-default uppercase font-medium', + shape === AvatarBaseShape.Circle ? 'rounded-full' : 'rounded-lg', + 'bg-alternative', 'overflow-hidden', // Size - AVATAR_BASE_SIZE_DIMENSIONS[size], + AVATAR_BASE_SIZE_CLASS_MAP[size], // Custom classes className, ); diff --git a/packages/design-system-react/src/components/avatar-base/AvatarBase.types.ts b/packages/design-system-react/src/components/avatar-base/AvatarBase.types.ts index 3120371d..692fca63 100644 --- a/packages/design-system-react/src/components/avatar-base/AvatarBase.types.ts +++ b/packages/design-system-react/src/components/avatar-base/AvatarBase.types.ts @@ -23,6 +23,17 @@ export enum AvatarBaseSize { Xl = 'xl', } +export enum AvatarBaseShape { + /** + * Circular shape with fully rounded corners + */ + Circle = 'circle', + /** + * Square shape with slight rounded corners + */ + Square = 'square', +} + export type AvatarBaseProps = ComponentProps<'div'> & { /** * Required prop for the content to be rendered within the AvatarBase @@ -49,4 +60,9 @@ export type AvatarBaseProps = ComponentProps<'div'> & { * Should be used sparingly and only for dynamic styles that can't be achieved with className. */ style?: React.CSSProperties; + /** + * Optional prop to control the shape of the AvatarBase + * @default AvatarBaseShape.Circle + */ + shape?: AvatarBaseShape; }; diff --git a/packages/design-system-react/src/components/avatar-base/README.mdx b/packages/design-system-react/src/components/avatar-base/README.mdx index 12ed2c77..2db1b9aa 100644 --- a/packages/design-system-react/src/components/avatar-base/README.mdx +++ b/packages/design-system-react/src/components/avatar-base/README.mdx @@ -7,15 +7,26 @@ import * as AvatarBaseStories from './AvatarBase.stories'; The AvatarBase is the base component for avatars ```tsx -import { AvatarBase } from '@metamask/design-system-react'; +import { AvatarBase, Text } from '@metamask/design-system-react'; -<AvatarBase>A</AvatarBase>; +<AvatarBase> + <Text>A</Text> +</AvatarBase>; ``` <Canvas of={AvatarBaseStories.Default} /> ## Props +### Shape + +AvatarBase supports two shapes: + +- `AvatarBaseShape.Circle` (fully rounded) - default +- `AvatarBaseShape.Square` (slightly rounded corners) + +<Canvas of={AvatarBaseStories.Shape} /> + ### Size AvatarBase supports five sizes: @@ -34,23 +45,25 @@ AvatarBase can contain different types of content including text, images, and ic <Canvas of={AvatarBaseStories.Children} /> -### ClassName +### Class Name Use the `className` prop to add custom CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to: - Add new styles that don't exist in the default component - Override the component's default styles when needed -<Canvas of={AvatarBaseStories.ClassName} /> - Example: ```tsx // Adding new styles -<AvatarBase className="my-4 mx-2">A</AvatarBase> +<AvatarBase className="my-4 mx-2"> + <Text>A</Text> +</AvatarBase> // Overriding default styles -<AvatarBase className="bg-success-default text-success-inverse">S</AvatarBase> +<AvatarBase className="bg-success-default"> + <Text color={TextColor.SuccessInverse}>A</Text> +</AvatarBase> ``` ### Style diff --git a/packages/design-system-react/src/components/avatar-base/index.ts b/packages/design-system-react/src/components/avatar-base/index.ts new file mode 100644 index 00000000..d86a8a1f --- /dev/null +++ b/packages/design-system-react/src/components/avatar-base/index.ts @@ -0,0 +1,3 @@ +export { AvatarBase } from './AvatarBase'; +export type { AvatarBaseProps } from './AvatarBase.types'; +export { AvatarBaseSize, AvatarBaseShape } from './AvatarBase.types'; diff --git a/packages/design-system-react/src/components/avatar-network/AvatarNetwork.constants.ts b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.constants.ts new file mode 100644 index 00000000..a0d030ec --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.constants.ts @@ -0,0 +1,16 @@ +// Remove this file if it's not needed +export const AVATARNETWORK_CLASSMAP = {}; + +import { AvatarBaseSize } from '../avatar-base'; +import { TextVariant } from '../text'; + +export const AVATAR_NETWORK_SIZE_TO_TEXT_VARIANT_MAP: Record< + AvatarBaseSize, + TextVariant +> = { + [AvatarBaseSize.Xs]: TextVariant.BodyXs, + [AvatarBaseSize.Sm]: TextVariant.BodyXs, + [AvatarBaseSize.Md]: TextVariant.BodySm, + [AvatarBaseSize.Lg]: TextVariant.BodyMd, + [AvatarBaseSize.Xl]: TextVariant.BodyMd, +}; diff --git a/packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx new file mode 100644 index 00000000..b30fdff7 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { TextColor } from '..'; +import { AvatarNetwork } from './AvatarNetwork'; +import { AvatarNetworkSize } from '.'; +import README from './README.mdx'; + +const meta: Meta<typeof AvatarNetwork> = { + title: 'React Components/AvatarNetwork', + component: AvatarNetwork, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + src: { + control: 'text', + description: + 'Optional URL for the network image. When provided, displays the image instead of fallback text', + }, + size: { + control: 'select', + options: Object.keys(AvatarNetworkSize), + mapping: AvatarNetworkSize, + description: + 'Optional prop to control the size of the avatar. Defaults to AvatarNetworkSize.Md', + }, + fallbackText: { + control: 'text', + description: + 'Required text to display when no image is provided. Also used as alt text for the image when src is provided', + }, + fallbackTextProps: { + control: 'object', + description: + 'Optional props to be passed to the Text component when rendering fallback text. Only used when src is not provided', + }, + className: { + control: 'text', + description: + 'Optional additional CSS classes to be applied to the component', + }, + }, +}; + +export default meta; +type Story = StoryObj<typeof AvatarNetwork>; + +export const Default: Story = { + args: { + src: 'https://cryptologos.cc/logos/ethereum-eth-logo.png', + fallbackText: 'Eth', + }, +}; + +export const Src: Story = { + render: () => ( + <div className="flex gap-2"> + <AvatarNetwork + fallbackText="Eth" + src="https://cryptologos.cc/logos/ethereum-eth-logo.png" + /> + <AvatarNetwork + fallbackText="Ava" + src="https://cryptologos.cc/logos/avalanche-avax-logo.png" + /> + <AvatarNetwork + fallbackText="Pol" + src="https://cryptologos.cc/logos/polygon-matic-logo.png" + /> + </div> + ), +}; + +export const FallbackText: Story = { + render: () => ( + <div className="flex gap-2"> + <AvatarNetwork fallbackText="Eth" /> + <AvatarNetwork fallbackText="Ava" /> + <AvatarNetwork + fallbackText="Pol" + fallbackTextProps={{ + color: TextColor.ErrorDefault, + 'data-testid': 'fallback-text', + }} + /> + </div> + ), +}; + +export const FallbackTextProps: Story = { + args: { + fallbackText: 'Eth', + fallbackTextProps: { + color: TextColor.ErrorDefault, + 'data-testid': 'fallback-text', + }, + }, +}; + +export const Size: Story = { + render: () => ( + <div className="flex gap-2 items-center"> + <AvatarNetwork fallbackText="E" size={AvatarNetworkSize.Xs} /> + <AvatarNetwork fallbackText="Eth" size={AvatarNetworkSize.Sm} /> + <AvatarNetwork fallbackText="Eth" size={AvatarNetworkSize.Md} /> + <AvatarNetwork fallbackText="Eth" size={AvatarNetworkSize.Lg} /> + <AvatarNetwork fallbackText="Eth" size={AvatarNetworkSize.Xl} /> + </div> + ), +}; diff --git a/packages/design-system-react/src/components/avatar-network/AvatarNetwork.test.tsx b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.test.tsx new file mode 100644 index 00000000..410b8a3e --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.test.tsx @@ -0,0 +1,127 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { TextColor } from '..'; +import { AvatarNetwork } from './AvatarNetwork'; +import { AvatarNetworkSize } from '.'; + +describe('AvatarNetwork', () => { + it('renders image when src is provided', () => { + render(<AvatarNetwork src="test-image.jpg" fallbackText="Eth" />); + + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'test-image.jpg'); + expect(img).toHaveAttribute('alt', 'Eth'); + }); + + it('renders fallbackText when src is not provided', () => { + render(<AvatarNetwork fallbackText="Eth" />); + expect(screen.getByText('Eth')).toBeInTheDocument(); + }); + + it('applies fallbackTextProps to Text component', () => { + render( + <AvatarNetwork + fallbackText="Eth" + fallbackTextProps={{ + color: TextColor.TextAlternative, + className: 'test-class', + 'data-testid': 'fallback-text', + }} + />, + ); + + const text = screen.getByTestId('fallback-text'); + expect(text).toHaveClass('text-alternative', 'test-class'); + }); + + it('applies custom className to root element', () => { + render( + <AvatarNetwork + fallbackText="Eth" + className="custom-class" + data-testid="avatar" + />, + ); + + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('custom-class'); + }); + + it('passes through additional image props when src is provided', () => { + render( + <AvatarNetwork + src="test-image.jpg" + fallbackText="Eth" + imageProps={{ + loading: 'lazy', + }} + />, + ); + + screen.debug(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('loading', 'lazy'); + }); + + it('applies size classes correctly', () => { + const { rerender } = render( + <AvatarNetwork + fallbackText="Eth" + size={AvatarNetworkSize.Xs} + data-testid="avatar" + />, + ); + + let avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-4 w-4'); + + rerender( + <AvatarNetwork + fallbackText="Eth" + size={AvatarNetworkSize.Sm} + data-testid="avatar" + />, + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-6 w-6'); + + rerender( + <AvatarNetwork + fallbackText="Eth" + size={AvatarNetworkSize.Md} + data-testid="avatar" + />, + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-8 w-8'); + + rerender( + <AvatarNetwork + fallbackText="Eth" + size={AvatarNetworkSize.Lg} + data-testid="avatar" + />, + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-10 w-10'); + + rerender( + <AvatarNetwork + fallbackText="Eth" + size={AvatarNetworkSize.Xl} + data-testid="avatar" + />, + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-12 w-12'); + }); + + it('uses medium size by default', () => { + render(<AvatarNetwork fallbackText="Eth" data-testid="avatar" />); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-8 w-8'); + }); +}); diff --git a/packages/design-system-react/src/components/avatar-network/AvatarNetwork.tsx b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.tsx new file mode 100644 index 00000000..cc289fa2 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +import { AvatarBase, AvatarBaseShape, AvatarBaseSize } from '../avatar-base'; +import { Text } from '../text'; +import { AVATAR_NETWORK_SIZE_TO_TEXT_VARIANT_MAP } from './AvatarNetwork.constants'; +import type { AvatarNetworkProps } from './AvatarNetwork.types'; + +export const AvatarNetwork = React.forwardRef< + HTMLDivElement, + AvatarNetworkProps +>( + ( + { + src, + fallbackText, + fallbackTextProps, + className, + size = AvatarBaseSize.Md, + 'data-testid': dataTestId, + imageProps, + ...props + }, + ref, + ) => ( + <AvatarBase + ref={ref} + shape={AvatarBaseShape.Square} + size={size} + className={className} + data-testid={dataTestId} + {...props} + > + {src ? ( + <img + src={src} + alt={fallbackText} + className="w-full h-full object-cover" + {...imageProps} + /> + ) : ( + <Text + variant={AVATAR_NETWORK_SIZE_TO_TEXT_VARIANT_MAP[size]} + asChild + {...fallbackTextProps} + > + <span>{fallbackText}</span> + </Text> + )} + </AvatarBase> + ), +); + +AvatarNetwork.displayName = 'AvatarNetwork'; diff --git a/packages/design-system-react/src/components/avatar-network/AvatarNetwork.types.ts b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.types.ts new file mode 100644 index 00000000..5aea19a8 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.types.ts @@ -0,0 +1,45 @@ +import type { ComponentProps } from 'react'; + +import type { TextProps } from '../text'; +import { AvatarNetworkSize } from '.'; + +export type AvatarNetworkProps = Omit< + ComponentProps<'img'>, + 'children' | 'size' +> & { + /** + * Optional URL for the network image + * When provided, displays the image instead of fallback text + */ + src?: string; + /** + * Optional prop to pass to the underlying img element + */ + imageProps?: ComponentProps<'img'>; + /** + * Optional prop to control the size of the avatar + * @default AvatarNetworkSize.Md + */ + size?: AvatarNetworkSize; + /** + * Required text to display when no image is provided + * Also used as alt text for the image when src is provided + */ + fallbackText: string; + /** + * Optional props to be passed to the Text component when rendering fallback text + * Only used when src is not provided + */ + fallbackTextProps?: Partial< + React.HTMLAttributes<HTMLSpanElement> & TextProps + >; + /** + * Optional additional CSS classes to be applied to the component + */ + className?: string; + /** + * Optional prop for testing purposes + * Passed to the root element + */ + 'data-testid'?: string; +}; diff --git a/packages/design-system-react/src/components/avatar-network/README.mdx b/packages/design-system-react/src/components/avatar-network/README.mdx new file mode 100644 index 00000000..0b06d7b1 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/README.mdx @@ -0,0 +1,88 @@ +import { Controls, Canvas } from '@storybook/blocks'; + +import * as AvatarNetworkStories from './AvatarNetwork.stories'; + +# AvatarNetwork + +AvatarNetwork is a component for displaying network avatars. It can show either a network's logo image or a fallback with the first letter of the network's name. + +```tsx +import { AvatarNetwork } from '@metamask/design-system-react'; + +<AvatarNetwork fallbackText="Eth" src="path/to/ethereum-logo.png" />; +``` + +<Canvas of={AvatarNetworkStories.Default} /> + +## Props + +### Src (image source) + +The `src` prop is optional and specifies the URL of the network's logo image. + +<Canvas of={AvatarNetworkStories.Src} /> + +### Image Props + +All standard HTML img attributes are supported and will be passed to the underlying img element when `src` is provided. + +```tsx +<AvatarNetwork + src="path/to/ethereum-logo.png" + fallbackText="Eth" + imageProps={{ alt: 'Ethereum', loading: 'lazy' }} +/> +``` + +### Fallback Text + +The `fallbackText` prop is required and serves two purposes: + +- Alt text for the network image when `src` is provided. For better accessibility, it's recommended to use the imageProps `alt` attribute. +- Fallback display text shows when `src` is not provided. It will show the entire string + +<Canvas of={AvatarNetworkStories.FallbackText} /> + +### Fallback Text Props + +The `fallbackTextProps` prop allows you to customize the Text component used for the fallback display: + +<Canvas of={AvatarNetworkStories.FallbackTextProps} /> + +### Size + +AvatarNetwork supports five sizes, each with a corresponding text variant for the fallback text: + +- `AvatarNetworkSize.Xs` (16px) - uses TextVariant.BodyXs +- `AvatarNetworkSize.Sm` (24px) - uses TextVariant.BodyXs +- `AvatarNetworkSize.Md` (32px) - uses TextVariant.BodySm (default) +- `AvatarNetworkSize.Lg` (40px) - uses TextVariant.BodyMd +- `AvatarNetworkSize.Xl` (48px) - uses TextVariant.BodyMd + +<Canvas of={AvatarNetworkStories.Size} /> + +### Class Name + +Use the `className` prop to add custom CSS classes to the component. These classes will be merged with the component's default classes using `twMerge`, allowing you to: + +- Add new styles that don't exist in the default component +- Override the component's default styles when needed + +Example: + +```tsx +// Adding new styles +<AvatarNetwork + src="path/to/ethereum-logo.png" + fallbackText="Eth" + className="my-4 mx-2" +/> +``` + +## Component API + +<Controls of={AvatarNetworkStories.Default} /> + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react/src/components/avatar-network/index.ts b/packages/design-system-react/src/components/avatar-network/index.ts new file mode 100644 index 00000000..59f3ad36 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/index.ts @@ -0,0 +1,3 @@ +export { AvatarNetwork } from './AvatarNetwork'; +export type { AvatarNetworkProps } from './AvatarNetwork.types'; +export { AvatarBaseSize as AvatarNetworkSize } from '../avatar-base'; diff --git a/packages/design-system-react/src/components/index.ts b/packages/design-system-react/src/components/index.ts index 1ba7dc5a..9efc7e02 100644 --- a/packages/design-system-react/src/components/index.ts +++ b/packages/design-system-react/src/components/index.ts @@ -32,3 +32,11 @@ export type { ButtonProps } from './button'; export { TextButton } from './text-button'; export type { TextButtonProps } from './text-button'; + +export { AvatarBase } from './avatar-base'; +export type { AvatarBaseProps } from './avatar-base'; +export { AvatarBaseSize, AvatarBaseShape } from './avatar-base'; + +export { AvatarNetwork } from './avatar-network'; +export { AvatarNetworkSize } from './avatar-network'; +export type { AvatarNetworkProps } from './avatar-network'; diff --git a/packages/design-system-react/src/components/text/Text.test.tsx b/packages/design-system-react/src/components/text/Text.test.tsx index 5e3bb1a5..20b64942 100644 --- a/packages/design-system-react/src/components/text/Text.test.tsx +++ b/packages/design-system-react/src/components/text/Text.test.tsx @@ -15,7 +15,11 @@ import { TEXT_CLASS_MAP } from './Text.constants'; describe('Text Component', () => { it('renders children correctly', () => { - render(<Text variant={TextVariant.BodyMd}>Hello, World!</Text>); + render( + <Text variant={TextVariant.BodyMd} data-testid="text"> + Hello, World! + </Text>, + ); expect(screen.getByText('Hello, World!')).toBeInTheDocument(); }); diff --git a/packages/design-system-react/src/components/text/Text.tsx b/packages/design-system-react/src/components/text/Text.tsx index c1a15526..c8fbe892 100644 --- a/packages/design-system-react/src/components/text/Text.tsx +++ b/packages/design-system-react/src/components/text/Text.tsx @@ -19,6 +19,7 @@ export const Text: React.FC<TextProps> = ({ asChild, color = TextColor.TextDefault, style, + ...props }) => { // When asChild is true, use Radix Slot to merge props onto the child component. // Otherwise, render the semantic HTML element mapped to this variant (e.g. h1-h4, p). @@ -37,7 +38,7 @@ export const Text: React.FC<TextProps> = ({ ); return ( - <Component className={mergedClassName} style={style}> + <Component className={mergedClassName} style={style} {...props}> {children} </Component> );