|
| 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/` |
| 72 | + |
| 73 | +Valid second choice **only** when `@metamask/design-system-react-native` does not export an equivalent (e.g. Tabs, MetaMask-specific modal wrappers not yet migrated to MMDS). |
| 74 | + |
| 75 | +Do not use any component in this library that carries a `@deprecated` JSDoc annotation — those have MMDS equivalents and must be replaced. For everything else in this library, prefer MMDS first; use `app/component-library` only when no MMDS primitive covers the need. |
| 76 | + |
| 77 | +### Layer 3 — Feature-specific composites |
| 78 | + |
| 79 | +Build from MMDS primitives when no primitive covers the use case. |
| 80 | + |
| 81 | +### Never use |
| 82 | +- Raw `View` from `react-native` — use `Box` |
| 83 | +- Raw `Text` from `react-native` without variants — use `Text` with `TextVariant` |
| 84 | +- `StyleSheet.create()` — use `twClassName` or Box layout props |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## 4. Styling patterns |
| 89 | + |
| 90 | +### `twClassName` string prop — for utility classes |
| 91 | + |
| 92 | +```tsx |
| 93 | +<Box twClassName="px-4 pb-6 w-full rounded-xl bg-muted" /> |
| 94 | +``` |
| 95 | + |
| 96 | +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`). |
| 97 | + |
| 98 | +### Box layout enum props — preferred for flexbox |
| 99 | + |
| 100 | +```tsx |
| 101 | +<Box |
| 102 | + flexDirection={BoxFlexDirection.Row} |
| 103 | + alignItems={BoxAlignItems.Center} |
| 104 | + justifyContent={BoxJustifyContent.Between} |
| 105 | + gap={2} |
| 106 | + padding={4} |
| 107 | +/> |
| 108 | +``` |
| 109 | + |
| 110 | +Type-safe, prevents class-string typos. Spacing units: each unit = 4px (`padding={4}` = 16px, max `12` = 48px). |
| 111 | + |
| 112 | +### Enum constants for props |
| 113 | + |
| 114 | +Import from `@metamask/design-system-react-native` — never use raw strings: |
| 115 | + |
| 116 | +```tsx |
| 117 | +import { |
| 118 | + TextVariant, |
| 119 | + TextColor, |
| 120 | + FontWeight, |
| 121 | + ButtonBaseSize, |
| 122 | + BoxFlexDirection, |
| 123 | + BoxAlignItems, |
| 124 | + BoxJustifyContent, |
| 125 | + BoxBackgroundColor, |
| 126 | +} from '@metamask/design-system-react-native'; |
| 127 | +``` |
| 128 | + |
| 129 | +### Interactive / pressed state |
| 130 | + |
| 131 | +```tsx |
| 132 | +import { useTailwind } from '@metamask/design-system-twrnc-preset'; |
| 133 | + |
| 134 | +const tw = useTailwind(); |
| 135 | + |
| 136 | +<ButtonBase |
| 137 | + style={({ pressed }) => |
| 138 | + tw.style('w-full flex-row items-center', pressed && 'bg-pressed') |
| 139 | + } |
| 140 | +/> |
| 141 | +``` |
| 142 | + |
| 143 | +--- |
| 144 | + |
| 145 | +## 5. Bottom sheet specifics |
| 146 | + |
| 147 | +1. Apply `useElevatedSurface()` to the `BottomSheet`'s `twClassName` — required for pure-black theme support: |
| 148 | + |
| 149 | +```tsx |
| 150 | +import { useElevatedSurface } from '../../../../util/theme/themeUtils'; // adjust depth |
| 151 | + |
| 152 | +const surfaceClass = useElevatedSurface(); |
| 153 | + |
| 154 | +<BottomSheet twClassName={surfaceClass}> |
| 155 | +``` |
| 156 | + |
| 157 | +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: |
| 158 | + |
| 159 | +```tsx |
| 160 | +// ✅ correct |
| 161 | +import { ScrollView } from 'react-native-gesture-handler'; |
| 162 | + |
| 163 | +// ❌ will not scroll on Android |
| 164 | +import { ScrollView } from 'react-native'; |
| 165 | +``` |
| 166 | + |
| 167 | +--- |
| 168 | + |
| 169 | +## 6. i18n |
| 170 | + |
| 171 | +All user-visible strings must go through i18n: |
| 172 | + |
| 173 | +```tsx |
| 174 | +import { strings } from '../../../../locales/i18n'; // adjust depth to file location |
| 175 | + |
| 176 | +{strings('namespace.key')} |
| 177 | +``` |
| 178 | + |
| 179 | +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. |
| 180 | + |
| 181 | +--- |
| 182 | + |
| 183 | +## 7. Imports: no path aliases |
| 184 | + |
| 185 | +`tsconfig.json` defines no `@components` or `@app` path aliases. All imports are relative. Compute the correct `../` depth based on the file's actual location. |
| 186 | + |
| 187 | +Example from `app/components/UI/Compliance/AccessRestrictedModal/AccessRestrictedModal.tsx`: |
| 188 | +```tsx |
| 189 | +import { strings } from '../../../../../locales/i18n'; |
| 190 | +import { useElevatedSurface } from '../../../../util/theme/themeUtils'; |
| 191 | +``` |
| 192 | + |
| 193 | +--- |
| 194 | + |
| 195 | +## 8. ESLint import fences |
| 196 | + |
| 197 | +| Blocked import | Use instead | |
| 198 | +|---------------|-------------| |
| 199 | +| `expo-haptics` | `app/util/haptics` | |
| 200 | +| `app/util/number/index.js` | `app/util/number/bigint` | |
| 201 | +| Sibling feature directories (route-isolation zones) | Only import from your own feature or shared `app/components/UI/` | |
| 202 | + |
| 203 | +- One allowed `eslint-disable`: `// eslint-disable-next-line @typescript-eslint/no-require-imports` inside a `jest.mock` factory that uses `require('react-native')` |
| 204 | + |
| 205 | +--- |
| 206 | + |
| 207 | +## 9. File templates |
| 208 | + |
| 209 | +Replace `Foo` with the `PascalCase` component name and `foo` with its `kebab-case` form. Adjust `../` import depth to match the actual file location. |
| 210 | + |
| 211 | +### `Foo.types.ts` |
| 212 | + |
| 213 | +```ts |
| 214 | +export interface FooProps { |
| 215 | + /** |
| 216 | + * Whether the component is visible. |
| 217 | + */ |
| 218 | + isVisible: boolean; |
| 219 | + /** |
| 220 | + * Callback fired when the user dismisses the component. |
| 221 | + */ |
| 222 | + onClose: () => void; |
| 223 | + /** |
| 224 | + * Optional test ID for the root element. |
| 225 | + */ |
| 226 | + testID?: string; |
| 227 | +} |
| 228 | +``` |
| 229 | + |
| 230 | +### `Foo.testIds.ts` |
| 231 | + |
| 232 | +```ts |
| 233 | +export const FooSelectorsIDs = { |
| 234 | + CONTAINER: 'foo', |
| 235 | + TITLE: 'foo-title', |
| 236 | +} as const; |
| 237 | +``` |
| 238 | + |
| 239 | +- Export name: `<ComponentName>SelectorsIDs` |
| 240 | +- Keys: `SCREAMING_SNAKE_CASE` |
| 241 | +- Values: `dash-case`, prefixed with the component's kebab-case name |
| 242 | + |
| 243 | +**List items:** when the component renders in a list, append a unique data value at render time to avoid duplicate testIDs: |
| 244 | + |
| 245 | +```tsx |
| 246 | +// testIds.ts |
| 247 | +ITEM: 'foo-item', |
| 248 | + |
| 249 | +// usage |
| 250 | +testID={`${FooSelectorsIDs.ITEM}-${item.id}`} |
| 251 | +``` |
| 252 | + |
| 253 | +### `Foo.tsx` |
| 254 | + |
| 255 | +```tsx |
| 256 | +import React from 'react'; |
| 257 | +import { |
| 258 | + Box, |
| 259 | + ButtonBase, |
| 260 | + Text, |
| 261 | + TextColor, |
| 262 | + TextVariant, |
| 263 | +} from '@metamask/design-system-react-native'; |
| 264 | +import { useTailwind } from '@metamask/design-system-twrnc-preset'; |
| 265 | +import { strings } from '../../../../locales/i18n'; // adjust depth |
| 266 | +import { FooProps } from './Foo.types'; |
| 267 | +import { FooSelectorsIDs } from './Foo.testIds'; |
| 268 | + |
| 269 | +const Foo: React.FC<FooProps> = ({ |
| 270 | + isVisible, |
| 271 | + onClose, |
| 272 | + testID = FooSelectorsIDs.CONTAINER, |
| 273 | +}) => { |
| 274 | + const tw = useTailwind(); |
| 275 | + |
| 276 | + if (!isVisible) return null; |
| 277 | + |
| 278 | + return ( |
| 279 | + <ButtonBase |
| 280 | + onPress={onClose} |
| 281 | + testID={testID} |
| 282 | + style={({ pressed }) => |
| 283 | + tw.style('px-4 pb-6', pressed && 'bg-pressed') |
| 284 | + } |
| 285 | + > |
| 286 | + <Text |
| 287 | + variant={TextVariant.BodyMd} |
| 288 | + color={TextColor.TextAlternative} |
| 289 | + testID={FooSelectorsIDs.TITLE} |
| 290 | + > |
| 291 | + {strings('foo.title')} |
| 292 | + </Text> |
| 293 | + </ButtonBase> |
| 294 | + ); |
| 295 | +}; |
| 296 | + |
| 297 | +export default Foo; |
| 298 | +``` |
| 299 | + |
| 300 | +> For a bottom sheet, add `useElevatedSurface()` — see Section 5. |
| 301 | + |
| 302 | +### `index.ts` |
| 303 | + |
| 304 | +```ts |
| 305 | +export { default } from './Foo'; |
| 306 | +export type { FooProps } from './Foo.types'; |
| 307 | +export { FooSelectorsIDs } from './Foo.testIds'; |
| 308 | +``` |
| 309 | + |
| 310 | +### `Foo.test.tsx` |
| 311 | + |
| 312 | +```tsx |
| 313 | +import React from 'react'; |
| 314 | +import { fireEvent } from '@testing-library/react-native'; |
| 315 | +import renderWithProvider from '../../../../util/test/renderWithProvider'; // adjust depth |
| 316 | +import Foo from './Foo'; |
| 317 | +import { FooSelectorsIDs } from './Foo.testIds'; |
| 318 | + |
| 319 | +describe('Foo', () => { |
| 320 | + const defaultProps = { |
| 321 | + isVisible: true, |
| 322 | + onClose: jest.fn(), |
| 323 | + }; |
| 324 | + |
| 325 | + beforeEach(() => { |
| 326 | + jest.clearAllMocks(); |
| 327 | + }); |
| 328 | + |
| 329 | + it('renders nothing when isVisible is false', () => { |
| 330 | + const { queryByTestId } = renderWithProvider( |
| 331 | + <Foo {...defaultProps} isVisible={false} />, |
| 332 | + ); |
| 333 | + |
| 334 | + expect(queryByTestId(FooSelectorsIDs.CONTAINER)).toBeNull(); |
| 335 | + }); |
| 336 | + |
| 337 | + it('renders the title when visible', () => { |
| 338 | + const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />); |
| 339 | + |
| 340 | + expect(getByTestId(FooSelectorsIDs.TITLE)).toBeOnTheScreen(); |
| 341 | + }); |
| 342 | + |
| 343 | + it('calls onClose when dismissed', () => { |
| 344 | + const { getByTestId } = renderWithProvider(<Foo {...defaultProps} />); |
| 345 | + |
| 346 | + fireEvent.press(getByTestId(FooSelectorsIDs.CONTAINER)); |
| 347 | + |
| 348 | + expect(defaultProps.onClose).toHaveBeenCalledTimes(1); |
| 349 | + }); |
| 350 | +}); |
| 351 | +``` |
| 352 | + |
| 353 | +Use `renderWithProvider` when the component: |
| 354 | +- reads from the Redux store (selectors, hooks like `useSelector`) |
| 355 | +- dispatches actions |
| 356 | +- accesses theme tokens via hooks |
| 357 | + |
| 358 | +Use `render` from `@testing-library/react-native` when the component only receives props and has no store/theme dependency. |
| 359 | + |
| 360 | +--- |
| 361 | + |
| 362 | +## 10. Parent barrel registration |
| 363 | + |
| 364 | +Register in the feature-level `index.ts` only when the component is consumed outside its own folder. |
| 365 | + |
| 366 | +Add to `app/components/UI/<Feature>/index.ts`: |
| 367 | + |
| 368 | +```ts |
| 369 | +export { default as Foo } from './Foo'; // converts the default export to a named export |
| 370 | +export type { FooProps } from './Foo'; |
| 371 | +export { FooSelectorsIDs } from './Foo'; |
| 372 | +``` |
| 373 | + |
| 374 | +Re-exports point at the component's own `index.ts` (`./Foo`), not directly at the implementation file. |
| 375 | + |
| 376 | +--- |
| 377 | + |
| 378 | +## 11. Scaffold checklist |
| 379 | + |
| 380 | +- [ ] Directory with `PascalCase` name in the correct location (Section 1) |
| 381 | +- [ ] `Foo.types.ts` — recommended; `FooProps` interface with JSDoc per prop, optional `testID?: string`; inline interface acceptable only for trivial components with 1–2 props |
| 382 | +- [ ] `Foo.testIds.ts` — recommended; `FooSelectorsIDs as const`, `SCREAMING_SNAKE` keys, `dash-case` values prefixed with kebab component name; skip only when the component has no testable elements |
| 383 | +- [ ] `Foo.tsx` — primitives from `@metamask/design-system-react-native` (or `app/component-library` for MetaMask-specific components with no MMDS equivalent and no `@deprecated` annotation); no `View`, no `StyleSheet`; `strings()` for all user-visible copy; every asserted element has `testID` wired from the testIds constant |
| 384 | +- [ ] `index.ts` — exports `default`, `type FooProps`, and `FooSelectorsIDs` |
| 385 | +- [ ] `Foo.test.tsx` — colocated (not in `__tests__/`); testIds via constant (never raw strings); `toBeOnTheScreen()` for presence, `.toBeNull()` for absence; `beforeEach(jest.clearAllMocks)` |
| 386 | +- [ ] ESLint and TypeScript pass: no `any`, no `eslint-disable`, no import fence violations |
| 387 | +- [ ] If consumed outside the folder: parent feature `index.ts` updated (Section 10) |
| 388 | +- [ ] If bottom sheet: `useElevatedSurface()` applied; `ScrollView` from `react-native-gesture-handler` |
| 389 | +- [ ] All user-visible strings use `strings()` — no hardcoded copy |
| 390 | + |
0 commit comments