Skip to content
Draft
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
14 changes: 14 additions & 0 deletions apps/storybook-react-native/.storybook/storybook.requires.js

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,110 @@
import { useTailwind } from '@metamask/design-system-twrnc-preset';
import type { Meta, StoryObj } from '@storybook/react-native';
import React from 'react';
import { View } from 'react-native';

import { ButtonIcon } from '../ButtonIcon';
import { Box } from '../Box';

Check failure on line 7 in packages/design-system-react-native/src/components/Attribution/Attribution.stories.tsx

View workflow job for this annotation

GitHub Actions / Lint, build, and test / Lint (22.x)

`../Box` import should occur before import of `../ButtonIcon`
import { IconName } from '../Icon';
import { TextColor, TextVariant } from '../Text';

import { Attribution } from './Attribution';
import type { AttributionProps } from './Attribution.types';

const meta: Meta<AttributionProps> = {
title: 'Components/Attribution',
component: Attribution,
args: {
children: 'Powered by MetaMask',
},
argTypes: {
children: { control: 'text' },
textProps: { control: 'object' },
startAccessory: { control: false },
endAccessory: { control: false },
},
};

export default meta;
type Story = StoryObj<AttributionProps>;

export const Default: Story = {
render: (args) => {
const tw = useTailwind();
return (
<View style={tw`p-4`}>
<Attribution {...args} />
</View>
);
},
};

export const StartAccessory: Story = {
render: (args) => {
const tw = useTailwind();
return (
<View style={tw`p-4`}>
<Attribution
{...args}
startAccessory={
<Box twClassName="w-6 h-6 rounded-full bg-primary-default" />
}
>
{args.children}
</Attribution>
</View>
);
},
};

export const EndAccessory: Story = {
render: (args) => {
const tw = useTailwind();
return (
<View style={tw`p-4`}>
<Attribution
{...args}
endAccessory={<ButtonIcon iconName={IconName.Info} />}
>
{args.children}
</Attribution>
</View>
);
},
};

export const StartAndEndAccessories: Story = {
render: (args) => {
const tw = useTailwind();
return (
<View style={tw`p-4`}>
<Attribution
{...args}
startAccessory={<Box twClassName="w-5 h-5 rounded bg-icon-default" />}
endAccessory={<ButtonIcon iconName={IconName.Info} />}
>
{args.children}
</Attribution>
</View>
);
},
};

