From 08fe9bbc7d6ccbe1da96ecd7e611497c5fe1366d Mon Sep 17 00:00:00 2001 From: georgewrmarshall Date: Tue, 4 Feb 2025 09:13:04 -0800 Subject: [PATCH] feat: adding avatar network to design system react --- .../avatar-network/AvatarNetwork.stories.tsx | 146 ++++++++++++ .../avatar-network/AvatarNetwork.test.tsx | 217 ++++++++++++++++++ .../avatar-network/AvatarNetwork.tsx | 49 ++++ .../avatar-network/AvatarNetwork.types.ts | 46 ++++ .../src/components/avatar-network/README.mdx | 103 +++++++++ .../src/components/avatar-network/index.ts | 3 + .../src/components/index.ts | 3 + 7 files changed, 567 insertions(+) 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-network/AvatarNetwork.stories.tsx b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx new file mode 100644 index 00000000..1e5aee25 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { AvatarNetwork } from './AvatarNetwork'; +import { AvatarNetworkSize } from '.'; +import README from './README.mdx'; + +const meta: Meta = { + title: 'React Components/AvatarNetwork', + component: AvatarNetwork, + parameters: { + docs: { + page: README, + }, + }, + argTypes: { + name: { + control: 'text', + description: + 'Required name of the network. Used as alt text for image and first letter is used as fallback if no fallbackText provided', + }, + src: { + control: 'text', + description: + 'Optional URL for the network image. When provided, displays the image instead of fallback text', + }, + imageProps: { + control: 'object', + description: + 'Optional prop to pass to the underlying img element. Useful for overriding the default alt 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: + 'Optional text to display when no image is provided. If not provided, first letter of name will be used', + }, + 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; + +export const Default: Story = { + args: { + src: 'https://cryptologos.cc/logos/ethereum-eth-logo.png', + name: 'Ethereum', + fallbackText: 'ETH', + }, +}; + +export const Src: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Name: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const FallbackText: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const Size: Story = { + render: () => ( +
+ + + + + +
+ ), +}; 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..3a6a8797 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.test.tsx @@ -0,0 +1,217 @@ +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( + , + ); + + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + expect(img).toHaveAttribute('src', 'test-image.jpg'); + expect(img).toHaveAttribute('alt', 'Ethereum'); + }); + + it('renders fallbackText when src is not provided', () => { + render(); + expect(screen.getByText('Eth')).toBeInTheDocument(); + }); + + it('applies fallbackTextProps to Text component', () => { + render( + , + ); + + const text = screen.getByTestId('fallback-text'); + expect(text).toHaveClass('text-alternative', 'test-class'); + }); + + it('applies custom className to root element', () => { + render( + , + ); + + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('custom-class'); + }); + + it('passes through additional image props when src is provided', () => { + render( + , + ); + + screen.debug(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('loading', 'lazy'); + }); + + it('applies size classes correctly', () => { + const { rerender } = render( + , + ); + + let avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-4 w-4'); + + rerender( + , + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-6 w-6'); + + rerender( + , + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-8 w-8'); + + rerender( + , + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-10 w-10'); + + rerender( + , + ); + avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-12 w-12'); + }); + + it('uses medium size by default', () => { + render(); + const avatar = screen.getByTestId('avatar'); + expect(avatar).toHaveClass('h-8 w-8'); + }); + + it('uses name as alt text when fallbackText is not provided', () => { + render(); + + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Ethereum'); + }); + + it('uses first letter of name as fallback text when fallbackText is not provided', () => { + render(); + expect(screen.getByText('E')).toBeInTheDocument(); + }); + + it('prioritizes fallbackText over name for both alt text and fallback display', () => { + const { rerender } = render( + , + ); + + let img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'ETH'); + + rerender(); + + expect(screen.getByText('ETH')).toBeInTheDocument(); + }); +}); + +describe('text display and alt text logic', () => { + it('uses first letter of name when fallbackText is not provided', () => { + render(); + expect(screen.getByText('E')).toBeInTheDocument(); + }); + + it('uses fallbackText for display when provided', () => { + render(); + expect(screen.getByText('ETH')).toBeInTheDocument(); + }); + + it('uses name for alt text when src is provided', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Ethereum'); + }); + + it('uses name for alt text even when fallbackText is provided', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Ethereum'); + }); + + it('allows alt text override through imageProps', () => { + render( + , + ); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Custom Alt'); + }); + + it('uses empty string for display text when name is not provided', () => { + // @ts-expect-error testing invalid props + render(); + const base = screen.getByTestId('avatar'); + expect(base.querySelector('span')).toHaveTextContent(''); + }); + + it('uses default "Network logo" for alt text when name is not provided', () => { + // @ts-expect-error testing invalid props + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('alt', 'Network logo'); + }); +}); 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..0799e8c8 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.tsx @@ -0,0 +1,49 @@ +import React from 'react'; + +import { AvatarBase, AvatarBaseShape, AvatarBaseSize } from '../avatar-base'; +import type { AvatarNetworkProps } from './AvatarNetwork.types'; + +export const AvatarNetwork = React.forwardRef< + HTMLDivElement, + AvatarNetworkProps +>( + ( + { + src, + name, + fallbackText, + fallbackTextProps, + className, + size = AvatarBaseSize.Md, + imageProps, + ...props + }, + ref, + ) => { + const displayText = fallbackText || (name ? name[0] : ''); + const altText = name || 'Network logo'; // TBC: Add localization for default text + + return ( + + {src && ( + {altText} + )} + + ); + }, +); + +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..8f139e77 --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/AvatarNetwork.types.ts @@ -0,0 +1,46 @@ +import type { ComponentProps } from 'react'; + +import type { TextProps } from '../text'; +import { AvatarNetworkSize } from '.'; + +export type AvatarNetworkProps = Omit< + ComponentProps<'img'>, + 'children' | 'size' +> & { + /** + * Required name of the network + * Used as alt text for image and first letter is used as fallback if no fallbackText provided + */ + name: string; + /** + * 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 + * Useful for overriding the default alt text which is the network name + */ + imageProps?: ComponentProps<'img'>; + /** + * Optional prop to control the size of the avatar + * @default AvatarNetworkSize.Md + */ + size?: AvatarNetworkSize; + /** + * Optional text to display when no image is provided + * If not provided, first letter of name will be used + */ + 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 & TextProps + >; + /** + * Optional additional CSS classes to be applied to the component + */ + className?: 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..271dda5b --- /dev/null +++ b/packages/design-system-react/src/components/avatar-network/README.mdx @@ -0,0 +1,103 @@ +import { Controls, Canvas } from '@storybook/blocks'; + +import * as AvatarNetworkStories from './AvatarNetwork.stories'; + +# AvatarNetwork + +Avatar reserved for representing networks + +```tsx +import { AvatarNetwork } from '@metamask/design-system-react'; + +; +``` + + + +## Props + +### Name (required) + +The `name` prop is required and serves two purposes: + +- Used as alt text for the network image (unless overridden by `imageProps.alt`) +- First letter is used as fallback display text when `fallbackText` is not provided + + + +### Src (image source) + +The `src` prop is optional and specifies the URL of the network's logo image. + + + +> Note: The `imageProps` prop allows you to customize the img element when `src` is provided. All standard HTML img attributes are supported and will be passed to the underlying img element when `src` is provided. This is useful for overriding the default alt text (which is the network name) when the AvatarNetwork is used as an accompaniment to an image. + +```tsx + +``` + +### Fallback Text + +The `fallbackText` prop is optional and is used for display text when no image is provided. If not provided, the first letter of `name` will be used. + + + +> Note: The `fallbackTextProps` prop allows you to customize the Text component used for the fallback display + +```tsx + +``` + +### 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 + + + +### 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 + +``` + +## Component API + + + +## 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 12812376..83e2f80a 100644 --- a/packages/design-system-react/src/components/index.ts +++ b/packages/design-system-react/src/components/index.ts @@ -38,3 +38,6 @@ export type { ButtonIconProps } from './button-icon'; export { AvatarBase, AvatarBaseSize } from './avatar-base'; export type { AvatarBaseProps } from './avatar-base'; + +export { AvatarNetwork, AvatarNetworkSize } from './avatar-network'; +export type { AvatarNetworkProps } from './avatar-network';