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.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.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 = { @@ -31,6 +31,12 @@ const meta: Meta = { 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; export const Default: Story = { + render: (args) => ( + + {args.children} + + ), args: { children: 'A', }, }; -export const Size: Story = { +export const Shape: Story = { render: () => (
- Xs - Sm - Md - Lg - Xl + + C + + + S + +
+ ), +}; + +export const Size: Story = { + render: () => ( +
+
+ + Xs + + + Sm + + + Md + + + Lg + + + Xl + +
+
+ + Xs + + + Sm + + + Md + + + Lg + + + Xl + +
), }; @@ -59,7 +112,9 @@ export const Children: Story = { render: () => (
{/* Text */} - A + + A + {/* Image */} {/* Icon */} - - -
- ), -}; - -export const ClassName: Story = { - render: () => ( -
- - S - - E - - W +
), 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(A); 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', () => { A, ); - Object.entries(AVATAR_BASE_SIZE_DIMENSIONS).forEach(([size, classes]) => { + Object.entries(AVATAR_BASE_SIZE_CLASS_MAP).forEach(([size, classes]) => { rerender(A); 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( + A, + ); + + let avatar = screen.getByText('A'); + expect(avatar).toHaveClass('rounded-full'); + + rerender(A); + avatar = screen.getByText('A'); + expect(avatar).toHaveClass('rounded-lg'); + }); + + it('uses circle shape by default', () => { + render(A); + + 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( ( - { 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( 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'; -A; + + A +; ``` ## Props +### Shape + +AvatarBase supports two shapes: + +- `AvatarBaseShape.Circle` (fully rounded) - default +- `AvatarBaseShape.Square` (slightly rounded corners) + + + ### Size AvatarBase supports five sizes: @@ -34,23 +45,25 @@ AvatarBase can contain different types of content including text, images, and ic -### 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 - - Example: ```tsx // Adding new styles -A + + A + // Overriding default styles -S + + A + ``` ### 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 = { + 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; + +export const Default: Story = { + args: { + src: 'https://cryptologos.cc/logos/ethereum-eth-logo.png', + fallbackText: 'Eth', + }, +}; + +export const Src: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const FallbackText: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const FallbackTextProps: Story = { + args: { + fallbackText: 'Eth', + fallbackTextProps: { + color: TextColor.ErrorDefault, + 'data-testid': 'fallback-text', + }, + }, +}; + +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..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(); + + 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(); + 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'); + }); +}); 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, + ) => ( + + {src ? ( + {fallbackText} + ) : ( + + {fallbackText} + + )} + + ), +); + +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 & 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'; + +; +``` + + + +## Props + +### Src (image source) + +The `src` prop is optional and specifies the URL of the network's logo image. + + + +### Image Props + +All standard HTML img attributes are supported and will be passed to the underlying img element when `src` is provided. + +```tsx + +``` + +### 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 + + + +### Fallback Text Props + +The `fallbackTextProps` prop allows you to customize the Text component used for the fallback display: + + + +### 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 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(Hello, World!); + render( + + Hello, World! + , + ); 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 = ({ 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 = ({ ); return ( - + {children} );