|
| 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 |
0 commit comments