Skip to content

Commit 9d25177

Browse files
feat: Add component scaffold skill for metamask mobile
1 parent d23becf commit 9d25177

2 files changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
---
2+
repo: metamask-mobile
3+
parent: component-scaffold
4+
---
5+
6+
# Component Scaffold — MetaMask Mobile
7+
8+
---
9+
10+
## 1. Where to put the component
11+
12+
| Scope | Location |
13+
|-------|----------|
14+
| Shared across features | `app/components/UI/<Feature>/` |
15+
| Feature-internal composite | `app/components/UI/<Feature>/components/<ComponentName>/` |
16+
| Screen / route | `app/components/Views/<ScreenName>/` |
17+
| Feature-internal only (not exported) | nested `components/` subdir inside the feature folder |
18+
19+
**Naming rules:**
20+
- Component directory: `PascalCase` matching the component name exactly (`AccessRestrictedModal/`, `EarnHeaderSubtitle/`)
21+
- Feature container folders are lowercase: `components/`, `hooks/`, `utils/`, `types/`
22+
- Files: `PascalCase` prefix with a dotted role suffix (`Foo.tsx`, `Foo.types.ts`, `Foo.testIds.ts`, `Foo.test.tsx`)
23+
- Barrel: lowercase `index.ts`
24+
25+
---
26+
27+
## 2. Required file set
28+
29+
Tests are **colocated** — do NOT create a separate `__tests__/` folder.
30+
31+
```
32+
ComponentName/
33+
ComponentName.tsx ← always required
34+
ComponentName.types.ts ← recommended; inline interface only for trivial components
35+
ComponentName.testIds.ts ← recommended; skip only when no testable elements exist
36+
ComponentName.test.tsx ← MANDATORY, colocated next to the component
37+
index.ts ← MANDATORY
38+
```
39+
40+
Optional situational files:
41+
- `ComponentName.constants.ts` — when the component has non-trivial magic values
42+
- Co-located `useComponentName.ts` hooks — when logic warrants extraction
43+
- `README.md` — only for larger feature-level folders
44+
45+
---
46+
47+
## 3. Design system: which layer to use
48+
49+
Two systems coexist in the repo:
50+
51+
### Layer 1 — `@metamask/design-system-react-native` (MMDS, npm package) ✅ Always use first
52+
53+
The only correct choice for new component primitives. Exports `Box`, `Text`, `BottomSheet`, `BottomSheetHeader`, `BottomSheetFooter`, `ButtonBase`, `ButtonIcon`, `HeaderStandard`, `Icon`, `Skeleton`, `Tag`, and more.
54+
55+
Before writing any UI, verify what the installed version actually exports — the package changes frequently:
56+
57+
```
58+
node_modules/@metamask/design-system-react-native/dist/components/index.d.cts
59+
```
60+
61+
If the file layout differs, also try:
62+
```
63+
node_modules/@metamask/design-system-react-native/dist/components/index.d.ts
64+
node_modules/@metamask/design-system-react-native/src/components/index.ts
65+
```
66+
67+
The installed package is the source of truth, not this skill's examples.
68+
69+
For local visual testing of a component in isolation, see `docs/readme/storybook.md`.
70+
71+
### Layer 2 — `app/component-library/components/` ❌ Deprecated
72+
73+
In-repo components carrying `@deprecated` JSDoc. Only touch these when working on legacy code where refactoring is explicitly out of scope. Never use in new components — every primitive that has an MMDS equivalent must come from the package.
74+
75+
### Layer 3 — Feature-specific composites
76+
77+
Build from MMDS primitives when no primitive covers the use case.
78+
79+
### Never use
80+
- Raw `View` from `react-native` — use `Box`
81+
- Raw `Text` from `react-native` without variants — use `Text` with `TextVariant`
82+
- `StyleSheet.create()` — use `twClassName` or Box layout props
83+
84+
---
85+
86+
## 4. Styling patterns
87+
88+
### `twClassName` string prop — for utility classes
89+
90+
```tsx
91+
<Box twClassName="px-4 pb-6 w-full rounded-xl bg-muted" />
92+
```
93+
94+
Use for: width/height, borders, rounded corners, shadows, opacity, overflow, z-index, absolute positioning, background colours via semantic tokens (`bg-default`, `bg-muted`, `bg-pressed`).
95+
96+
### Box layout enum props — preferred for flexbox
97+
98+
```tsx
99+
<Box
100+
flexDirection={BoxFlexDirection.Row}
101+
alignItems={BoxAlignItems.Center}
102+
justifyContent={BoxJustifyContent.Between}
103+
gap={2}
104+
padding={4}
105+
/>
106+
```
107+
108+
Type-safe, prevents class-string typos. Spacing units: each unit = 4px (`padding={4}` = 16px, max `12` = 48px).
109+
110+
### Enum constants for props
111+
112+
Import from `@metamask/design-system-react-native` — never use raw strings:
113+
114+
```tsx
115+
import {
116+
TextVariant,
117+
TextColor,
118+
FontWeight,
119+
ButtonBaseSize,
120+
BoxFlexDirection,
121+
BoxAlignItems,
122+
BoxJustifyContent,
123+
BoxBackgroundColor,
124+
} from '@metamask/design-system-react-native';
125+
```
126+
127+
### Interactive / pressed state
128+
129+
```tsx
130+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
131+
132+
const tw = useTailwind();
133+
134+
<ButtonBase
135+
style={({ pressed }) =>
136+
tw.style('w-full flex-row items-center', pressed && 'bg-pressed')
137+
}
138+
/>
139+
```
140+
141+
---
142+
143+
## 5. Bottom sheet specifics
144+
145+
1. Apply `useElevatedSurface()` to the `BottomSheet`'s `twClassName` — required for pure-black theme support:
146+
147+
```tsx
148+
import { useElevatedSurface } from '../../../../util/theme/themeUtils'; // adjust depth
149+
150+
const surfaceClass = useElevatedSurface();
151+
152+
<BottomSheet twClassName={surfaceClass}>
153+
```
154+
155+
2. `ScrollView` inside a `BottomSheet` must come from `react-native-gesture-handler` — the standard React Native `ScrollView` will not scroll on Android inside a gesture-managed bottom sheet:
156+
157+
```tsx
158+
// ✅ correct
159+
import { ScrollView } from 'react-native-gesture-handler';
160+
161+
// ❌ will not scroll on Android
162+
import { ScrollView } from 'react-native';
163+
```
164+
165+
---
166+
167+
## 6. i18n
168+
169+
All user-visible strings must go through i18n:
170+
171+
```tsx
172+
import { strings } from '../../../../locales/i18n'; // adjust depth to file location
173+
174+
{strings('namespace.key')}
175+
```
176+
177+
Add new keys to `app/locales/languages/en.json` only. Crowdin picks up new English strings automatically after the PR merges — do not edit other language files manually.
178+
179+
---
180+
181+
## 7. Imports: no path aliases
182+
183+
`tsconfig.json` defines no `@components` or `@app` path aliases. All imports are relative. Compute the correct `../` depth based on the file's actual location.
184+
185+
Example from `app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx`:
186+
```tsx
187+
import { strings } from '../../../../../locales/i18n';
188+
import { useElevatedSurface } from '../../../../util/theme/themeUtils';
189+
```
190+
191+
---
192+
193+
## 8. ESLint import fences
194+
195+
| Blocked import | Use instead |
196+
|---------------|-------------|
197+
| `expo-haptics` | `app/util/haptics` |
198+
| `app/util/number/index.js` | `app/util/number/bigint` |
199+
| Sibling feature directories (route-isolation zones) | Only import from your own feature or shared `app/components/UI/` |
200+
201+
- One allowed `eslint-disable`: `// eslint-disable-next-line @typescript-eslint/no-require-imports` inside a `jest.mock` factory that uses `require('react-native')`
202+
203+
---
204+
205+
## 9. File templates
206+
207+
Replace `Foo` with the `PascalCase` component name and `foo` with its `kebab-case` form. Adjust `../` import depth to match the actual file location.
208+
209+
### `Foo.types.ts`
210+
211+
```ts
212+
export interface FooProps {
213+
/**
214+
* Whether the component is visible.
215+
*/
216+
isVisible: boolean;
217+
/**
218+
* Callback fired when the user dismisses the component.
219+
*/
220+
onClose: () => void;
221+
/**
222+
* Optional test ID for the root element.
223+
*/
224+
testID?: string;
225+
}
226+
```
227+
228+
### `Foo.testIds.ts`
229+
230+
```ts
231+
export const FooSelectorsIDs = {
232+
CONTAINER: 'foo',
233+
TITLE: 'foo-title',
234+
} as const;
235+
```
236+
237+
- Export name: `<ComponentName>SelectorsIDs`
238+
- Keys: `SCREAMING_SNAKE_CASE`
239+
- Values: `dash-case`, prefixed with the component's kebab-case name
240+
241+
**List items:** when the component renders in a list, append a unique data value at render time to avoid duplicate testIDs:
242+
243+
```tsx
244+
// testIds.ts
245+
ITEM: 'foo-item',
246+
247+
// usage
248+
testID={`${FooSelectorsIDs.ITEM}-${item.id}`}
249+
```
250+
251+
### `Foo.tsx`
252+
253+
```tsx
254+
import React from 'react';
255+
import {
256+
Box,
257+
Text,
258+
TextColor,
259+
TextVariant,
260+
} from '@metamask/design-system-react-native';
261+
import { strings } from '../../../../locales/i18n'; // adjust depth
262+
import { FooProps } from './Foo.types';
263+
import { FooSelectorsIDs } from './Foo.testIds';
264+
265+
const Foo: React.FC<FooProps> = ({
266+
isVisible,
267+
onClose,
268+
testID = FooSelectorsIDs.CONTAINER,
269+
}) => {
270+
if (!isVisible) return null;
271+
272+
return (
273+
<Box twClassName="px-4 pb-6" testID={testID}>
274+
<Text
275+
variant={TextVariant.BodyMd}
276+
color={TextColor.TextAlternative}
277+
testID={FooSelectorsIDs.TITLE}
278+
>
279+
{strings('foo.title')}
280+
</Text>
281+
</Box>
282+
);
283+
};
284+
285+
export default Foo;
286+
```
287+
288+
> For a bottom sheet, add `useElevatedSurface()` — see Section 5.
289+
290+
### `index.ts`
291+
292+
```ts
293+
export { default } from './Foo';
294+
export type { FooProps } from './Foo.types';
295+
export { FooSelectorsIDs } from './Foo.testIds';
296+
```
297+
298+
### `Foo.test.tsx`
299+
300+
```tsx
301+
import React from 'react';
302+
import { fireEvent } from '@testing-library/react-native';
303+
import renderWithProvider from '../../../../util/test/renderWithProvider'; // adjust depth
304+
import Foo from './Foo';
305+
import { FooSelectorsIDs } from './Foo.testIds';
306+
307+
describe('Foo', () => {
308+
const defaultProps = {
309+
isVisible: true,
310+
onClose: jest.fn(),
311+
};
312+
313+
beforeEach(() => {
314+
jest.clearAllMocks();
315+
});
316+
317+
it('renders nothing when isVisible is false', () => {
318+
const { queryByTestId } = renderWithProvider(
319+
<Foo {...defaultProps} isVisible={false} />,
320+
);
321+
322+
expect(queryByTestId(FooSelectorsIDs.CONTAINER)).toBeNull();
323+
});
324+
325+
it('renders the title when visible', () => {
326+
const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />);
327+
328+
expect(getByTestId(FooSelectorsIDs.TITLE)).toBeOnTheScreen();
329+
});
330+
331+
it('calls onClose when dismissed', () => {
332+
const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />);
333+
334+
fireEvent.press(getByTestId(FooSelectorsIDs.CONTAINER));
335+
336+
expect(defaultProps.onClose).toHaveBeenCalledTimes(1);
337+
});
338+
});
339+
```
340+
341+
Use `renderWithProvider` when the component:
342+
- reads from the Redux store (selectors, hooks like `useSelector`)
343+
- dispatches actions
344+
- accesses theme tokens via hooks
345+
346+
Use `render` from `@testing-library/react-native` when the component only receives props and has no store/theme dependency.
347+
348+
---
349+
350+
## 10. Parent barrel registration
351+
352+
Register in the feature-level `index.ts` only when the component is consumed outside its own folder.
353+
354+
Add to `app/components/UI/<Feature>/index.ts`:
355+
356+
```ts
357+
export { default as Foo } from './Foo'; // converts the default export to a named export
358+
export type { FooProps } from './Foo';
359+
export { FooSelectorsIDs } from './Foo';
360+
```
361+
362+
Re-exports point at the component's own `index.ts` (`./Foo`), not directly at the implementation file.
363+
364+
---
365+
366+
## 11. Scaffold checklist
367+
368+
- [ ] Directory with `PascalCase` name in the correct location (Section 1)
369+
- [ ] `Foo.types.ts` — `FooProps` interface with JSDoc per prop, optional `testID?: string`
370+
- [ ] `Foo.testIds.ts` — `FooSelectorsIDs as const`, `SCREAMING_SNAKE` keys, `dash-case` values prefixed with kebab component name
371+
- [ ] `Foo.tsx` — all imports from `@metamask/design-system-react-native`; no `View`, no `StyleSheet`; `strings()` for all user-visible copy; every asserted element has `testID` wired from the testIds constant
372+
- [ ] `index.ts` — exports `default`, `type FooProps`, and `FooSelectorsIDs`
373+
- [ ] `Foo.test.tsx` — colocated (not in `__tests__/`); testIds via constant (never raw strings); `toBeOnTheScreen()` for presence, `.toBeNull()` for absence; `beforeEach(jest.clearAllMocks)`
374+
- [ ] ESLint and TypeScript pass: no `any`, no `eslint-disable`, no import fence violations
375+
- [ ] If consumed outside the folder: parent feature `index.ts` updated (Section 10)
376+
- [ ] If bottom sheet: `useElevatedSurface()` applied; `ScrollView` from `react-native-gesture-handler`
377+
- [ ] All user-visible strings use `strings()` — no hardcoded copy
378+
379+
@app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx
380+
@app/components/UI/Earn/components/EarnHeaderSubtitle/EarnHeaderSubtitle.tsx
381+
@app/component-library/components/design-system.stories.tsx
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
name: component-scaffold
3+
description: >-
4+
Scaffolds (initializes) a new React Native component for MetaMask
5+
Mobile. Use when the user asks to create, add, generate, scaffold,
6+
initialize, or set up a new component, UI component, or feature
7+
component.
8+
---

0 commit comments

Comments
 (0)