Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
2524b7c
feat: Add Tag component for React Native
Apr 8, 2026
31473dc
chore: resolve linting errors
Apr 8, 2026
4b4bc84
chore: fix prettier issues in readme
Apr 8, 2026
55a1aac
chore: add tests for coerage
Apr 8, 2026
98b6cd0
Merge branch 'main' into feat-tag/react-native
amandaye0h Apr 16, 2026
338b885
refactor: move Tag const assertion to shared types (ADR-0004)
amandaye0h Apr 16, 2026
087b91e
refactor: refine layout to inline styles and remove redundant Tailwin…
amandaye0h Apr 16, 2026
e561a9d
chore: update prop variant > severity to align with BannerAlert and I…
amandaye0h Apr 16, 2026
0aca470
refactor: rerender text to use BoxRow, remove label and textProps
amandaye0h Apr 16, 2026
6e60b43
chore: migrate start and endAccessory to shared props for consistency
amandaye0h Apr 16, 2026
6ee5cf7
chore: rename all TagVariant references to TagSeverity for consistency
amandaye0h Apr 16, 2026
55754c2
chore: align export styles
amandaye0h Apr 16, 2026
3b6ee42
chore: align figma <> code
amandaye0h Apr 16, 2026
5399006
chore: update storybook to add default story
amandaye0h Apr 16, 2026
38d1a14
chore: resolve linting issues
amandaye0h Apr 16, 2026
5ac9e7a
chore: update each file to match cursor rules
amandaye0h Apr 16, 2026
6d575c8
chore: fix linting issues in readme
amandaye0h Apr 16, 2026
a409bac
chore: fix inline style issues
amandaye0h Apr 17, 2026
cdbff40
Merge remote-tracking branch 'origin/main' into feat-tag/react-native
amandaye0h Apr 21, 2026
7dd19be
chore: update import paths to shared folder
amandaye0h Apr 21, 2026
aa96b26
chore: remove numeric children handling
amandaye0h Apr 21, 2026
c464af6
chore: update container styling to use tw.style
amandaye0h Apr 21, 2026
7d5d89b
chore: correct box style vs twClassName ordering
amandaye0h Apr 21, 2026
ca8046c
Merge branch 'main' into feat-tag/react-native
georgewrmarshall Apr 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 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';

<Tag>Default Example</Tag>;
```

## Props

### `children`

The content of the `Tag` component.

| TYPE | REQUIRED | DEFAULT |
| ----------- | -------- | ----------- |
| `ReactNode` | No | `undefined` |

```tsx
import { Tag } from '@metamask/design-system-react-native';

<Tag>
<Text>Custom children content</Text>
</Tag>;
```

### `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';

// Add additional styles
<Tag twClassName="mt-4">
<Text>Custom Background</Text>
</Tag>

// Override default styles
<Tag twClassName="bg-error-default">
<Text>Override Background</Text>
</Tag>
```

### `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<ViewStyle>` | No | `undefined` |

```tsx
import { useTailwind } from '@metamask/design-system-twrnc-preset';

export const ConditionalExample = ({ isActive }: { isActive: boolean }) => {
const tw = useTailwind();

return (
<Tag style={tw.style('bg-default', isActive && 'bg-success-default')}>
<Text>Conditional styling</Text>
</Tag>
);
};
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
BoxAlignItems,
BoxBackgroundColor,
BoxFlexDirection,
IconColor,
TextColor,
} from '../../types';

