diff --git a/packages/design-system-react-native/src/components/Tag/README.md b/packages/design-system-react-native/src/components/Tag/README.md new file mode 100644 index 000000000..edc5c9a39 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/README.md @@ -0,0 +1,160 @@ +# Tag + +A tag is a compact, non-interactive or interactive label used to categorize, annotate, or highlight metadata. Tags help users quickly scan, filter, and understand content relationships at a glance. + +**Figma:** [MMDS Components — Tag](https://www.figma.com/design/1D6tnzXqWgnUC3spaAOELN/%F0%9F%A6%8A-MMDS-Components?node-id=12339-1167) + +```tsx +import { Tag } from '@metamask/design-system-react-native'; + +Default Example; +``` + +## Props + +### `severity` + +Semantic emphasis for background, string child text color, and icons. Values use `TagSeverity` from `@metamask/design-system-shared` (same pattern as `BannerAlert` / `IconAlert`). + +Available values: + +- `TagSeverity.Neutral` +- `TagSeverity.Success` +- `TagSeverity.Error` +- `TagSeverity.Warning` +- `TagSeverity.Info` + +| TYPE | REQUIRED | DEFAULT | +| -------------- | -------- | --------------------- | +| `TagSeverity?` | No | `TagSeverity.Neutral` | + +```tsx +import { Tag } from '@metamask/design-system-react-native'; +import { TagSeverity } from '@metamask/design-system-shared'; + +Success +Error +``` + +### `children` + +Main content. String children are rendered with design-system `Text` (`BodyXs`, medium weight, severity-based color). Other React nodes are rendered as-is (use your own `Text` or layout inside when needed). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { Tag } from '@metamask/design-system-react-native'; +import { Text } from 'react-native'; + + + Custom children content +; +``` + +### `startIconName` / `endIconName` + +Optional `IconName` for small icons at the start or end of the tag (`IconSize.Xs` by default). Prefer these when using built-in icons; use `startIconProps` / `endIconProps` for overrides (including `name` instead of `startIconName` / `endIconName`). + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `IconName?` | No | `undefined` | + +```tsx +import { IconName, Tag } from '@metamask/design-system-react-native'; + +With start icon +With end icon +``` + +### `startIconProps` / `endIconProps` + +Optional partial `IconProps` passed through to the underlying `Icon`. You may set `name` here instead of `startIconName` / `endIconName`. + +| TYPE | REQUIRED | DEFAULT | +| --------------------- | -------- | ----------- | +| `Partial?` | No | `undefined` | + +### `startAccessory` / `endAccessory` + +Optional React nodes shown when no start/end icon is resolved (e.g. custom glyph or badge). Icons take precedence when `startIconName` / `endIconName` (or `name` in icon props) is set. + +| TYPE | REQUIRED | DEFAULT | +| ----------- | -------- | ----------- | +| `ReactNode` | No | `undefined` | + +```tsx +import { Tag } from '@metamask/design-system-react-native'; +import { Text } from 'react-native'; + +→}>With accessory; +``` + +### `testID` + +Test identifier for selecting the component in tests. + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +### `twClassName` + +Use the `twClassName` prop to add Tailwind 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 + +| TYPE | REQUIRED | DEFAULT | +| -------- | -------- | ----------- | +| `string` | No | `undefined` | + +```tsx +import { Tag } from '@metamask/design-system-react-native'; +import { Text } from 'react-native'; + +// Add additional styles + + Custom Background + + +// Override default styles + + Override Background + +``` + +### `style` + +Use the `style` prop to customize the component's appearance with React Native styles. For consistent styling, prefer using `twClassName` with Tailwind classes when possible. Use `style` with `tw.style()` for conditionals or dynamic values. + +| TYPE | REQUIRED | DEFAULT | +| ---------------------- | -------- | ----------- | +| `StyleProp` | No | `undefined` | + +```tsx +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { Tag } from '@metamask/design-system-react-native'; +import { Text } from 'react-native'; + +export const ConditionalExample = ({ isActive }: { isActive: boolean }) => { + const tw = useTailwind(); + + return ( + + Conditional styling + + ); +}; +``` + +## Accessibility + +- String `children` are rendered with design-system typography inside the layout row, so assistive technologies can treat visible label text like normal copy. Prefer clear, concise labels; do not rely on color or icons alone to convey meaning. +- Icons are decorative unless your app assigns accessibility labels on the underlying `Icon` via `startIconProps` / `endIconProps` when needed. +- `testID` is intended for automated tests only; it does not replace accessible names for users. + +## References + +[MetaMask Design System Guides](https://www.notion.so/MetaMask-Design-System-Guides-Design-f86ecc914d6b4eb6873a122b83c12940) diff --git a/packages/design-system-react-native/src/components/Tag/Tag.constants.ts b/packages/design-system-react-native/src/components/Tag/Tag.constants.ts new file mode 100644 index 000000000..2e4606ce9 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.constants.ts @@ -0,0 +1,30 @@ +import { TagSeverity, TextColor } from '@metamask/design-system-shared'; + +import { BoxBackgroundColor, IconColor } from '../../types'; + +export const MAP_TAG_SEVERITY_BACKGROUND: Record< + TagSeverity, + BoxBackgroundColor +> = { + [TagSeverity.Neutral]: BoxBackgroundColor.BackgroundMuted, + [TagSeverity.Success]: BoxBackgroundColor.SuccessMuted, + [TagSeverity.Error]: BoxBackgroundColor.ErrorMuted, + [TagSeverity.Warning]: BoxBackgroundColor.WarningMuted, + [TagSeverity.Info]: BoxBackgroundColor.InfoMuted, +}; + +export const MAP_TAG_SEVERITY_TEXT_COLOR: Record = { + [TagSeverity.Neutral]: TextColor.TextDefault, + [TagSeverity.Success]: TextColor.SuccessDefault, + [TagSeverity.Error]: TextColor.ErrorDefault, + [TagSeverity.Warning]: TextColor.WarningDefault, + [TagSeverity.Info]: TextColor.InfoDefault, +}; + +export const MAP_TAG_SEVERITY_ICON_COLOR: Record = { + [TagSeverity.Neutral]: IconColor.IconDefault, + [TagSeverity.Success]: IconColor.SuccessDefault, + [TagSeverity.Error]: IconColor.ErrorDefault, + [TagSeverity.Warning]: IconColor.WarningDefault, + [TagSeverity.Info]: IconColor.InfoDefault, +}; diff --git a/packages/design-system-react-native/src/components/Tag/Tag.figma.tsx b/packages/design-system-react-native/src/components/Tag/Tag.figma.tsx new file mode 100644 index 000000000..69bd9eb38 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.figma.tsx @@ -0,0 +1,66 @@ +// import figma needs to remain as figma otherwise it breaks code connect +// eslint-disable-next-line import-x/no-named-as-default +import figma from '@figma/code-connect'; +import { TagSeverity } from '@metamask/design-system-shared'; +import React from 'react'; + +import { IconName } from '../Icon'; + +import { Tag } from './Tag'; + +/** + * -- This file was auto-generated by Code Connect -- + * React Native implementation of Tag (`figma.connect` for Figma Dev Mode). + * + * [MMDS Tag in Figma](https://www.figma.com/design/1D6tnzXqWgnUC3spaAOELN/%F0%9F%A6%8A-MMDS-Components?node-id=12339-6553) + * + * Root Figma props: `severity`, `state`, `icons`. The first argument to each `figma.*` + * helper must match Dev Mode property names exactly. + * + * - **`state`** (default / hover / pressed) is visual-only in Figma; `Tag` has no matching prop yet. + * - **`icons`** drives `startIconName` / `endIconName` with a placeholder icon (`IconName.Tag`). + * - Label copy comes from the nested **Label** instance (`Label text`), same pattern as `Checkbox.figma.tsx`. + */ +figma.connect( + Tag, + 'https://www.figma.com/design/1D6tnzXqWgnUC3spaAOELN/%F0%9F%A6%8A-MMDS-Components?node-id=12339-6553', + { + props: { + severity: figma.enum('severity', { + neutral: TagSeverity.Neutral, + error: TagSeverity.Error, + info: TagSeverity.Info, + success: TagSeverity.Success, + warning: TagSeverity.Warning, + }), + state: figma.enum('state', { + default: 'default', + hover: 'hover', + pressed: 'pressed', + }), + icons: figma.enum('icons', { + none: 'none', + start: 'start', + 'start & end': 'both', + }), + label: figma.nestedProps('Label', { + text: figma.string('Label text'), + }), + }, + example: ({ severity, state: _figmaState, icons, label }) => { + const startIconName = + icons === 'start' || icons === 'both' ? IconName.Tag : undefined; + const endIconName = icons === 'both' ? IconName.Tag : undefined; + + return ( + + {label.text} + + ); + }, + }, +); diff --git a/packages/design-system-react-native/src/components/Tag/Tag.stories.tsx b/packages/design-system-react-native/src/components/Tag/Tag.stories.tsx new file mode 100644 index 000000000..172dfc9c8 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.stories.tsx @@ -0,0 +1,94 @@ +import { TagSeverity } from '@metamask/design-system-shared'; +import type { Meta, StoryObj } from '@storybook/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { IconName } from '../Icon'; + +import { Tag } from './Tag'; +import type { TagProps } from './Tag.types'; + +const meta: Meta = { + title: 'Components/Tag', + component: Tag, + parameters: {}, + argTypes: { + severity: { + control: 'select', + options: Object.values(TagSeverity), + }, + children: { + control: 'text', + }, + startIconName: { + control: 'select', + options: Object.values(IconName), + }, + endIconName: { + control: 'select', + options: Object.values(IconName), + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: 'Tag', + severity: TagSeverity.Neutral, + }, +}; + +export const Severity: Story = { + render: () => ( + <> + Neutral + + Success + + + Error + + + Warning + + + Info + + + ), +}; + +export const StartIconName: Story = { + render: () => Tag, +}; + +export const EndIconName: Story = { + render: () => Tag, +}; + +export const StartAccessory: Story = { + render: () => ( + →}> + Tag + + ), +}; + +export const EndAccessory: Story = { + render: () => ( + ←}> + Tag + + ), +}; + +export const StartAndEndIconNames: Story = { + render: () => ( + + Tag + + ), +}; diff --git a/packages/design-system-react-native/src/components/Tag/Tag.test.tsx b/packages/design-system-react-native/src/components/Tag/Tag.test.tsx new file mode 100644 index 000000000..fb3c8c35c --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.test.tsx @@ -0,0 +1,142 @@ +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import { renderHook } from '@testing-library/react-hooks'; +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +import { IconName } from '../Icon'; + +import { Tag } from './Tag'; + +describe('Tag', () => { + let tw: ReturnType; + + beforeAll(() => { + const { result } = renderHook(() => useTailwind()); + tw = result.current; + }); + + describe('children', () => { + it('renders children correctly', () => { + const { getByText } = render( + + Hello, World! + , + ); + + expect(getByText('Hello, World!')).toBeOnTheScreen(); + }); + + it('renders string children with tag text styling', () => { + const { getByText } = render( + From string, + ); + + expect(getByText('From string')).toBeOnTheScreen(); + }); + }); + + describe('twClassName', () => { + it('applies the correct styles', () => { + const { getByTestId } = render( + + Styled Content + , + ); + + expect(getByTestId('component-name')).toHaveStyle(tw`bg-default`); + }); + }); + + describe('testID', () => { + it('accepts testID prop', () => { + const { getByTestId } = render( + + Test Content + , + ); + + expect(getByTestId('component-name')).toBeOnTheScreen(); + }); + }); + + describe('startIconName and startIconProps', () => { + it('renders start icon when startIconName is provided', () => { + const { getByTestId } = render( + + Tagged + , + ); + + expect(getByTestId('tag-start-icon')).toBeOnTheScreen(); + }); + + it('resolves icon from startIconProps.name when startIconName is omitted', () => { + const { getByTestId } = render( + + From props + , + ); + + expect(getByTestId('tag-start-icon')).toBeOnTheScreen(); + }); + }); + + describe('endIconName', () => { + it('renders end icon when endIconName is provided', () => { + const { getByTestId } = render( + + Tagged + , + ); + + expect(getByTestId('tag-end-icon')).toBeOnTheScreen(); + }); + }); + + describe('icons omitted', () => { + it('does not render icons when no name is provided', () => { + const { queryByTestId } = render( + + No icons + , + ); + + expect(queryByTestId('tag-start-icon')).not.toBeOnTheScreen(); + expect(queryByTestId('tag-end-icon')).not.toBeOnTheScreen(); + }); + }); + + describe('startAccessory', () => { + it('renders startAccessory when no start icon', () => { + const { getByTestId } = render( + →}> + With accessory + , + ); + + expect(getByTestId('tag-start-accessory')).toBeOnTheScreen(); + }); + }); + + describe('endAccessory', () => { + it('renders endAccessory when no end icon', () => { + const { getByTestId } = render( + ←}> + With end accessory + , + ); + + expect(getByTestId('tag-end-accessory')).toBeOnTheScreen(); + }); + }); +}); diff --git a/packages/design-system-react-native/src/components/Tag/Tag.tsx b/packages/design-system-react-native/src/components/Tag/Tag.tsx new file mode 100644 index 000000000..d6ae2561e --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.tsx @@ -0,0 +1,93 @@ +import { + FontWeight, + TagSeverity, + TextVariant, +} from '@metamask/design-system-shared'; +import { useTailwind } from '@metamask/design-system-twrnc-preset'; +import React from 'react'; + +import { Box } from '../Box'; +import { BoxRow } from '../BoxRow'; +import { Icon, IconSize } from '../Icon'; + +import { + MAP_TAG_SEVERITY_BACKGROUND, + MAP_TAG_SEVERITY_ICON_COLOR, + MAP_TAG_SEVERITY_TEXT_COLOR, +} from './Tag.constants'; +import type { TagProps } from './Tag.types'; + +export const Tag: React.FC = ({ + children, + severity = TagSeverity.Neutral, + startIconName, + startIconProps, + startAccessory, + endIconName, + endIconProps, + endAccessory, + twClassName, + style, + ...props +}) => { + const tw = useTailwind(); + const backgroundColor = MAP_TAG_SEVERITY_BACKGROUND[severity]; + const textColor = MAP_TAG_SEVERITY_TEXT_COLOR[severity]; + const iconColor = MAP_TAG_SEVERITY_ICON_COLOR[severity]; + + return ( + + { + const name = startIconName ?? startIconProps?.name; + if (!name) { + return startAccessory ?? null; + } + return ( + + ); + })()} + endAccessory={(() => { + const name = endIconName ?? endIconProps?.name; + if (!name) { + return endAccessory ?? null; + } + return ( + + ); + })()} + > + {children} + + + ); +}; diff --git a/packages/design-system-react-native/src/components/Tag/Tag.types.ts b/packages/design-system-react-native/src/components/Tag/Tag.types.ts new file mode 100644 index 000000000..967449ff1 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/Tag.types.ts @@ -0,0 +1,39 @@ +import type { TagPropsShared } from '@metamask/design-system-shared'; +import type { ViewProps, StyleProp, ViewStyle } from 'react-native'; + +import type { IconName, IconProps } from '../Icon'; + +/** + * Tag component props (React Native platform-specific). + * Extends shared props from @metamask/design-system-shared with React Native specific platform concerns. + */ +export type TagProps = TagPropsShared & { + /** + * Optional prop to specify an icon to show at the start of the tag (`IconSize.Xs` unless overridden in `startIconProps`). + */ + startIconName?: IconName; + /** + * Optional prop to pass additional properties to the start icon. You may set `name` here instead of `startIconName`. + */ + startIconProps?: Partial; + /** + * Optional prop to specify an icon to show at the end of the tag (`IconSize.Xs` unless overridden in `endIconProps`). + */ + endIconName?: IconName; + /** + * Optional prop to pass additional properties to the end icon. You may set `name` here instead of `endIconName`. + */ + endIconProps?: Partial; + /** + * Additional Tailwind classes to be applied to the Tag container. + */ + twClassName?: string; + /** + * Test identifier for selecting the component in tests. + */ + testID?: string; + /** + * Optional container styles. + */ + style?: StyleProp; +} & Omit; diff --git a/packages/design-system-react-native/src/components/Tag/index.ts b/packages/design-system-react-native/src/components/Tag/index.ts new file mode 100644 index 000000000..6aa1fd3b8 --- /dev/null +++ b/packages/design-system-react-native/src/components/Tag/index.ts @@ -0,0 +1,3 @@ +export { Tag } from './Tag'; +export { TagSeverity } from '@metamask/design-system-shared'; +export type { TagProps } from './Tag.types'; diff --git a/packages/design-system-react-native/src/components/index.ts b/packages/design-system-react-native/src/components/index.ts index 3afafcfdc..6256e7b7f 100644 --- a/packages/design-system-react-native/src/components/index.ts +++ b/packages/design-system-react-native/src/components/index.ts @@ -242,3 +242,6 @@ export type { SpinnerProps } from './temp-components/Spinner'; export { BannerAlert, BannerAlertSeverity } from './BannerAlert'; export type { BannerAlertProps } from './BannerAlert'; + +export { Tag, TagSeverity } from './Tag'; +export type { TagProps } from './Tag'; diff --git a/packages/design-system-shared/src/index.ts b/packages/design-system-shared/src/index.ts index 0367db0c7..4f3c81b05 100644 --- a/packages/design-system-shared/src/index.ts +++ b/packages/design-system-shared/src/index.ts @@ -114,3 +114,6 @@ export { // Checkbox types (ADR-0004) export { type CheckboxPropsShared } from './types/Checkbox'; + +// Tag types (ADR-0003 + ADR-0004) +export { TagSeverity, type TagPropsShared } from './types/Tag'; diff --git a/packages/design-system-shared/src/types/Tag/Tag.types.ts b/packages/design-system-shared/src/types/Tag/Tag.types.ts new file mode 100644 index 000000000..acf395d5e --- /dev/null +++ b/packages/design-system-shared/src/types/Tag/Tag.types.ts @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; + +/** + * Tag severity values (ADR-0003). + * Shared across platforms so Figma / React / React Native stay aligned. + */ +export const TagSeverity = { + Neutral: 'neutral', + Success: 'success', + Error: 'error', + Warning: 'warning', + Info: 'info', +} as const; + +export type TagSeverity = (typeof TagSeverity)[keyof typeof TagSeverity]; + +/** + * Tag component shared props (ADR-0004). + * Platform-independent properties shared across React and React Native. + */ +export type TagPropsShared = { + /** + * Semantic severity (background, default text color for string children, and icon tint). + * Aligns with `BannerAlert` and `IconAlert`, which use `severity` for the same class of states. + * + * @default TagSeverity.Neutral + */ + severity?: TagSeverity; + /** + * Content inside the tag. String children are wrapped in design-system `Text` with tag typography; other nodes render unchanged. + */ + children?: ReactNode; + /** + * Optional node at the start of the tag when no start icon is set (e.g. custom glyph or badge). + */ + startAccessory?: ReactNode; + /** + * Optional node at the end of the tag when no end icon is set. + */ + endAccessory?: ReactNode; +}; diff --git a/packages/design-system-shared/src/types/Tag/index.ts b/packages/design-system-shared/src/types/Tag/index.ts new file mode 100644 index 000000000..1da9e8a59 --- /dev/null +++ b/packages/design-system-shared/src/types/Tag/index.ts @@ -0,0 +1 @@ +export { TagSeverity, type TagPropsShared } from './Tag.types';