export const TextPropsOverride: Story = {
render: (args) => {
const tw = useTailwind();
return (
<View style={tw`p-4`}>
<Attribution
{...args}
textProps={{
variant: TextVariant.BodyMd,
color: TextColor.TextDefault,
}}
>
Custom variant and color
</Attribution>
</View>
);
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 { TextColor, TextVariant } from '../Text';

import { Attribution } from './Attribution';

const ROOT_TEST_ID = 'attribution-root';

describe('Attribution', () => {
let tw: ReturnType<typeof useTailwind>;

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

describe('when children is a string', () => {
it('renders text content', () => {
const { getByText } = render(
<Attribution>Powered by MetaMask</Attribution>,
);
expect(getByText('Powered by MetaMask')).toBeOnTheScreen();
});

it('applies default textProps (BodySm, TextAlternative)', () => {
const { getByText } = render(<Attribution>Label</Attribution>);
const textNode = getByText('Label');
const styles = [textNode.props.style].flat();
const color = styles.find(
(s: Record<string, unknown>) => s?.color !== undefined,
)?.color;
expect(color).toBe(tw.style(TextColor.TextAlternative).color);
const fontSize = styles.find(
(s: Record<string, unknown>) => s?.fontSize !== undefined,
)?.fontSize;
expect(fontSize).toBe(tw.style(`text-${TextVariant.BodySm}`).fontSize);
});
});

describe('when children is not a string', () => {
it('renders child components', () => {
const { getByText } = render(
<Attribution>
<Text>Nested content</Text>
</Attribution>,
);
expect(getByText('Nested content')).toBeOnTheScreen();
});
});

describe('when textProps is provided', () => {
it('merges textProps over defaults', () => {
const { getByText } = render(
<Attribution
textProps={{
variant: TextVariant.BodyMd,
color: TextColor.TextDefault,
}}
>
Custom
</Attribution>,
);
const textNode = getByText('Custom');
const styles = [textNode.props.style].flat();
const color = styles.find(
(s: Record<string, unknown>) => s?.color !== undefined,
)?.color;
expect(color).toBe(tw.style(TextColor.TextDefault).color);
const fontSize = styles.find(
(s: Record<string, unknown>) => s?.fontSize !== undefined,
)?.fontSize;
expect(fontSize).toBe(tw.style(`text-${TextVariant.BodyMd}`).fontSize);
});
});

describe('when startAccessory is provided', () => {
it('renders startAccessory before text', () => {
const { getByTestId, getByText } = render(
<Attribution
testID={ROOT_TEST_ID}
startAccessory={<Text testID="start-icon">S</Text>}
>
Label
</Attribution>,
);
expect(getByTestId('start-icon')).toBeOnTheScreen();
expect(getByText('Label')).toBeOnTheScreen();
});
});

describe('when endAccessory is provided', () => {
it('renders endAccessory after text', () => {
const { getByTestId, getByText } = render(
<Attribution
testID={ROOT_TEST_ID}
endAccessory={<Text testID="end-badge">Badge</Text>}
>
Label
</Attribution>,
);
expect(getByText('Label')).toBeOnTheScreen();
expect(getByTestId('end-badge')).toBeOnTheScreen();
});
});

describe('root layout', () => {
it('applies default gap-2 and merges twClassName', () => {
const { getByTestId } = render(
<Attribution testID={ROOT_TEST_ID} twClassName="p-2">
Content
</Attribution>,
);
const root = getByTestId(ROOT_TEST_ID);
expect(root).toHaveStyle(tw`gap-2 p-2`);
});

it('applies only gap-2 when twClassName is not passed', () => {
const { getByTestId } = render(
<Attribution testID={ROOT_TEST_ID}>Content</Attribution>,
);
const root = getByTestId(ROOT_TEST_ID);
expect(root).toHaveStyle(tw`gap-2`);
});
});

describe('ViewProps extension', () => {
it('passes testID to root', () => {
const { getByTestId } = render(
<Attribution testID={ROOT_TEST_ID}>Content</Attribution>,
);
expect(getByTestId(ROOT_TEST_ID)).toBeOnTheScreen();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React from 'react';

import { TextColor, TextVariant } from '../../types';
import { TextWithAccessories } from '../temp-components/TextWithAccessories';

import type { AttributionProps } from './Attribution.types';

export const Attribution = ({
textProps,
twClassName,
...rest
}: AttributionProps) => (
<TextWithAccessories
textProps={{
...textProps,
variant: TextVariant.BodySm,
color: TextColor.TextAlternative,
twClassName: `flex-1 ${textProps?.twClassName ?? ''}`.trim(),
}}
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.

textProps spread order prevents user overrides

High Severity

The textProps object spreads the user-provided textProps first, then hardcodes variant and color after, meaning the explicit defaults always win. Users can never override variant or color via textProps. The defaults need to come before the spread ({ variant: BodySm, color: TextAlternative, ...textProps, twClassName: ... }) so user-supplied values take precedence. This breaks the TextPropsOverride story and the "merges textProps over defaults" test, and contradicts the README which states overrides are supported.

Fix in Cursor Fix in Web

twClassName={`gap-2 ${twClassName ?? ''}`.trim()}
{...rest}
/>
);

Attribution.displayName = 'Attribution';
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { TextWithAccessoriesProps } from '../temp-components/TextWithAccessories/TextWithAccessories.types';

export type AttributionProps = TextWithAccessoriesProps;
Loading
Loading