export const TagVariant = {
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per ADR-0004, this const assertion should live in @metamask/design-system-shared/src/types/Tag/ rather than the React Native package. This matters because when a React (web) Tag component is built later, both platforms need to share the same const object — if it's defined in the platform package there's a risk they drift out of sync.

The mapping constants below (MAP_TAG_VARIANT_BACKGROUND, etc.) are fine to keep here since they reference React Native-specific tokens.

You can prompt Cursor/Claude to handle the migration:

Following .cursor/rules/component-architecture.md ADR-0004 pattern, move TagVariant
from Tag.constants.ts into packages/design-system-shared/src/types/Tag/Tag.types.ts,
create a TagPropsShared type there, update Tag.types.ts to import and extend it,
and update index.ts to export TagVariant directly from @metamask/design-system-shared.
Reference BadgeStatus as the golden path example.

Copy link
Copy Markdown
Contributor Author

@amandaye0h amandaye0h Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neutral: 'neutral',
Success: 'success',
Error: 'error',
Warning: 'warning',
Info: 'info',
} as const;
export type TagVariant = (typeof TagVariant)[keyof typeof TagVariant];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be in the shared package (you can get Cursor to help you align these props with cursor rules

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commits here and here


export const MAP_TAG_VARIANT_BACKGROUND: Record<
TagVariant,
BoxBackgroundColor
> = {
[TagVariant.Neutral]: BoxBackgroundColor.BackgroundMuted,
[TagVariant.Success]: BoxBackgroundColor.SuccessMuted,
[TagVariant.Error]: BoxBackgroundColor.ErrorMuted,
[TagVariant.Warning]: BoxBackgroundColor.WarningMuted,
[TagVariant.Info]: BoxBackgroundColor.InfoMuted,
};

export const MAP_TAG_VARIANT_TEXT_COLOR: Record<TagVariant, TextColor> = {
[TagVariant.Neutral]: TextColor.TextDefault,
[TagVariant.Success]: TextColor.SuccessDefault,
[TagVariant.Error]: TextColor.ErrorDefault,
[TagVariant.Warning]: TextColor.WarningDefault,
[TagVariant.Info]: TextColor.InfoDefault,
};

export const MAP_TAG_VARIANT_ICON_COLOR: Record<TagVariant, IconColor> = {
[TagVariant.Neutral]: IconColor.IconDefault,
[TagVariant.Success]: IconColor.SuccessDefault,
[TagVariant.Error]: IconColor.ErrorDefault,
[TagVariant.Warning]: IconColor.WarningDefault,
[TagVariant.Info]: IconColor.InfoDefault,
};

/** Vertical padding (Tailwind `py-*`) */
export const TAG_PADDING_VERTICAL_TW = 'py-0';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TAG_PADDING_VERTICAL_TW = 'py-0' has no effect — py-0 is the Tailwind default, so this is a no-op. Remove it.

TAG_LAYOUT below is used in exactly one place. Single-use constants like this add indirection without benefit — a future engineer has to jump between files to understand the layout. Move these values inline into Tag.tsx directly.

Copy link
Copy Markdown
Contributor Author

@amandaye0h amandaye0h Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated

export const TAG_LAYOUT = {
flexDirection: BoxFlexDirection.Row,
alignItems: BoxAlignItems.Center,
twClassName: 'rounded-md self-start gap-0.5',
} as const;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these styling should not be stored as consts. It should just be used inline

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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 React from 'react';

import { IconName } from '../Icon';

import { Tag } from './Tag';

import { TagVariant } from '.';

/**
* Code Connect for Tag (React Native).
* If Dev Mode shows different property names, change the first argument to
* `figma.enum` / `figma.string` (e.g. `Type` instead of `variant`, `Text` instead of `Label`).
*/

figma.connect(
Tag,
'https://www.figma.com/design/1D6tnzXqWgnUC3spaAOELN/%F0%9F%A6%8A-MMDS-Components?node-id=12339-6553',
Copy link
Copy Markdown
Contributor Author

@amandaye0h amandaye0h Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this so there is better connection between code:design. It'll help with maintainability in the long term. I can remove it if this is premature at this stage of our DS.

If/when this PR is approved, I'll need to add the code link in Figma.

{
props: {
variant: figma.enum('variant', {
Copy link
Copy Markdown
Contributor

@georgewrmarshall georgewrmarshall Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Figma property is mistmatched type not variant. This mapping will silently produce no output in Dev Mode. Based on what the outcome is for variant/severity update to:

severity: figma.enum('severity', {
  neutral: TagSeverity.Neutral,
  info: TagSeverity.Info,
  success: TagSeverity.Success,
  error: TagSeverity.Error,
  warning: TagSeverity.Warning,
})

Hold off on publishing (yarn figma:connect:publish) until the severity/variant prop rename and info/primary Figma alignment are resolved. Once those are confirmed, run yarn figma:connect:publish:dry-run first to validate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screen.Recording.2026-04-16.at.8.07.16.PM.mov

Neutral: TagVariant.Neutral,
Success: TagVariant.Success,
Error: TagVariant.Error,
Warning: TagVariant.Warning,
Info: TagVariant.Info,
}),
Comment thread
cursor[bot] marked this conversation as resolved.
label: figma.string('Label'),
startIconName: figma.boolean('startIcon (Figma Only)', {
true: IconName.Tag,
false: undefined,
}),
endIconName: figma.boolean('endIcon (Figma Only)', {
true: IconName.Tag,
false: undefined,
}),
},
example: ({ variant, label, startIconName, endIconName }) => (
<Tag
variant={variant}
label={label}
startIconName={startIconName}
endIconName={endIconName}
/>
),
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { Meta, StoryObj } from '@storybook/react-native';
import React from 'react';

import { IconName } from '../Icon';

import { Tag } from './Tag';
import { TagVariant } from './Tag.constants';

const meta: Meta<typeof Tag> = {
title: 'Components/Tag',
component: Tag,
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per our component documentation standards, the first story must always be Default with minimal args and all controls wired up via argTypes. This powers the Storybook controls panel.

const meta: Meta<typeof Tag> = {
  title: 'Components/Tag',
  component: Tag,
  argTypes: {
    severity: {
      control: 'select',
      options: Object.values(TagSeverity),
    },
    startIconName: { control: 'select', options: Object.values(IconName) },
    endIconName: { control: 'select', options: Object.values(IconName) },
  },
};

export const Default: Story = {
  args: {
    children: 'Tag',
    severity: TagSeverity.Neutral,
  },
};

non-blocking: TagVariant is imported from './Tag.constants' — once moved to shared, import from '.' (the index) to match the pattern used across the codebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


export default meta;
type Story = StoryObj<typeof Tag>;

export const Variants: Story = {
render: () => (
<>
<Tag variant={TagVariant.Neutral}>Neutral</Tag>
<Tag variant={TagVariant.Success} twClassName="mt-2">
Success
</Tag>
<Tag variant={TagVariant.Error} twClassName="mt-2">
Error
</Tag>
<Tag variant={TagVariant.Warning} twClassName="mt-2">
Warning
</Tag>
<Tag variant={TagVariant.Info} twClassName="mt-2">
Info
</Tag>
</>
),
};

export const NoIcon: Story = {
render: () => <Tag label="Tag" />,
};

export const StartIcon: Story = {
render: () => <Tag startIconName={IconName.Warning} label="Tag" />,
};

export const EndIcon: Story = {
render: () => <Tag endIconName={IconName.ArrowRight} label="Tag" />,
};

const renderStartAndEndIcons = () => (
<Tag
startIconName={IconName.Warning}
endIconName={IconName.ArrowRight}
label="Tag"
/>
);

export const StartAndEndIcons: Story = {
render: renderStartAndEndIcons,
};
147 changes: 147 additions & 0 deletions packages/design-system-react-native/src/components/Tag/Tag.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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<typeof useTailwind>;

beforeAll(() => {
const { result } = renderHook(() => useTailwind());
tw = result.current;
});

it('renders children correctly', () => {
const { getByText } = render(
<Tag>
<Text>Hello, World!</Text>
</Tag>,
);

expect(getByText('Hello, World!')).toBeOnTheScreen();
});

it('applies the correct styles', () => {
const { getByTestId } = render(
<Tag twClassName="bg-default" testID="component-name">
<Text>Styled Content</Text>
</Tag>,
);

expect(getByTestId('component-name')).toHaveStyle(tw`bg-default`);
});

it('accepts testID prop', () => {
const { getByTestId } = render(
<Tag testID="component-name">
<Text>Test Content</Text>
</Tag>,
);

expect(getByTestId('component-name')).toBeOnTheScreen();
});

it('renders label prop as text', () => {
const { getByText } = render(
<Tag label="From label" testID="tag-with-label" />,
);

expect(getByText('From label')).toBeOnTheScreen();
});

it('renders string children with tag text styling when label is omitted', () => {
const { getByText } = render(<Tag>String child</Tag>);

expect(getByText('String child')).toBeOnTheScreen();
});

it('renders number children with tag text styling when label is omitted', () => {
const { getByText } = render(<Tag>{42}</Tag>);

expect(getByText('42')).toBeOnTheScreen();
});

it('prefers label over string children', () => {
const { getByText, queryByText } = render(
<Tag label="Label wins">Children lose</Tag>,
);

expect(getByText('Label wins')).toBeOnTheScreen();
expect(queryByText('Children lose')).not.toBeOnTheScreen();
});

it('renders start icon when startIconName is provided', () => {
const { getByTestId } = render(
<Tag
label="Tagged"
startIconName={IconName.Tag}
startIconProps={{ testID: 'tag-start-icon' }}
/>,
);

expect(getByTestId('tag-start-icon')).toBeOnTheScreen();
});

it('renders end icon when endIconName is provided', () => {
const { getByTestId } = render(
<Tag
label="Tagged"
endIconName={IconName.Tag}
endIconProps={{ testID: 'tag-end-icon' }}
/>,
);

expect(getByTestId('tag-end-icon')).toBeOnTheScreen();
});

it('resolves icon from startIconProps.name when startIconName is omitted', () => {
const { getByTestId } = render(
<Tag
label="From props"
startIconProps={{ name: IconName.Tag, testID: 'tag-start-icon' }}
/>,
);

expect(getByTestId('tag-start-icon')).toBeOnTheScreen();
});

it('does not render icons when no name is provided', () => {
const { queryByTestId } = render(
<Tag
label="No icons"
startIconProps={{ testID: 'tag-start-icon' }}
endIconProps={{ testID: 'tag-end-icon' }}
/>,
);

expect(queryByTestId('tag-start-icon')).not.toBeOnTheScreen();
expect(queryByTestId('tag-end-icon')).not.toBeOnTheScreen();
});

it('renders startAccessory when no start icon', () => {
const { getByTestId } = render(
<Tag
label="With accessory"
startAccessory={<Text testID="tag-start-accessory">→</Text>}
/>,
);

expect(getByTestId('tag-start-accessory')).toBeOnTheScreen();
});

it('renders endAccessory when no end icon', () => {
const { getByTestId } = render(
<Tag
label="With end accessory"
endAccessory={<Text testID="tag-end-accessory">←</Text>}
/>,
);

expect(getByTestId('tag-end-accessory')).toBeOnTheScreen();
});
});
Loading
Loading