-
-
Notifications
You must be signed in to change notification settings - Fork 11
feat: Add Tag component for React Native #1053
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 5 commits
2524b7c
31473dc
4b4bc84
55a1aac
98b6cd0
338b885
087b91e
e561a9d
0aca470
6e60b43
6ee5cf7
55754c2
3b6ee42
5399006
38d1a14
5ac9e7a
6d575c8
a409bac
cdbff40
7dd19be
aa96b26
c464af6
7d5d89b
ca8046c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 = { | ||
| Neutral: 'neutral', | ||
| Success: 'success', | ||
| Error: 'error', | ||
| Warning: 'warning', | ||
| Info: 'info', | ||
| } as const; | ||
| export type TagVariant = (typeof TagVariant)[keyof typeof TagVariant]; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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', { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Figma property is mistmatched severity: figma.enum('severity', {
neutral: TagSeverity.Neutral,
info: TagSeverity.Info,
success: TagSeverity.Success,
error: TagSeverity.Error,
warning: TagSeverity.Warning,
})Hold off on publishing (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed: chore: align figma <> code
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }), | ||
|
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, | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Per our component documentation standards, the first story must always be 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:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
| }; | ||
| 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(); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in commits