diff --git a/.claude/skills/cds-migrator-transform/SKILL.md b/.claude/skills/cds-migrator-transform/SKILL.md new file mode 100644 index 0000000000..1532991740 --- /dev/null +++ b/.claude/skills/cds-migrator-transform/SKILL.md @@ -0,0 +1,133 @@ +--- +name: cds-migrator-transform +description: | + End-to-end workflow for adding a jscodeshift migration to packages/migrator: clarify symbol and + target behavior, decide web vs mobile vs shared transforms, research real usage with Sourcegraph MCP, + split automatable vs manual cases (confirm with user), implement codemods and TODO markers, add + fixtures and tests, register presets when applicable. Use when the user asks to add a CDS migrator + transform, codemod, jscodeshift migration, major-upgrade migration (e.g. v8-to-v9), or a + **standalone** codemod not tied to a version bump. Also when migrating consumer imports/APIs for a + CDS release. +allowed-tools: Read, Grep, Glob, StrReplace, Bash(yarn nx run:*), call_mcp_tool +argument-hint: ' — [preset or standalone] — [web|mobile|both] — [optional: Sourcegraph scope / repos / queries the user supplies]' +--- + +# CDS migrator transform (jscodeshift) + +Adds or updates a **jscodeshift** transform under `packages/migrator/src/transforms/`. + +**Where to put files** is **not** always a “version” folder. Choose a subdirectory (or root) that fits the work: + +- **Major / preset migrations** often use a version-style folder (`v9/`, `v10/`, …) aligned with a preset such as `v8-to-v9`. +- **Other codemods** (rename, internal API move, one-off cleanup) can live under any clear grouping the team agrees on (`v9/` still, a feature folder, or directly under `transforms/` like `example-transform.ts`). + +Follow the steps in order unless the user already locked scope. + +## Prerequisites + +- **Nx + yarn**: run migrator commands as `yarn nx run migrator:` (see repo `AGENTS.md`). +- **Sourcegraph MCP (strongly recommended)**: Before calling Sourcegraph tools, read the tool schema under `mcps/user-sourcegraph/tools/` (e.g. `sourcegraph_search.json`, `sourcegraph_fetch_file.json`). If Sourcegraph is not configured, tell the user to add the **Sourcegraph** MCP server in Cursor MCP settings and authenticate if required, then continue with workspace `grep` or whatever source the user provides. **Do not invent search queries or repo filters in this skill**—use the symbols, repositories, queries, or links the **user** gives you; if they omitted search context, ask what to search before assuming scope. + +--- + +## 1 — Define the migration + +Capture explicitly: + +1. **Symbol(s)** to migrate (export name, import path, prop name, type name, etc.). +2. **Desired outcome**: rename, change import path/module, replace expression, map enum/string values, add local type alias, etc. +3. **Preset (if any) and on-disk location**: whether this ships in a preset (`packages/migrator/src/presets//manifest.json`) or runs **only via `-t `** without a preset entry. Pick the directory under `transforms/` for the new files (versioned `v9/` / `v10/`, or another name, or `transforms/` root). **Align the manifest `file` field with that path** when you add an entry (see step 7). + +If anything is ambiguous, ask the user before coding. + +--- + +## 2 — Platform scope: one transform or two? + +1. **Web-only** (e.g. CSS, DOM, `@coinbase/cds-web`): single transform, typically under `transforms//-web.ts` or a neutral name if only web is affected. +2. **Mobile-only** (e.g. React Native, `@coinbase/cds-mobile`): single transform for mobile. +3. **Both** with **different** replacement rules (e.g. `DimensionValue` → web local alias vs RN import): **two** transforms (`…-web.ts`, `…-mobile.ts`) plus optional **`…-shared.ts`** for pure helpers (no jscodeshift import in shared if workers load it—keep shared logic environment-safe; follow existing layout-type splits). +4. **Both** with **identical** AST changes: one transform is enough. + +Document in the transform file header **what** is migrated and **what is not** (re-exports, `require`, dynamic import, etc.). + +--- + +## 3 — Research usage (Sourcegraph + repo) + +1. **Inputs from the user**: They should supply what to look for—symbol names, old/new APIs, repos or orgs to include, example file paths, or concrete Sourcegraph queries. **Follow that source of truth**; do not rely on fixed query templates in this skill. +2. **Sourcegraph MCP**: Run searches and fetches using the user’s queries and scope. Read MCP tool schemas first. Use `sourcegraph_fetch_file` when line previews are not enough. +3. **This monorepo**: Supplement with `grep` / `Glob` under `packages/` when the migration touches CDS itself or when the user asks for in-repo usage. + +Record a short list of **patterns you actually saw** in the results (import style, re-exports, edge cases)—derived from discovery, not from a checklist in this doc. + +--- + +## 4 — Case matrix and user confirmation + +From research, build a table: + +| Case | Example | Automate in codemod? | If not: strategy | +| ---- | ------- | -------------------- | ------------------------------ | +| … | … | Yes / No | TODO comment / skip / doc only | + +**Stop and confirm with the user** which rows to automate vs leave manual/TODO-only before implementing non-trivial logic. Call out gaps that **their** search surfaced but the AST transform will not handle (re-exports, dynamic imports, etc., as applicable). + +--- + +## 5 — Implement transforms + +**Location**: `packages/migrator/src/transforms//.ts`, or `transforms/.ts` at the transforms root. The manifest `file` value (when used) must be the path **relative to `transforms/`** without extension, e.g. `v9/my-transform`, `v10/my-transform`, or `my-oneoff/my-transform`. + +**Patterns**: + +- Default export: `export default function transformer(file, api, options)`; eslint may require `// eslint-disable-next-line no-restricted-exports` for default export. +- Import **`transformLogger`**, **`addTodoComment`**, **`hasMigrationTodo`** from `transform-utils`. Typical depths: **`../../utils/transform-utils`** from `transforms//.ts`; **`../utils/transform-utils`** from `transforms/.ts`. If you nest deeper under `transforms/`, add one `../` per extra level. +- **Package scope from jscodeshift `options`**: When matching or rewriting **`@/cds-…`** import paths, use **`getPackageScopeFromOptions(options)`** from **`../../utils/package-scope`** (same depth pattern as `transform-utils`). The cds-migrator CLI forwards **`--packageScope`** / **`-ps`** into `options.packageScope` (`coinbase` or `@coinbase` both normalize to `@coinbase`). **If set**, only rewrite modules under that scope; **if omitted**, match any scope (e.g. regex like `@…/cds-common/…`). State this in the transform’s file header so consumers know they can narrow runs. Reference: `packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts` and `packages/migrator/src/utils/package-scope.ts`. +- Prefer **constants** and small helpers; for shared module strings (e.g. allowed import sources), centralize in a `*-shared.ts` sibling when web/mobile share rules. +- **Idempotency**: second run should no-op when migration is complete. +- **TODO path**: for dynamic or ambiguous AST, insert a standard CDS migration TODO via `addTodoComment` and log with `transformLogger.warn`. + +Reference examples in-repo: major-style folder `transforms/v9/` (`migrate-use-merge-refs.ts`, `button-variant-values.ts`, `migrate-layout-types-*.ts`); root-level `example-transform.ts` shows a transform not under a version subfolder. + +--- + +## 6 — Tests and fixtures + +1. **Fixtures directory**: colocate with the transform, e.g. `packages/migrator/src/transforms//__testfixtures__//` with paired `*.input.tsx` and `*.output.tsx` (names aligned with scenario). Deeper trees may need different relative paths in tests. +2. **Tests**: `packages/migrator/src/transforms//__tests__/.test.ts` using `readTransformFixture` from `../../../test-utils/readTransformFixture` when `__tests__/` is one level under `transforms//` (three hops up to `src/`). **Re-check** `readTransformFixture` and any local imports if `` depth changes. +3. Mock **`console.log` / `console.warn`** if transforms log during tests (see existing migrator tests). +4. Cover: happy paths that match **patterns the user’s research identified**, every scope or import shape they asked you to support, idempotency, no-op when nothing to migrate, edge cases the user approved. For **scope-aware** transforms, pass **`packageScope`** in the third argument to `applyTransform` when testing the narrowed behavior (e.g. `applyTransform(transform, { packageScope: '@coinbase' }, { source }, { parser: 'tsx' })`). +5. **`__testfixtures__` is in `.prettierignore`**—outputs must match the codemod exactly; do not rely on Prettier rewriting fixtures. + +**Run until green**: + +```bash +yarn nx run migrator:test --testPathPattern='' +yarn nx run migrator:typecheck +yarn nx format:write --files= +``` + +--- + +## 7 — Preset manifest + +**Only if** this codemod should appear in a preset (major upgrade bundle, curated migration set, etc.): add an entry to `packages/migrator/src/presets//manifest.json`: + +- **`name`**: stable CLI identifier. +- **`description`**: short, user-facing. +- **`file`**: path relative to `transforms/` without extension—must match where the file actually lives (e.g. `v9/my-transform`, `v10/my-transform`, or `some-group/my-transform`). + +**Non-version / standalone codemods** may **omit** the preset entirely and still be run with the migrator CLI (e.g. `-t `). See `packages/migrator/docs/PRESETS_AND_TRANSFORMS.md`. If omitted from any preset, say so in the PR/summary. + +--- + +## Checklist (before finishing) + +- [ ] User confirmed automatable vs manual cases. +- [ ] Web/mobile split matches real replacement behavior. +- [ ] Coverage matches **sources, scopes, and cases** the user specified (anything out of scope is documented). +- [ ] Tests + fixtures added; `migrator:test` and `migrator:typecheck` pass; formatting applied to non-fixture sources. +- [ ] If the transform is preset-backed: manifest entry added and `file` matches the real path under `transforms/` (no mismatch between folder name and `file`). If standalone: team knows how to invoke it (CLI / docs). +- [ ] Transform header documents limitations (`export … from`, `require`, dynamic import, etc.). +- [ ] If the transform is **scope-aware**: behavior with and without `options.packageScope` / CLI `-ps` is documented and covered by tests where relevant. diff --git a/.claude/skills/component-docs/SKILL.md b/.claude/skills/component-docs/SKILL.md index a18258bd1c..d275ab6c52 100644 --- a/.claude/skills/component-docs/SKILL.md +++ b/.claude/skills/component-docs/SKILL.md @@ -80,11 +80,6 @@ packages/web/src/[source-category]/[ComponentName].tsx # for web packages/mobile/src/[source-category]/[ComponentName].tsx # for mobile ``` -Also check visualization packages if applicable: - -- `packages/web-visualization/src/...` -- `packages/mobile-visualization/src/...` - Also check for Storybook stories (`packages/*/src/**/__stories__/[ComponentName].stories.tsx`). If one exists, add the `storybook` field to webMetadata.json. ### Check for Styles @@ -695,4 +690,3 @@ Before completing, verify: 3. Ensure all examples work and have proper code snippets 4. Include accessibility section with specific examples 5. Test all examples and props tables render correctly -6. For visualization components, use paths like `web-visualization` or `mobile-visualization` instead of `web` or `mobile` diff --git a/.claude/skills/component-styles/SKILL.md b/.claude/skills/component-styles/SKILL.md index 2616265d53..c6a01f1a7f 100644 --- a/.claude/skills/component-styles/SKILL.md +++ b/.claude/skills/component-styles/SKILL.md @@ -27,8 +27,6 @@ Find the component source file: ```bash packages/web/src/[source-category]/[ComponentName].tsx # for web packages/mobile/src/[source-category]/[ComponentName].tsx # for mobile -packages/web-visualization/src/[source-category]/[ComponentName].tsx # for web visualization -packages/mobile-visualization/src/[source-category]/[ComponentName].tsx # for mobile visualization ``` ## Step 2: Evaluate Component Structure diff --git a/.claude/skills/deprecate-cds-api/SKILL.md b/.claude/skills/deprecate-cds-api/SKILL.md index 8dbc95f9f6..790e017302 100644 --- a/.claude/skills/deprecate-cds-api/SKILL.md +++ b/.claude/skills/deprecate-cds-api/SKILL.md @@ -14,7 +14,7 @@ argument-hint: ' — replacement — [@deprecationExpectedRe # Deprecate CDS public API -Automate the standard CDS deprecation workflow for symbols exported from `packages/web`, `packages/mobile`, `packages/common`, `packages/web-visualization`, or `packages/mobile-visualization`. +Automate the standard CDS deprecation workflow for symbols exported from `packages/web`, `packages/mobile`, or `packages/common`. ## Inputs to confirm first @@ -28,7 +28,7 @@ Automate the standard CDS deprecation workflow for symbols exported from `packag **Deprecate the symbol everywhere it is publicly reachable**, not only where it is first implemented. -1. For each CDS package (`web`, `mobile`, `common`, `web-visualization`, `mobile-visualization`), trace the symbol from that package’s `package.json` **`exports`** map → barrel / `index` files → the module that declares or re-exports the symbol. +1. For each CDS package (`web`, `mobile`, `common`), trace the symbol from that package’s `package.json` **`exports`** map → barrel / `index` files → the module that declares or re-exports the symbol. 2. **`Grep`** for the symbol name under `packages//src` (e.g. `export { Foo`, `export * from`, `Foo as`) to catch re-exports and alternate entry paths. 3. **Every** package that publicly exports the symbol must end up with deprecation coverage: primary implementation **and** any re-export site where your tooling or consumers would not see JSDoc from the source file (add JSDoc on the re-export line or duplicate the tags as needed so imports from `@coinbase/cds-web`, `@coinbase/cds-mobile`, `@coinbase/cds-common`, etc. all surface the deprecation). @@ -69,7 +69,6 @@ The tag must satisfy `@deprecationExpectedRemoval v…` as enforced by ESLint (e 1. **Confirm with the user** which major **`N`** to use, unless they already specified it in **Inputs** (e.g. “remove in v10” → use `v10`). 2. **Default suggestion** when the user wants a recommendation: read the **`version`** field from the relevant `package.json` and set **`N = current major + 1`**. - **`packages/web`**, **`packages/mobile`**, and **`packages/common`** always share the same semver — read **`version`** from any one of them (e.g. `8.60.0` → suggest **`v9`**). - - Symbols owned only by **`packages/web-visualization`** or **`packages/mobile-visualization`**: read **that** package’s `package.json` (those versions are independent from web/mobile/common). 3. After agreeing on **`N`**, use **`@deprecationExpectedRemoval v`** everywhere for this deprecation (same **Step 3**). Do **not** assume the default without checking—either the user names **`N`**, or they accept the suggested next-major after you show the current **`version`**. @@ -141,7 +140,7 @@ Use the workspace convention: yarn nx run :lint ``` -Examples: `web`, `mobile`, `common`, `web-visualization`, `mobile-visualization` — run **each** project you touched. Fix any reported issues before finishing (most often: missing `@deprecationExpectedRemoval`, or `@deprecated` text not ending with the standard sentence). +Examples: `web`, `mobile`, `common` — run **each** project you touched. Fix any reported issues before finishing (most often: missing `@deprecationExpectedRemoval`, or `@deprecated` text not ending with the standard sentence). --- diff --git a/.claude/skills/detect-breaking-changes/SKILL.md b/.claude/skills/detect-breaking-changes/SKILL.md index f15961e064..7378b3267b 100644 --- a/.claude/skills/detect-breaking-changes/SKILL.md +++ b/.claude/skills/detect-breaking-changes/SKILL.md @@ -17,8 +17,6 @@ Only analyze changes within these packages: - `packages/web/` - `packages/mobile/` - `packages/common/` -- `packages/web-visualization/` -- `packages/mobile-visualization/` ## Determining the public API surface @@ -85,7 +83,7 @@ Examples: ### 5. DOM / element structure change (web packages only) -Applies to `packages/web/` and `packages/web-visualization/` only. Changes to the rendered HTML element tree that could break consumer CSS selectors or DOM queries targeting internal component structure. +Applies to `packages/web/` only. Changes to the rendered HTML element tree that could break consumer CSS selectors or DOM queries targeting internal component structure. Examples: diff --git a/.codeflow.yml b/.codeflow.yml index 4885bbdc81..3208a1f3bb 100644 --- a/.codeflow.yml +++ b/.codeflow.yml @@ -89,14 +89,6 @@ build: expire_keep_tags_after_days: 1 expire_tmp_tags_after_days: 1 expire_all_after_days: 2 - - BaldurNode: - name: package-ui-mobile-playground - path: ./packages/ui-mobile-playground/publish.Dockerfile - autobuild_files: - - packages/ui-mobile-playground/package.json - expire_keep_tags_after_days: 1 - expire_tmp_tags_after_days: 1 - expire_all_after_days: 2 - BaldurNode: name: package-ui-scorecard path: ./packages/ui-scorecard/publish.Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd1b790daa..0865ba7f52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: fetch-depth: 100 # TODO: This needs to include the merge-base - uses: ./.github/actions/setup - name: Build - run: yarn nx affected --exclude=mobile-app --target=build --base=$NX_BASE --head=$NX_HEAD + run: yarn nx affected --exclude=test-expo --target=build --base=$NX_BASE --head=$NX_HEAD depcheck: name: Depcheck diff --git a/.github/workflows/debug-workflow.yml b/.github/workflows/debug-workflow.yml index 6c3c03c6f6..93154a991c 100644 --- a/.github/workflows/debug-workflow.yml +++ b/.github/workflows/debug-workflow.yml @@ -26,7 +26,169 @@ jobs: steps: - uses: actions/checkout@v4 - # Test the published action - # - name: New CDS Action - # uses: [fill this in on new branch] - # with: [fill this in on new branch] + - uses: ./.github/actions/setup + + - name: Set Percy branch + run: | + BRANCH_INPUT="${{ inputs.branch }}" + if [[ -n "$BRANCH_INPUT" ]]; then + echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" + else + echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" + fi + + - name: Install Maestro + run: node packages/mobile-visreg/src/setup.mjs + + - name: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Prepare iOS app (extract prebuild + patch JS bundle) + run: yarn nx run test-expo:patch-bundle-ios + + - name: Boot iOS simulator + run: | + xcrun simctl boot "iPhone 16" || true + xcrun simctl bootstatus booted + + - name: Install app on simulator + run: xcrun simctl install booted apps/test-expo/prebuilds/ios-release/testexpo.app + + - name: Capture screenshots + run: yarn nx run mobile-visreg:ios + + - name: Upload to Percy + id: percy-upload + if: always() + run: | + OUTPUT=$(yarn nx run mobile-visreg:upload 2>&1) + EXIT_CODE=$? + echo "$OUTPUT" + PERCY_URL=$(echo "$OUTPUT" | grep -oE 'https://percy\.io[^[:space:]]+' | head -1) + echo "percy_url=$PERCY_URL" >> "$GITHUB_OUTPUT" + exit $EXIT_CODE + env: + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} + PERCY_BRANCH: ${{ env.PERCY_BRANCH }} + PERCY_PARALLEL_NONCE: ${{ github.run_id }} + PERCY_PARALLEL_TOTAL: 1 + + # android: + # name: Visreg Android + # runs-on: ubuntu-latest + # environment: production + # if: > + # github.event_name == 'push' || + # github.event_name == 'workflow_dispatch' || + # contains(github.event.pull_request.labels.*.name, 'visreg-mobile') + # steps: + # - name: Harden the runner (Audit all outbound calls) + # uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + # with: + # egress-policy: audit + # - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # with: + # fetch-depth: 1 + # ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + # - uses: ./.github/actions/setup + + # - name: Set Percy branch + # run: | + # BRANCH_INPUT="${{ inputs.branch }}" + # if [[ -n "$BRANCH_INPUT" ]]; then + # echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" + # elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + # echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" + # else + # echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" + # fi + + # - name: Install Maestro + # run: node packages/mobile-visreg/src/setup.mjs + + # - name: Add Maestro to PATH + # run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + # - name: Prepare Android app (extract prebuild + patch JS bundle) + # run: yarn nx run test-expo:patch-bundle-android + + # # Enable KVM hardware acceleration for the Android emulator. + # # Without this, the emulator runs in software emulation mode, which takes 6+ minutes to boot + # # and is significantly more flaky. Ubuntu GHA runners support KVM but it must be explicitly + # # unlocked via udev rules before use. + # # Ref: https://github.com/marketplace/actions/android-emulator-runner + # - name: Enable KVM + # run: | + # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + # sudo udevadm control --reload-rules + # sudo udevadm trigger --name-match=kvm + + # - name: Start Android emulator + run visreg + # uses: reactivecircus/android-emulator-runner@v2 + # with: + # api-level: 30 + # arch: x86_64 + # profile: pixel_7_pro + # avd-name: cds_detox + # # -no-window -gpu swiftshader_indirect: headless software rendering (no display available in CI) + # # -no-boot-anim -noaudio -camera-back none: disable unused subsystems to speed up boot + # # -no-snapshot: disable snapshot load and save entirely (clean state every run) + # emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + # disable-animations: true + # script: | + # # Enable Demo Mode to freeze status bar (avoids false Percy diffs) + # adb shell settings put global sysui_demo_allowed 1 + # adb shell am broadcast -a com.android.systemui.demo -e command enter + # adb shell am broadcast -a com.android.systemui.demo -e command clock --es hhmm 1200 + # adb shell am broadcast -a com.android.systemui.demo -e command battery --es level 100 --es plugged false + # adb shell am broadcast -a com.android.systemui.demo -e command network --es mobile show --es level 4 --es wifi show + + # # sys.boot_completed=1 fires before all services are ready; wait for + # # the package manager specifically before attempting install. + # while ! adb shell pm list packages > /dev/null 2>&1; do echo "Waiting for package manager..."; sleep 1; done + + # adb install -r apps/test-expo/prebuilds/android-release/testexpo.apk + + # # Copy Maestro debug artifacts after the run so they can be uploaded after the emulator shuts down + # yarn nx run mobile-visreg:android; cp -r ~/.maestro/tests /tmp/maestro-debug || true + + # - name: Upload Maestro debug artifacts + # if: always() + # uses: actions/upload-artifact@v4 + # with: + # name: maestro-debug-android + # path: /tmp/maestro-debug/ + # if-no-files-found: ignore + + # - name: Upload to Percy + # if: always() + # run: yarn nx run mobile-visreg:visreg-upload + # env: + # PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} + # PERCY_BRANCH: ${{ env.PERCY_BRANCH }} + # PERCY_PARALLEL_NONCE: ${{ github.run_id }} + # PERCY_PARALLEL_TOTAL: 2 + + # comment-pr: + # name: Comment Percy Link + # needs: [ios, android] + # if: always() && github.event_name == 'pull_request' + # runs-on: ubuntu-latest + # steps: + # - name: Post Percy link on PR + # env: + # GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # PERCY_URL: ${{ needs.ios.outputs.percy_url }} + # run: | + # BODY="${PERCY_URL:-Percy build URL unavailable}" + + # gh pr comment ${{ github.event.pull_request.number }} \ + # --repo ${{ github.repository }} \ + # --body "$BODY" \ + # --edit-last 2>/dev/null || \ + # gh pr comment ${{ github.event.pull_request.number }} \ + # --repo ${{ github.repository }} \ + # --body "$BODY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 74ca91226f..66f2e788fb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,7 +53,7 @@ jobs: run: | # Define all publishable packages (those with publish.Dockerfile # and not private: true) - ALL_PACKAGES="common,eslint-plugin-cds,icons,illustrations,lottie-files,mcp-server,mobile,mobile-visualization,ui-mobile-playground,utils,web,web-visualization" + ALL_PACKAGES="common,eslint-plugin-cds,icons,illustrations,lottie-files,mcp-server,mobile,mobile-visualization,utils,web,web-visualization" # Function to convert comma-separated list to JSON array csv_to_json() { diff --git a/.github/workflows/visreg-mobile.yml b/.github/workflows/visreg-mobile.yml index 2d6511c0e9..c2284d91b7 100644 --- a/.github/workflows/visreg-mobile.yml +++ b/.github/workflows/visreg-mobile.yml @@ -105,7 +105,7 @@ jobs: run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH - name: Prepare iOS app (extract prebuild + patch JS bundle) - run: yarn nx run mobile-app:patch-bundle-ios + run: yarn nx run test-expo:patch-bundle-ios - name: Boot iOS simulator run: | @@ -117,7 +117,7 @@ jobs: sleep 30 - name: Install app on simulator - run: xcrun simctl install booted apps/mobile-app/prebuilds/ios-release-hermes.app + run: xcrun simctl install booted apps/test-expo/prebuilds/ios-release/testexpo.app - name: Capture screenshots run: yarn nx run mobile-visreg:ios diff --git a/.gitignore b/.gitignore index 65cc125f92..cbdbe8eda9 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,10 @@ apps/mobile-app/prebuilds/android-* !apps/mobile-app/credentials/android-release-*.keystore # temporary build directory for native compilation apps/mobile-app/build/ +apps/test-expo/prebuilds/android-* +!apps/test-expo/prebuilds/android-release-*.zip +# temporary build directory for native compilation +apps/test-expo/build/ #reassure **/.reassure/* diff --git a/.prettierignore b/.prettierignore index 2500464492..4a3f850f68 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,9 @@ **/persisted_queries.json **/__generated__ +# Test fixtures (must match exact transform output) +**/__testfixtures__/ + # Builds dist/ lib/ diff --git a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch similarity index 66% rename from .yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch rename to .yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch index e8f544c190..0a4b594dd4 100644 --- a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch +++ b/.yarn/patches/@expo-cli-npm-54.0.22-eb5155f2b5.patch @@ -1,26 +1,26 @@ diff --git a/build/src/start/platforms/android/AndroidAppIdResolver.js b/build/src/start/platforms/android/AndroidAppIdResolver.js -index f4b217c5d71fb62179160cdbf8e02276abd06a6d..74d58fee13c7dbb5144b6c77d6e12917dc62958d 100644 +index eedb068830f3d5869bfc594671c254f48cd9ab8a..38b728b747bc8974ad8ab0fd38f271c771b0519a 100644 --- a/build/src/start/platforms/android/AndroidAppIdResolver.js +++ b/build/src/start/platforms/android/AndroidAppIdResolver.js -@@ -31,7 +31,7 @@ class AndroidAppIdResolver extends _appIdResolver.AppIdResolver { +@@ -33,7 +33,7 @@ class AndroidAppIdResolver extends _AppIdResolver.AppIdResolver { async resolveAppIdFromNativeAsync() { - const applicationIdFromGradle = await _configPlugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); + const applicationIdFromGradle = await _configplugins().AndroidConfig.Package.getApplicationIdAsync(this.projectRoot).catch(()=>null); if (applicationIdFromGradle) { - return applicationIdFromGradle; + return `${applicationIdFromGradle}.development`; } try { - var ref, ref1; + var _androidManifest_manifest_$, _androidManifest_manifest; diff --git a/build/src/start/platforms/ios/AppleAppIdResolver.js b/build/src/start/platforms/ios/AppleAppIdResolver.js -index 06d6d1e11802ed88388444b10acd83834e079f50..c4409c566377897eacdb78aea4a8fd78d5aeca03 100644 +index 96cc53df6109e3b62ede2e79bf4093598a24fa5b..9ec60b4477480128f28c5eb1394caf60e65d8072 100644 --- a/build/src/start/platforms/ios/AppleAppIdResolver.js +++ b/build/src/start/platforms/ios/AppleAppIdResolver.js -@@ -50,7 +50,7 @@ class AppleAppIdResolver extends _appIdResolver.AppIdResolver { +@@ -52,7 +52,7 @@ class AppleAppIdResolver extends _AppIdResolver.AppIdResolver { async resolveAppIdFromNativeAsync() { // Check xcode project try { -- const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); -+ const bundleId = _configPlugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); +- const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot); ++ const bundleId = _configplugins().IOSConfig.BundleIdentifier.getBundleIdentifierFromPbxproj(this.projectRoot, {'buildConfiguration': 'Debug'}); if (bundleId) { return bundleId; } diff --git a/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch new file mode 100644 index 0000000000..3fca734af2 --- /dev/null +++ b/.yarn/patches/@expo-metro-config-npm-54.0.14-88915da766.patch @@ -0,0 +1,19 @@ +diff --git a/build/serializer/environmentVariableSerializerPlugin.js b/build/serializer/environmentVariableSerializerPlugin.js +index 3b13e076369a5a94ec3dc7fddb905b01e52d91e8..c42cdebf000b8bd53c5eb45bfbbb2f6492e20b1c 100644 +--- a/build/serializer/environmentVariableSerializerPlugin.js ++++ b/build/serializer/environmentVariableSerializerPlugin.js +@@ -17,6 +17,14 @@ function getTransformEnvironment(url) { + function getAllExpoPublicEnvVars(inputEnv = process.env) { + // Create an object containing all environment variables that start with EXPO_PUBLIC_ + const env = {}; ++ ++ if (inputEnv._ENV_VARS_FOR_APP) { ++ const keys = JSON.parse(inputEnv._ENV_VARS_FOR_APP); ++ for (const key of keys) { ++ env[key] = inputEnv[key]; ++ } ++ } ++ + for (const key in inputEnv) { + if (key.startsWith('EXPO_PUBLIC_')) { + // @ts-expect-error: TS doesn't know that the key starts with EXPO_PUBLIC_ diff --git a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch b/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch deleted file mode 100644 index d318a3e1b0..0000000000 --- a/.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -index b7a856d72f271e5d655d256a2ea2774c6d4356bd..49d90a461f0c7a26c72a71b77009ec92c0e94105 100644 ---- a/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -+++ b/expo-dev-launcher-gradle-plugin/src/main/kotlin/expo/modules/devlauncher/DevLauncherPlugin.kt -@@ -32,7 +32,7 @@ abstract class DevLauncherPlugin : Plugin { - } - - val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) -- androidComponents.onVariants(androidComponents.selector().withBuildType("debug")) { variant -> -+ androidComponents.onVariants(androidComponents.selector().withBuildType("development")) { variant -> - variant.instrumentation.transformClassesWith(DevLauncherClassVisitorFactory::class.java, InstrumentationScope.ALL) { - it.enabled.set(true) - } diff --git a/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch new file mode 100644 index 0000000000..0e1951e2f9 --- /dev/null +++ b/.yarn/patches/expo-modules-core-npm-3.0.29-7b93dc0961.patch @@ -0,0 +1,17 @@ +diff --git a/ios/Core/ExpoBridgeModule.mm b/ios/Core/ExpoBridgeModule.mm +index 2ed1c00f47406e109750cc27ace7e0d88e42c00e..d14269aae847143318888ad3c848d707de95e691 100644 +--- a/ios/Core/ExpoBridgeModule.mm ++++ b/ios/Core/ExpoBridgeModule.mm +@@ -45,9 +45,9 @@ - (void)setBridge:(RCTBridge *)bridge + _bridge = bridge; + _appContext.reactBridge = bridge; + +-#if !__has_include() +- _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; +-#endif // React Native <0.74 ++// #if !__has_include() ++// _appContext._runtime = [EXJavaScriptRuntimeManager runtimeFromBridge:bridge]; ++// #endif // React Native <0.74 + } + + #if __has_include() diff --git a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch b/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch deleted file mode 100644 index 22a0caa149..0000000000 --- a/.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch +++ /dev/null @@ -1,126 +0,0 @@ -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -index f5ac5483aa3f34ae59830a9da16afe52ccc8ba0e..e2bdef4296b1aeecae41681adf43fe6e7fcc3b89 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenView.kt -@@ -8,6 +8,11 @@ import android.view.ViewGroup - import android.widget.ImageView - import android.widget.RelativeLayout - -+import androidx.core.content.ContextCompat -+import android.view.Gravity -+import android.widget.TextView -+import android.graphics.Color -+ - // this needs to stay for versioning to work - - @SuppressLint("ViewConstructor") -@@ -15,16 +20,44 @@ class SplashScreenView( - context: Context - ) : RelativeLayout(context) { - val imageView: ImageView = ImageView(context).also { view -> -- view.layoutParams = LayoutParams( -+ val params = LayoutParams( - LayoutParams.MATCH_PARENT, - LayoutParams.MATCH_PARENT - ) -+ params.addRule(CENTER_IN_PARENT) // Center align -+ view.layoutParams = params - } - - init { - layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - - addView(imageView) -+ -+ // context comes from the application level. -+ val packageName = context.packageName -+ -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ -+ // If bottom image is provided, add it to the view -+ // Otherwise we keep only the main, centered, image -+ if (resId != 0) { -+ val bottomImageView = ImageView(context).apply { -+ val params = LayoutParams( -+ LayoutParams.WRAP_CONTENT, -+ LayoutParams.WRAP_CONTENT -+ ) -+ params.addRule(ALIGN_PARENT_BOTTOM) -+ params.addRule(CENTER_HORIZONTAL) -+ layoutParams = params -+ setPadding(0, 0, 0, 40) -+ val resId = context.resources.getIdentifier("splashscreen_bottom_image", "drawable", packageName) -+ if (resId != 0) { -+ setImageResource(resId) -+ } -+ scaleType = ImageView.ScaleType.CENTER -+ } -+ addView(bottomImageView) -+ } - } - - fun configureImageViewResizeMode(resizeMode: SplashScreenImageResizeMode) { -diff --git a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -index 23e8d4b416bb12192a3fe517f02e0945ccd8c347..16fd58a80216f49d8b9eeaa3a7a27ba8567760b3 100644 ---- a/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -+++ b/android/src/main/java/expo/modules/splashscreen/SplashScreenViewController.kt -@@ -7,6 +7,7 @@ import android.view.View - import android.view.ViewGroup - import expo.modules.splashscreen.exceptions.NoContentViewException - import java.lang.ref.WeakReference -+import android.view.animation.AlphaAnimation - - const val SEARCH_FOR_ROOT_VIEW_INTERVAL = 20L - -@@ -63,12 +64,19 @@ open class SplashScreenViewController( - return failureCallback("Cannot hide native splash screen on activity that is already destroyed (application is already closed).") - } - -- Handler(activity.mainLooper).post { -- contentView.removeView(splashScreenView) -- autoHideEnabled = true -- splashScreenShown = false -- successCallback(true) -+ val fadeOutDuration = 300L -+ val fadeOutAnimation = AlphaAnimation(1f, 0f).apply { -+ duration = fadeOutDuration -+ fillAfter = true - } -+ -+ Handler(activity.mainLooper).postDelayed({ -+ contentView.removeView(splashScreenView) -+ }, fadeOutDuration) -+ splashScreenView.startAnimation(fadeOutAnimation) -+ autoHideEnabled = true -+ splashScreenShown = false -+ successCallback(true) - } - - // endregion -diff --git a/ios/EXSplashScreen/EXSplashScreenViewController.m b/ios/EXSplashScreen/EXSplashScreenViewController.m -index 3f1226e3867c7b3ef663a3b56787975006d60ddf..3361283632abc49143e59f93c8e57b57324f1708 100644 ---- a/ios/EXSplashScreen/EXSplashScreenViewController.m -+++ b/ios/EXSplashScreen/EXSplashScreenViewController.m -@@ -72,12 +72,16 @@ - (void)hideWithCallback:(nullable void(^)(BOOL))successCallback - EX_WEAKIFY(self); - dispatch_async(dispatch_get_main_queue(), ^{ - EX_ENSURE_STRONGIFY(self); -- [self.splashScreenView removeFromSuperview]; -- self.splashScreenShown = NO; -- self.autoHideEnabled = YES; -- if (successCallback) { -- successCallback(YES); -- } -+ [UIView animateWithDuration:0.2 // 200ms fade-out animation -+ animations:^{self.splashScreenView.alpha = 0.0;} -+ completion:^(BOOL finished){ -+ [self.splashScreenView removeFromSuperview]; -+ self.splashScreenShown = NO; -+ self.autoHideEnabled = YES; -+ if (successCallback) { -+ successCallback(YES); -+ } -+ }]; - }); - } - diff --git a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch b/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch deleted file mode 100644 index d45460a62a..0000000000 --- a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch +++ /dev/null @@ -1,41 +0,0 @@ -diff --git a/sync.js b/sync.js ---- a/sync.js -+++ b/sync.js -@@ -18,6 +18,10 @@ var ownProp = common.ownProp - var childrenIgnored = common.childrenIgnored - var isIgnored = common.isIgnored - -+function safeJoin (arr) { -+ return arr.map(function (p) { return typeof p === 'symbol' ? '**' : p }).join('/') -+} -+ - function globSync (pattern, options) { - if (typeof options === 'function' || arguments.length === 3) - throw new TypeError('callback provided to sync glob\n'+ -@@ -89,7 +93,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - switch (n) { - // if not, then this is rather simple - case pattern.length: -- this._processSimple(pattern.join('/'), index) -+ this._processSimple(safeJoin(pattern), index) - return - - case 0: -@@ -102,7 +106,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - // pattern has some string bits in the front. - // whatever it starts with, whether that's 'absolute' like /foo/bar, - // or 'relative' like '../baz' -- prefix = pattern.slice(0, n).join('/') -+ prefix = safeJoin(pattern.slice(0, n)) - break - } - -@@ -112,7 +116,7 @@ GlobSync.prototype._process = function (pattern, index, inGlobStar) { - var read - if (prefix === null) - read = '.' -- else if (isAbsolute(prefix) || isAbsolute(pattern.join('/'))) { -+ else if (isAbsolute(prefix) || isAbsolute(safeJoin(pattern))) { - if (!prefix || !isAbsolute(prefix)) - prefix = '/' + prefix - read = prefix diff --git a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch b/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch deleted file mode 100644 index 4b7ce66e94..0000000000 --- a/.yarn/patches/react-native-gesture-handler-npm-2.16.2-c16529326b.patch +++ /dev/null @@ -1,76 +0,0 @@ -diff --git a/src/handlers/gestures/GestureDetector.tsx b/src/handlers/gestures/GestureDetector.tsx -index 45d927c230a86a7713d097f19e97da9c32563e2d..06c8d1d441957f29f899af5efd533cab25dd690d 100644 ---- a/src/handlers/gestures/GestureDetector.tsx -+++ b/src/handlers/gestures/GestureDetector.tsx -@@ -256,8 +256,29 @@ function updateHandlers( - ) { - gestureConfig.prepare(); - -+ /* Patch added to fix performance regression due to SharedValue reads. As -+ * per this discussion https://github.com/software-mansion/react-native-gesture-handler/commit/1217039146ddcae6796820b5ecf19d1ff51af837#r143406410 -+ * -+ * Remove patch if this change -+ * https://github.com/software-mansion/react-native-gesture-handler/pull/2957 -+ * has landed on the version you upgrade to. -+ */ -+ // if amount of gesture configs changes, we need to update the callbacks in shared value -+ let shouldUpdateSharedValueIfUsed = -+ preparedGesture.config.length !== gesture.length; -+ - for (let i = 0; i < gesture.length; i++) { - const handler = preparedGesture.config[i]; -+ -+ // if the gestureId is different (gesture isn't wrapped with useMemo or its dependencies changed), -+ // we need to update the shared value, assuming the gesture runs on UI thread or the thread changed -+ if ( -+ handler.handlers.gestureId !== gesture[i].handlers.gestureId && -+ (gesture[i].shouldUseReanimated || handler.shouldUseReanimated) -+ ) { -+ shouldUpdateSharedValueIfUsed = true; -+ } -+ - checkGestureCallbacksForWorklets(handler); - - // only update handlerTag when it's actually different, it may be the same -@@ -301,34 +322,13 @@ function updateHandlers( - } - - if (preparedGesture.animatedHandlers) { -- const previousHandlersValue = -- preparedGesture.animatedHandlers.value ?? []; -- const newHandlersValue = preparedGesture.config -- .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -- .map((g) => g.handlers) as unknown as HandlerCallbacks< -- Record -- >[]; -- -- // if amount of gesture configs changes, we need to update the callbacks in shared value -- let shouldUpdateSharedValue = -- previousHandlersValue.length !== newHandlersValue.length; -- -- if (!shouldUpdateSharedValue) { -- // if the amount is the same, we need to check if any of the configs inside has changed -- for (let i = 0; i < newHandlersValue.length; i++) { -- if ( -- // we can use the `gestureId` prop as it's unique for every config instance -- newHandlersValue[i].gestureId !== previousHandlersValue[i].gestureId -- ) { -- shouldUpdateSharedValue = true; -- break; -- } -- } -- } -- -- if (shouldUpdateSharedValue) { -- preparedGesture.animatedHandlers.value = newHandlersValue; -- } -+ if (shouldUpdateSharedValueIfUsed) { -+ preparedGesture.animatedHandlers.value = preparedGesture.config -+ .filter((g) => g.shouldUseReanimated) // ignore gestures that shouldn't run on UI -+ .map((g) => g.handlers) as unknown as HandlerCallbacks< -+ Record -+ >[]; -+ } - } - - scheduleFlushOperations(); diff --git a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch b/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch deleted file mode 100644 index a09c674667..0000000000 --- a/.yarn/patches/react-native-npm-0.74.5-db5164f47b.patch +++ /dev/null @@ -1,36 +0,0 @@ -diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m -index b0295e05ae4d54091bd80f77809ca2aeaaa8562b..81f8f4fa738cfe80ec89f32ebe5bab7ed21f5958 100644 ---- a/React/Views/RCTModalHostViewManager.m -+++ b/React/Views/RCTModalHostViewManager.m -@@ -75,7 +75,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - modalHostView.onShow(nil); - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_presentationBlock) { - self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else { -@@ -83,7 +82,6 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView - animated:animated - completion:completionBlock]; - } -- }); - } - - - (void)dismissModalHostView:(RCTModalHostView *)modalHostView -@@ -95,7 +93,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; - } - }; -- dispatch_async(dispatch_get_main_queue(), ^{ - if (self->_dismissalBlock) { - self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); - } else if (viewController.presentingViewController) { -@@ -106,7 +103,6 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView - // This, somehow, invalidate the presenting view controller and the modal remains always visible. - completionBlock(); - } -- }); - } - - - (RCTShadowView *)shadowView diff --git a/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch new file mode 100644 index 0000000000..c5e401ba99 --- /dev/null +++ b/.yarn/patches/react-native-npm-0.81.5-0a0008b930.patch @@ -0,0 +1,92 @@ +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +index 9d663610a0546d4f801196217966ad9d184818af..1586d116b9fc4e86a39976de543489c6a23a1154 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js +@@ -16868,7 +16868,7 @@ __DEV__ && + shouldSuspendImpl = newShouldSuspendImpl; + }; + var isomorphicReactPackageVersion = React.version; +- if ("19.1.0" !== isomorphicReactPackageVersion) ++ if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +index b3d1cfa09d76b50617b9032b15c82351a699638e..c17f99912028e52b9acfb01b9b9560bba8c03c16 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-prod.js +@@ -10603,7 +10603,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +index b317ca102b0b7d25c15819c61352019fef05561b..e5c3854d0d6de8c9181d6a50124fdc7e8ddc72ab 100644 +--- a/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js ++++ b/Libraries/Renderer/implementations/ReactNativeRenderer-profiling.js +@@ -11245,7 +11245,7 @@ function updateContainer(element, container, parentComponent, callback) { + return lane; + } + var isomorphicReactPackageVersion = React.version; +-if ("19.1.0" !== isomorphicReactPackageVersion) ++if ("19.1.2" !== isomorphicReactPackageVersion) + throw Error( + 'Incompatible React versions: The "react" and "react-native-renderer" packages must have the exact same version. Instead got:\n - react: ' + + (isomorphicReactPackageVersion + +diff --git a/React/Views/RCTModalHostViewManager.m b/React/Views/RCTModalHostViewManager.m +index 203d0b441342487bfd8765b93044b291029614b2..1f2abc9651d3a4c809be6a03e8d9f7d6f7bd12bc 100644 +--- a/React/Views/RCTModalHostViewManager.m ++++ b/React/Views/RCTModalHostViewManager.m +@@ -60,7 +60,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + modalHostView.onShow(nil); + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_presentationBlock) { + self->_presentationBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else { +@@ -68,7 +68,7 @@ - (void)presentModalHostView:(RCTModalHostView *)modalHostView + animated:animated + completion:completionBlock]; + } +- }); ++ + } + + - (void)dismissModalHostView:(RCTModalHostView *)modalHostView +@@ -80,7 +80,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + [[self.bridge moduleForClass:[RCTModalManager class]] modalDismissed:modalHostView.identifier]; + } + }; +- dispatch_async(dispatch_get_main_queue(), ^{ ++ + if (self->_dismissalBlock) { + self->_dismissalBlock([modalHostView reactViewController], viewController, animated, completionBlock); + } else if (viewController.presentingViewController) { +@@ -91,7 +91,7 @@ - (void)dismissModalHostView:(RCTModalHostView *)modalHostView + // This, somehow, invalidate the presenting view controller and the modal remains always visible. + completionBlock(); + } +- }); ++ + } + + - (RCTShadowView *)shadowView +diff --git a/sdks/hermes-engine/hermes-engine.podspec b/sdks/hermes-engine/hermes-engine.podspec +index 326c6fa9089cf794c2dcf37084085bf3bef3f6a5..4aa7b70780af967ff607aada3419959a8be49670 100644 +--- a/sdks/hermes-engine/hermes-engine.podspec ++++ b/sdks/hermes-engine/hermes-engine.podspec +@@ -77,7 +77,7 @@ Pod::Spec.new do |spec| + . "$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh" + + CONFIG="Release" +- if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "DEBUG=1"; then ++ if echo $GCC_PREPROCESSOR_DEFINITIONS | grep -q "HERMES_ENABLE_DEBUGGER=1"; then + CONFIG="Debug" + fi + diff --git a/AGENTS.md b/AGENTS.md index 8e6e41a6bb..9ec2dceb29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,9 @@ Runtime: NodeJS (see .nvmrc for version) - **`packages/common/`** - Shared functionality and types (`@coinbase/cds-common`) - **`packages/icons/`** - Icon definitions and data (`@coinbase/cds-icons`) - **`packages/illustrations/`** - Illustration assets (`@coinbase/illustrations`) -- **`packages/web-visualization/`** - Web visualization components built with D3 (`@coinbase/cds-web-visualization`) -- **`packages/mobile-visualization/`** - Mobile visualization components built with D3 and react-native-skia (`@coinbase/cds-mobile-visualization`) - **`apps/docs/`** - Public documentation website (Docusaurus) - **`apps/storybook/`** - Component development and testing environment for cds-web -- **`apps/mobile-app/`** - Sample React Native app for testing components from cds-mobile +- **`apps/test-expo/`** - Expo app for testing and visual regression of CDS mobile components ## Standards & Best Practices diff --git a/README.md b/README.md index 802c816002..90c784a676 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ yarn nx run docs:dev ```sh # Launch local debug builds -yarn nx run mobile-app:launch:ios-debug -yarn nx run mobile-app:launch:android-debug +yarn nx run test-expo:launch:ios-debug +yarn nx run test-expo:launch:android-debug ``` ## Contributing diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index e8f8879fef..004630bde4 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -21,9 +21,7 @@ module.exports = { */ entryPoints: [ path.join(__dirname, '../../packages/web/tsconfig.json'), - path.join(__dirname, '../../packages/web-visualization/tsconfig.json'), path.join(__dirname, '../../packages/mobile/tsconfig.json'), - path.join(__dirname, '../../packages/mobile-visualization/tsconfig.json'), path.join(__dirname, '../../packages/common/tsconfig.json'), path.join(__dirname, '../../packages/icons/tsconfig.json'), path.join(__dirname, '../../packages/illustrations/tsconfig.json'), @@ -68,17 +66,17 @@ module.exports = { 'cells/CellMedia', 'cells/ContentCell', 'cells/ListCell', - 'chart/area/AreaChart', - 'chart/bar/BarChart', - 'chart/CartesianChart', - 'chart/legend/Legend', - 'chart/line/LineChart', - 'chart/line/ReferenceLine', - 'chart/axis/XAxis', - 'chart/axis/YAxis', - 'chart/PeriodSelector', - 'chart/point/Point', - 'chart/scrubber/Scrubber', + 'visualizations/chart/area/AreaChart', + 'visualizations/chart/bar/BarChart', + 'visualizations/chart/CartesianChart', + 'visualizations/chart/legend/Legend', + 'visualizations/chart/line/LineChart', + 'visualizations/chart/line/ReferenceLine', + 'visualizations/chart/axis/XAxis', + 'visualizations/chart/axis/YAxis', + 'visualizations/chart/PeriodSelector', + 'visualizations/chart/point/Point', + 'visualizations/chart/scrubber/Scrubber', 'chips/Chip', 'chips/InputChip', 'chips/MediaChip', @@ -162,11 +160,11 @@ module.exports = { 'visualizations/ProgressBarWithFloatLabel', 'visualizations/ProgressCircle', 'section-header/SectionHeader', - 'sparkline/Sparkline', + 'visualizations/sparkline/Sparkline', 'stepper/Stepper', - 'sparkline/SparklineGradient', - 'sparkline/sparkline-interactive/SparklineInteractive', - 'sparkline/sparkline-interactive-header/SparklineInteractiveHeader', + 'visualizations/sparkline/SparklineGradient', + 'visualizations/sparkline/sparkline-interactive/SparklineInteractive', + 'visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader', 'system/Interactable', 'system/MediaQueryProvider', 'system/Pressable', diff --git a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json index c172e168a0..3abaf8ff25 100644 --- a/apps/docs/docs/components/animation/Lottie/mobileMetadata.json +++ b/apps/docs/docs/components/animation/Lottie/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json index 1ba88778e5..ae92a6b54d 100644 --- a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json +++ b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "lottie-react-native", - "version": "^6.7.0" + "version": "7.3.1" } ] } diff --git a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx index 2a1a0da348..90330f6863 100644 --- a/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContainedAssetCard/_webExamples.mdx @@ -81,7 +81,7 @@ function Example() { @@ -94,7 +94,12 @@ function Example() { subtitle: 'Sept earnings', onPress: NoopFn, header: ( - + ), diff --git a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx index 7d5d1c194d..62bcdceea5 100644 --- a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx @@ -126,7 +126,7 @@ function Example() { ## With Background -Apply a background color to the card using the `background` prop. When using a background, consider using `variant="tertiary"` on buttons. +Apply a background color to the card using the `background` prop. When using a background, consider using `variant="inverse"` on buttons. ```jsx function Example() { @@ -153,7 +153,7 @@ function Example() { - @@ -275,7 +275,7 @@ function AccessibleCard() { @@ -259,9 +259,9 @@ function Example() { - + Reward - + +$15 ACS @@ -317,7 +317,7 @@ function AccessibleCard() { + @@ -46,6 +50,12 @@ Use transparent buttons for supplementary actions with lower prominence. The con + + diff --git a/apps/docs/docs/components/inputs/Button/_webExamples.mdx b/apps/docs/docs/components/inputs/Button/_webExamples.mdx index 4100dc2206..c4e4ad15c8 100644 --- a/apps/docs/docs/components/inputs/Button/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Button/_webExamples.mdx @@ -14,7 +14,8 @@ Use variants to communicate the importance and intent of an action. - **Primary** — High emphasis for main actions like "Save" or "Confirm". Limit to one per screen. - **Secondary** — Medium emphasis for multiple actions of equal weight. -- **Tertiary** — High contrast with inverted background. +- **Tertiary** — Low emphasis with a muted background. +- **Inverse** — High contrast with inverted background. - **Negative** — Destructive actions that can't be undone. Use sparingly. ```jsx live @@ -28,6 +29,9 @@ Use variants to communicate the importance and intent of an action. + @@ -49,6 +53,9 @@ Use transparent buttons for supplementary actions with lower prominence. The con + diff --git a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json index 77ea5e689d..84ba30dce8 100644 --- a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/webMetadata.json b/apps/docs/docs/components/inputs/Combobox/webMetadata.json index 4db33d49b9..807152ff4b 100644 --- a/apps/docs/docs/components/inputs/Combobox/webMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx index de2eb7bdee..35c8fbf927 100644 --- a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onPress={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onPress={console.log} /> - ``` @@ -135,13 +122,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onPress={console.log} /> - ``` @@ -191,7 +171,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx index 4228b112f4..7f56da4a60 100644 --- a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx @@ -38,12 +38,6 @@ Use variants to denote intent and importance. The `active` prop fills the icon w variant="tertiary" onClick={console.log} /> - ``` @@ -76,13 +70,6 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onClick={console.log} /> - ``` @@ -201,13 +188,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onClick={console.log} /> - ``` @@ -257,7 +237,7 @@ A toggleable icon button with an adjacent label. Uses `accessibilityLabelledBy` ```jsx live function ClaimDropExample() { const [active, setActive] = useState(false); - const variant = useMemo(() => (active ? 'primary' : 'foregroundMuted'), [active]); + const variant = useMemo(() => (active ? 'primary' : 'secondary'), [active]); const label = useMemo(() => (active ? 'Reject drop' : 'Claim drop'), [active]); return ( diff --git a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json index 6ef25ea309..a1d0bcb69c 100644 --- a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json @@ -28,7 +28,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json index 685965c943..1835a350d4 100644 --- a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json index c594a0a22a..d4fccd3eac 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json @@ -18,7 +18,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/inputs/Select/webMetadata.json b/apps/docs/docs/components/inputs/Select/webMetadata.json index 135c57e756..6a0a6c4799 100644 --- a/apps/docs/docs/components/inputs/Select/webMetadata.json +++ b/apps/docs/docs/components/inputs/Select/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json index db9d2c6ec2..9bc249be52 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json @@ -8,7 +8,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json index 59cda3ea44..bb8c0514a6 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json @@ -12,7 +12,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json index 036d3ca9a0..aff8fd59a2 100644 --- a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json index fc3dc6f6c9..54a937a146 100644 --- a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json @@ -30,7 +30,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json index b8be5679bb..c61c0c0385 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json index f7088fafeb..f629c63e12 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json @@ -26,7 +26,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json index 2c8b9fc38d..4748bad616 100644 --- a/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SlideButton/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-gesture-handler", - "version": "^2.16.2" + "version": "2.28.0" } ] } diff --git a/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx new file mode 100644 index 0000000000..bf115dda45 --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/controls/Switch/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/_webStyles.mdx b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx new file mode 100644 index 0000000000..05ea45585d --- /dev/null +++ b/apps/docs/docs/components/inputs/Switch/_webStyles.mdx @@ -0,0 +1,30 @@ +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Switch } from '@coinbase/cds-web/controls'; + +import webStylesData from ':docgen/web/controls/Switch/styles-data'; + +export const StatefulSwitchPreview = ({ classNames }) => { + const [isChecked, setIsChecked] = useState(false); + +return ( + + setIsChecked(event.target.checked)} +> + Dark mode + +); }; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Switch/index.mdx b/apps/docs/docs/components/inputs/Switch/index.mdx index cbf97aa2b6..98ac45f3c7 100644 --- a/apps/docs/docs/components/inputs/Switch/index.mdx +++ b/apps/docs/docs/components/inputs/Switch/index.mdx @@ -16,6 +16,8 @@ import webPropsToc from ':docgen/web/controls/Switch/toc-props'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; @@ -34,12 +36,16 @@ import mobileMetadata from './mobileMetadata.json'; } + webStyles={} webExamples={} mobilePropsTable={} + mobileStyles={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} mobilePropsToc={mobilePropsToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json index 8cbed53d2b..13f259ea12 100644 --- a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json +++ b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx index 09678b32e1..4d7fd8c347 100644 --- a/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx +++ b/apps/docs/docs/components/layout/Carousel/_mobileExamples.mdx @@ -4,6 +4,8 @@ Carousels are a great way to showcase a list of items in a compact and engaging By default, Carousels have navigation and pagination enabled. You can also add a title to the Carousel by setting `title` prop. +`paginationVariant` is deprecated. Carousel now defaults to dot pagination. Existing uses of `paginationVariant="pill"` still work during the deprecation window, but new usage should prefer the default pagination or a custom `PaginationComponent`. + You simply wrap each child in a `CarouselItem` component, and can optionally set the `width` prop to control the width of the item. You can also set the `styles` prop to control the styles of the carousel, such as the gap between items. @@ -31,7 +33,6 @@ function MyCarousel() { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -171,7 +171,6 @@ function ResponsiveSizingCarousel() { return ( + @@ -611,14 +603,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onPress={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -673,7 +665,7 @@ function CustomComponentsCarousel() { Earn staking rewards on ETH by holding it on Coinbase @@ -697,7 +689,7 @@ function CustomComponentsCarousel() { Chat with other devs in our Discord community @@ -721,7 +713,7 @@ function CustomComponentsCarousel() { Use code NOV60 when you sign up for Coinbase One @@ -745,7 +737,7 @@ function CustomComponentsCarousel() { Spend USDC to get rewards with our Visa® debit card @@ -779,7 +771,6 @@ You can use the `styles` props to customize different parts of the carousel. function CustomStylesCarousel() { return ( ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -184,7 +185,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -213,7 +214,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -242,7 +243,7 @@ function DynamicSizingCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -558,7 +559,6 @@ function SnapModeCarousel() { + @@ -859,14 +856,14 @@ function CustomComponentsCarousel() { disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -942,7 +939,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start earning} - dangerouslySetBackground="rgb(var(--purple70))" + style={{ backgroundColor: 'rgb(var(--purple70))' }} description={ Earn staking rewards on ETH by holding it on Coinbase @@ -967,7 +964,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Start chatting} - dangerouslySetBackground="rgb(var(--teal70))" + style={{ backgroundColor: 'rgb(var(--teal70))' }} description={ Chat with other devs in our Discord community @@ -992,7 +989,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get 60 days free} - dangerouslySetBackground="rgb(var(--blue80))" + style={{ backgroundColor: 'rgb(var(--blue80))' }} description={ Use code NOV60 when you sign up for Coinbase One @@ -1017,7 +1014,7 @@ function CustomComponentsCarousel() { {({ isVisible }) => ( Get started} - dangerouslySetBackground="rgb(var(--gray100))" + style={{ backgroundColor: 'rgb(var(--gray100))' }} description={ Spend USDC to get rewards with our Visa® debit card @@ -1053,7 +1050,6 @@ function CustomStylesCarousel() { return ( console.log('Page changed', activePageIndex)} onDragStart={() => console.log('Drag started')} onDragEnd={() => console.log('Drag ended')} diff --git a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json index 539db97fbf..1f97ba4c47 100644 --- a/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json +++ b/apps/docs/docs/components/layout/Collapsible/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/layout/Dropdown/webMetadata.json b/apps/docs/docs/components/layout/Dropdown/webMetadata.json index d0df1b6cc6..3b4e99f96b 100644 --- a/apps/docs/docs/components/layout/Dropdown/webMetadata.json +++ b/apps/docs/docs/components/layout/Dropdown/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/Avatar/mobileMetadata.json b/apps/docs/docs/components/media/Avatar/mobileMetadata.json index 1e9ac28249..9fe43be2da 100644 --- a/apps/docs/docs/components/media/Avatar/mobileMetadata.json +++ b/apps/docs/docs/components/media/Avatar/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Avatar/webMetadata.json b/apps/docs/docs/components/media/Avatar/webMetadata.json index 8570ed2307..05b76303df 100644 --- a/apps/docs/docs/components/media/Avatar/webMetadata.json +++ b/apps/docs/docs/components/media/Avatar/webMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json index 3ad2f79f6f..f0ae9e9932 100644 --- a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json +++ b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json @@ -15,7 +15,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json index f9eb2b2ea7..5717fcb92c 100644 --- a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json index a2eca6c388..1042016dd0 100644 --- a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json index a06f647034..ba0a1e2dc6 100644 --- a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json index 30aedc1c64..3b826ae4ab 100644 --- a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json +++ b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json index ff0b14bfe2..fd2274207f 100644 --- a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json index 109cd18957..5e7183cda7 100644 --- a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json index c5697db759..b646cdaa6c 100644 --- a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json index f8817f27ba..8b0e9e96bf 100644 --- a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json index 03c6bc3376..e7dc64ca93 100644 --- a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json index 16d4c6ef1a..77fe49f782 100644 --- a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json index d0c8956cf6..e12bc1ed1c 100644 --- a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json @@ -19,7 +19,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json index ecc956fceb..a8ed09aedd 100644 --- a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json index f51f4917f8..df48967410 100644 --- a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json @@ -24,7 +24,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json index f0bbda8767..bbb8cfc8fe 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx index 91b6d29bbd..0fb4e7e314 100644 --- a/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx +++ b/apps/docs/docs/components/navigation/Sidebar/_webExamples.mdx @@ -385,9 +385,9 @@ function RenderEndExample() { {!isCollapsed && ( - + Help & Support - + )} @@ -448,7 +448,9 @@ function CustomStyles() { > - Help + + Help + )} @@ -568,7 +570,11 @@ function ApplicationShell() { > - {!isCollapsed && Settings} + {!isCollapsed && ( + + Settings + + )} - {!isCollapsed && Profile} + {!isCollapsed && ( + + Profile + + )} diff --git a/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx new file mode 100644 index 0000000000..76b8dd7c95 --- /dev/null +++ b/apps/docs/docs/components/navigation/SidebarItem/_webStyles.mdx @@ -0,0 +1,30 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { LogoMark } from '@coinbase/cds-web/icons'; +import { HStack } from '@coinbase/cds-web/layout'; +import { Sidebar, SidebarItem } from '@coinbase/cds-web/navigation'; + +import webStylesData from ':docgen/web/navigation/SidebarItem/styles-data'; + +## Explorer + + + {(classNames) => ( + + }> + undefined} + title="Home" + tooltipContent="Home" + /> + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/navigation/SidebarItem/index.mdx b/apps/docs/docs/components/navigation/SidebarItem/index.mdx index f8b94cc0ea..65388061e9 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/index.mdx +++ b/apps/docs/docs/components/navigation/SidebarItem/index.mdx @@ -14,14 +14,17 @@ import webPropsToc from ':docgen/web/navigation/SidebarItem/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; import webMetadata from './webMetadata.json'; } + webStyles={} webExamples={} webExamplesToc={webExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json index 648e617b7a..ff3117cd23 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json @@ -17,7 +17,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json index 5fcaf9184d..bba58c545c 100644 --- a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json @@ -25,7 +25,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json index af4c63704f..a19864c6e1 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -16,7 +16,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json index 4b2b7ef553..1bb03cbb63 100644 --- a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json @@ -21,7 +21,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json index 745a786e7b..dbf20bf601 100644 --- a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json @@ -13,7 +13,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json index 98412d5d70..49a6d1fa82 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json @@ -29,7 +29,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index c0db513f49..c8fc565aa3 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json index 3fd09a8015..13af54de36 100644 --- a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json index dcb490e8f9..636878e6ab 100644 --- a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json @@ -11,7 +11,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/navigation/Tour/webMetadata.json b/apps/docs/docs/components/navigation/Tour/webMetadata.json index 53c33ef98b..3cdd3102a9 100644 --- a/apps/docs/docs/components/navigation/Tour/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json index 989c9eeae0..acf6065693 100644 --- a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json +++ b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json @@ -7,7 +7,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/Calendar/webMetadata.json b/apps/docs/docs/components/other/Calendar/webMetadata.json index 1ac432167b..5d9f8f465a 100644 --- a/apps/docs/docs/components/other/Calendar/webMetadata.json +++ b/apps/docs/docs/components/other/Calendar/webMetadata.json @@ -12,7 +12,7 @@ "dependencies": [ { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json index 6a1739571d..c67219b293 100644 --- a/apps/docs/docs/components/other/DatePicker/mobileMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/other/DatePicker/webMetadata.json b/apps/docs/docs/components/other/DatePicker/webMetadata.json index c785ad8c75..bcf504b892 100644 --- a/apps/docs/docs/components/other/DatePicker/webMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/other/DotCount/mobileMetadata.json b/apps/docs/docs/components/other/DotCount/mobileMetadata.json index 78d58b2e5c..bbef0f0284 100644 --- a/apps/docs/docs/components/other/DotCount/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotCount/mobileMetadata.json @@ -20,7 +20,7 @@ "dependencies": [ { "name": "react-native-reanimated", - "version": "^3.14.0" + "version": "4.1.1" } ] } diff --git a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json index aed0ba53c3..f5077fee12 100644 --- a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-svg", - "version": "^14.1.0" + "version": "15.12.1" } ] } diff --git a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx index 744a052539..72ae20c962 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_mobileExamples.mdx @@ -52,6 +52,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -80,7 +82,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx index a2033aae8a..e1ada240a6 100644 --- a/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx +++ b/apps/docs/docs/components/other/ThemeProvider/_webExamples.mdx @@ -46,11 +46,11 @@ theme.fontSize.display3; // "2.5rem" For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. ::: -## ThemeProvider CSS Variables +## CSS Variables CSS Variables are created for every value in the theme. -For best performance, prefer to use CSS Variables instead of the `useTheme` hook whenever possible. +For best performance, prefer using CSS Variables instead of the `useTheme` hook whenever possible. ```jsx const theme = useTheme(); @@ -70,6 +70,23 @@ You can see all the CSS Variables for the `defaultTheme` below. +### CSS Variable inheritance + +When ThemeProviders are nested, the nested provider only sets CSS variables that differ from its parent. +Unchanged values are inherited through the DOM via normal CSS custom property inheritance. + +This optimization breaks when a ThemeProvider renders **outside** its parent's DOM tree — for example, inside a portal — because CSS inheritance requires DOM ancestry. In these cases, use the `isolated` prop to ensure the ThemeProvider writes all CSS variables: + +```tsx + + {/* All CSS variables are written, regardless of the parent theme */} + +``` + +:::tip +CDS overlay components (Modal, Toast, Alert, etc.) handle this automatically via [PortalProvider](/components/overlay/PortalProvider). You only need the `isolated` prop when rendering a ThemeProvider inside a custom portal that is not managed by CDS. +::: + ## ThemeProvider classnames The ThemeProvider renders with CSS classnames based on the `activeColorScheme` and the theme's `id`. @@ -95,6 +112,8 @@ ThemeProviders can be nested to create theme overrides for specific sections. ``` +### Overriding theme values + When nesting, you may want to override specific color values from the current theme. Overrides must be conditionally applied because we don't enforce that a theme has both light and dark colors defined. ```jsx @@ -123,7 +142,7 @@ const customTheme = { } as const satisfies Theme; ``` -## Theme inheritence +### Theme inheritance Nested ThemeProviders do not automatically inherit the theme from their parent provider. You can manually inherit the theme with the `useTheme` hook. diff --git a/apps/docs/docs/components/overlay/Alert/webMetadata.json b/apps/docs/docs/components/overlay/Alert/webMetadata.json index c89a519b50..0d04f735ea 100644 --- a/apps/docs/docs/components/overlay/Alert/webMetadata.json +++ b/apps/docs/docs/components/overlay/Alert/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json index 48a8300764..436ca5e1ec 100644 --- a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json index 9fb8bfb472..b1341fbd57 100644 --- a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json @@ -17,7 +17,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json index 94605e2da2..32dfbe0dc5 100644 --- a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json @@ -21,7 +21,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Modal/webMetadata.json b/apps/docs/docs/components/overlay/Modal/webMetadata.json index 6be86f4db3..0f641bc4f1 100644 --- a/apps/docs/docs/components/overlay/Modal/webMetadata.json +++ b/apps/docs/docs/components/overlay/Modal/webMetadata.json @@ -37,7 +37,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx index 5c64fa8cf2..53dbac6446 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_mobileExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your mobile application to manage overlay components: +Render PortalProvider once near the root of your application, to manage overlay components: ```tsx function App() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx index 48b39e28eb..673d79f8f5 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/_webExamples.mdx @@ -1,6 +1,6 @@ ### Basic usage -The PortalProvider component is typically used at the root of your application to manage overlay components: +Render PortalProvider once near the root of your application: ```tsx live function Example() { diff --git a/apps/docs/docs/components/overlay/PortalProvider/index.mdx b/apps/docs/docs/components/overlay/PortalProvider/index.mdx index 32aa69a7e4..43ba352540 100644 --- a/apps/docs/docs/components/overlay/PortalProvider/index.mdx +++ b/apps/docs/docs/components/overlay/PortalProvider/index.mdx @@ -28,7 +28,7 @@ import mobileMetadata from './mobileMetadata.json'; title="PortalProvider" webMetadata={webMetadata} mobileMetadata={mobileMetadata} - description="The PortalProvider component manages the rendering of portals for modals, toasts, alerts, and tooltips. It provides a centralized way to handle overlay components in your application." + description="A required root-level provider that enables CDS overlay components (Modal, Toast, Alert, Tooltip, Tray). Must be rendered once near the root of your application, alongside ThemeProvider." /> {item} handleDelete(index)} accessibilityLabel={`Delete ${item}`} /> diff --git a/apps/docs/docs/components/overlay/Toast/webMetadata.json b/apps/docs/docs/components/overlay/Toast/webMetadata.json index 690f61dd3f..40cc051bb9 100644 --- a/apps/docs/docs/components/overlay/Toast/webMetadata.json +++ b/apps/docs/docs/components/overlay/Toast/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json index 1355f725a8..d1d828744a 100644 --- a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json @@ -25,7 +25,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json index 2e1051b2e4..034502ee0c 100644 --- a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json @@ -24,7 +24,7 @@ "dependencies": [ { "name": "react-native-safe-area-context", - "version": "^4.10.5" + "version": "5.6.0" } ] } diff --git a/apps/docs/docs/components/overlay/Tray/webMetadata.json b/apps/docs/docs/components/overlay/Tray/webMetadata.json index 0d3166435d..7315ea3ee1 100644 --- a/apps/docs/docs/components/overlay/Tray/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/webMetadata.json @@ -29,7 +29,7 @@ }, { "name": "react-dom", - "version": "^18.3.1" + "version": "^18.0.0 || ~19.1.2" } ] } diff --git a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx index bb063fbb78..cf1a325cbc 100644 --- a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx @@ -188,7 +188,8 @@ React Native flattens nested Text into a string and cannot focus internal links ```jsx import { AccessibilityInfo, Linking } from 'react-native'; - Consider a case where you have a block of text with an inline link.{' '} Like so. You may want to write your code like this. -; +; ``` ### Multiple nested links diff --git a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx index 0e5a7fd240..484251c9e4 100644 --- a/apps/docs/docs/components/typography/Text/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Text/_mobileExamples.mdx @@ -57,34 +57,25 @@ All text components support a few numeric typography styles, overflow, text tran ### A11y -On the web, there are different HTML elements to wrap texts with to communicated semantic meanings of the strings. Therefore, CDS does not make any assumptions about the semantic of the text but ask developers to choose the approriate semantic HTML element via the `as` prop. +On mobile, `Text` automatically sets `accessibilityRole="header"` for display and title font variants (`display1`, `display2`, `display3`, `title1`, `title2`). This ensures screen readers correctly identify these elements as headings without requiring any additional props. ```jsx -Display +// accessibilityRole="header" is applied automatically +Page Title -// If we want large text but not as the page title -Display +// Other font variants do not receive a default accessibilityRole +Regular body text ``` -### Headings +You can override the default `accessibilityRole` if needed: -Headings help users understand the hierarchical page organization. All pages on the web should at least have a `

` level heading providing the title or summary of the page. Screen readers users prefer that only the document title be `

` on a page. Headings should NOT be used inside tables header elements (``). - -When using headings, it is confusing to screen reader users to skip heading levels to be more specific (ex. do not go from `

` to `

`). However, it is permissible to use a higher heading level after a lower heading level, i.e. from `

`to`

`, if the outline of the page calls for it. - -One common misconception is that headings for a web app have consistent typography across different pages. That is not an accessibility requirement or a design guideline that our product designers follow. Therefore, based on the content layouts, product engineers should determine the approriate semantic tags to use for each string and choose the proper heading element when the texts convey hierarchical content information. - -Yale has a detailed [web accessibility article](https://usability.yale.edu/web-accessibility/articles/headings#:~:text=One%20of%20the%20most%20common,Do%20not%20overuse%20headings) about how to use headings if you want to learn more. - -In a nutshell, you can reference the following for the most common text semantics. +```jsx +// Override to remove the default header role +Decorative Title -- `h1` for page title (exactly one per page) -- `h2`-`h4` for hierarchical section headings (CDS does not foresee the need for heading level 5 or 6 in Coinbase products). -- `p` for paragraphs of text with default block display. It can be wrapped inside `blockquote`, `li`, or `label` elements for additional semantics. -- `li` for bullet points in a list. -- `time`, `abbr`, `sup`, `kbd`, etc, for granular semantics. -- `pre` and `code` for preformatted code blocks. -- `span` when no semantics are required (within buttons for example) and it also has default inline display. +// Explicitly set a different role +Summary Section +``` ### With Links diff --git a/apps/docs/docs/getting-started/installation/_mobileContent.mdx b/apps/docs/docs/getting-started/installation/_mobileContent.mdx index 60ab85c711..05d0067062 100644 --- a/apps/docs/docs/getting-started/installation/_mobileContent.mdx +++ b/apps/docs/docs/getting-started/installation/_mobileContent.mdx @@ -33,18 +33,24 @@ For React Native projects, ensure you have set up your environment for React Nat ## Getting started -### 1. Render a ThemeProvider +### 1. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: + +- **ThemeProvider** — applies the CDS theme and color scheme +- **PortalProvider** — manages the registry of active overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider } from '@coinbase/cds-mobile/system'; +import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); diff --git a/apps/docs/docs/getting-started/installation/_webContent.mdx b/apps/docs/docs/getting-started/installation/_webContent.mdx index b6acc2e1b8..bf4873b6e4 100644 --- a/apps/docs/docs/getting-started/installation/_webContent.mdx +++ b/apps/docs/docs/getting-started/installation/_webContent.mdx @@ -49,21 +49,26 @@ import '@coinbase/cds-web/defaultFontStyles'; -### 2. Render a ThemeProvider and MediaQueryProvider +### 2. Render providers -Render a ThemeProvider at the root of your application, and pass the `theme` and `activeColorScheme`. +Render the following providers at the root of your application: -Render a MediaQueryProvider for components that use the `useMediaQuery` hook. +- **ThemeProvider** — applies the CDS theme and color scheme +- **MediaQueryProvider** — prevents issues with `window.matchMedia()` in SSR environments ([read more →](/components/other/MediaQueryProvider#server-side-rendering)) +- **PortalProvider** — creates the DOM containers required by overlay components (Modal, Toast, Alert, Tooltip, Tray). ([read more →](/components/overlay/PortalProvider)) ```tsx import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; const Index = () => ( - + + + ); @@ -71,11 +76,6 @@ const Index = () => ( export default Index; ``` -:::tip -The MediaQueryProvider prevents issues with `window.matchMedia()` in SSR environments. -[Read more here →](/components/other/MediaQueryProvider#server-side-rendering) -::: - ### 3. Verify the installation @@ -104,6 +104,7 @@ import '@coinbase/cds-web/defaultFontStyles'; import '@coinbase/cds-web/globalStyles'; import { createRoot } from 'react-dom/client'; import { ThemeProvider, MediaQueryProvider } from '@coinbase/cds-web/system'; +import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import App from './App'; @@ -112,7 +113,9 @@ const root = createRoot(document.getElementById('root')); root.render( - + + + , ); diff --git a/apps/docs/docs/hooks/useMergeRefs/_api.mdx b/apps/docs/docs/hooks/useMergeRefs/_api.mdx index 0266f16ead..ef38176406 100644 --- a/apps/docs/docs/hooks/useMergeRefs/_api.mdx +++ b/apps/docs/docs/hooks/useMergeRefs/_api.mdx @@ -5,10 +5,10 @@ import { MDXArticle } from '@site/src/components/page/MDXArticle'; The `useMergeRefs` hook accepts a spread of refs as its parameter: -- `...refs: (React.MutableRefObject | React.LegacyRef | undefined | null)[]` - An array of refs to merge. Can include: - - `MutableRefObject` - Object-based refs created with `useRef` - - `LegacyRef` - Function-based refs or string refs (legacy) - - `undefined` or `null` - Optional refs that might not be provided +- `...refs: (React.Ref | undefined)[]` - An array of refs to merge. Can include: + - `RefObject` - Object-based refs created with `useRef` / `createRef` + - `RefCallback` - Function refs + - `undefined` - Optional refs that might not be provided diff --git a/apps/docs/docs/hooks/useMergeRefs/metadata.json b/apps/docs/docs/hooks/useMergeRefs/metadata.json index 22d542ff63..f958e8363a 100644 --- a/apps/docs/docs/hooks/useMergeRefs/metadata.json +++ b/apps/docs/docs/hooks/useMergeRefs/metadata.json @@ -1,5 +1,5 @@ { - "import": "import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'", + "import": "import { useMergeRefs } from '@coinbase/cds-common/utils/mergeRefs'", "source": "https://github.com/coinbase/cds/blob/master/packages/common/src/hooks/useMergeRefs.ts", "description": "Combines multiple refs into a single ref callback, allowing a component to work with multiple ref instances simultaneously. Useful when you need to combine refs from different sources, such as forwarded refs and internal component state." } diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 63ce097a96..7e75ab7e7c 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -6,9 +6,7 @@ import commonPackageJson from '../../packages/common/package.json'; import iconsPackageJson from '../../packages/icons/package.json'; import illustrationsPackageJson from '../../packages/illustrations/package.json'; import mobilePackageJson from '../../packages/mobile/package.json'; -import mobileVisualizationPackageJson from '../../packages/mobile-visualization/package.json'; import webPackageJson from '../../packages/web/package.json'; -import webVisualizationPackageJson from '../../packages/web-visualization/package.json'; import docgenConfig from './docgen.config'; @@ -50,15 +48,7 @@ const webpackPlugin = () => { ), '@coinbase/cds-utils': path.resolve(__dirname, '../../packages/utils/src'), '@coinbase/cds-mobile': path.resolve(__dirname, '../../packages/mobile/src'), - '@coinbase/cds-mobile-visualization': path.resolve( - __dirname, - '../../packages/mobile-visualization/src', - ), '@coinbase/cds-web': path.resolve(__dirname, '../../packages/web/src'), - '@coinbase/cds-web-visualization': path.resolve( - __dirname, - '../../packages/web-visualization/src', - ), }), }, }, @@ -115,8 +105,6 @@ const config: Config = { cdsCommonVersion: commonPackageJson.version, cdsIconsVersion: iconsPackageJson.version, cdsIllustrationsVersion: illustrationsPackageJson.version, - cdsMobileVisualizationVersion: mobileVisualizationPackageJson.version, - cdsWebVisualizationVersion: webVisualizationPackageJson.version, }, // Even if you don't use internationalization, you can use this field to set diff --git a/apps/docs/package.json b/apps/docs/package.json index 76d8e4f2a3..76510fb8b4 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -21,9 +21,7 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", "@coinbase/docusaurus-plugin-docgen": "workspace:^", "@coinbase/docusaurus-plugin-kbar": "workspace:^", "@coinbase/docusaurus-plugin-llm-dev-server": "workspace:^", @@ -55,16 +53,16 @@ "lz-string": "^1.5.0", "prettier": "^3.6.2", "prism-react-renderer": "^2.4.1", - "react": "^18.3.1", + "react": "19.1.2", "react-colorful": "^5.6.1", - "react-dom": "^18.3.1", + "react-dom": "19.1.2", "react-live": "^4.1.8", "three": "0.177.0" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/module-type-aliases": "~3.7.0", "@docusaurus/tsconfig": "~3.7.0", @@ -73,8 +71,8 @@ "@linaria/core": "^3.0.0-beta.22", "@linaria/webpack-loader": "^3.0.0-beta.22", "@types/lz-string": "^1.5.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", "@types/three": "0.177.0", "babel-loader": "^10.0.0", "css-loader": "^7.1.2", diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 8764d60d32..c06b4c06c8 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -905,26 +905,6 @@ const sidebars: SidebarsConfig = { }, }, }, - { - type: 'link', - href: 'https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/CHANGELOG.md', - label: '@coinbase/cds-mobile-visualization', - customProps: { - kbar: { - keywords: 'changelog', - }, - }, - }, - { - type: 'link', - href: 'https://github.com/coinbase/cds/blob/master/packages/web-visualization/CHANGELOG.md', - label: '@coinbase/cds-web-visualization', - customProps: { - kbar: { - keywords: 'changelog', - }, - }, - }, ], }, ], diff --git a/apps/docs/src/components/ButtonLink/index.tsx b/apps/docs/src/components/ButtonLink/index.tsx index c1f11fd01b..960b28b89d 100644 --- a/apps/docs/src/components/ButtonLink/index.tsx +++ b/apps/docs/src/components/ButtonLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Button, type ButtonProps } from '@coinbase/cds-web/buttons'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/components/FooterLink/index.tsx b/apps/docs/src/components/FooterLink/index.tsx index adaacdfac4..d35c99daf2 100644 --- a/apps/docs/src/components/FooterLink/index.tsx +++ b/apps/docs/src/components/FooterLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Text, type TextDefaultElement, type TextProps } from '@coinbase/cds-web/typography/Text'; import isInternalUrl from '@docusaurus/isInternalUrl'; import Link, { type Props } from '@docusaurus/Link'; diff --git a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx index 2cbe2708f3..8978b282d9 100644 --- a/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx +++ b/apps/docs/src/components/home/AnimatedHero/HeroCell.tsx @@ -66,10 +66,10 @@ export const HeroCell = ({ diff --git a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx index 0e2a272823..76b638e100 100644 --- a/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx +++ b/apps/docs/src/components/home/QuickStartCampaignCard/index.tsx @@ -9,8 +9,8 @@ export type QuickStartLinkProps = { title: string; description: string; link: { label: string; to: string } | { label: string; href: string }; - BannerComponentLight: React.ComponentType>; - BannerComponentDark: React.ComponentType>; + BannerComponentLight: React.ComponentType<{ width?: string | number; height?: string | number }>; + BannerComponentDark: React.ComponentType<{ width?: string | number; height?: string | number }>; }; const cardTitleFontConfig = { base: 'title4', desktop: 'title2' } as const; diff --git a/apps/docs/src/components/kbar/KBarAnimator.tsx b/apps/docs/src/components/kbar/KBarAnimator.tsx index 1d0d78a6f5..aa2a8f67b5 100644 --- a/apps/docs/src/components/kbar/KBarAnimator.tsx +++ b/apps/docs/src/components/kbar/KBarAnimator.tsx @@ -30,7 +30,7 @@ const KBarAnimator = memo(function KBarAnimator({ children }: { children: React. const exitMs = options?.animations?.exitMs ?? 0; // Height animation - const previousHeight = useRef(); + const previousHeight = useRef(undefined); useEffect(() => { // Only animate if we're actually showing if (visualState === VisualState.showing) { diff --git a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx index dbec407807..b7fdd65977 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ModalLink.tsx @@ -9,7 +9,7 @@ import { Link, type LinkBaseProps } from '@coinbase/cds-web/typography/Link'; export type ModalLinkProps = { children: string; content: React.ReactElement; - modalBodyRef?: React.RefObject; + modalBodyRef?: React.RefObject; modalBodyProps?: Omit; title?: string; } & Omit; diff --git a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx index f41645312c..7ca9bc9bb4 100644 --- a/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx +++ b/apps/docs/src/components/page/ComponentPropsTable/ParentTypesList.tsx @@ -22,7 +22,7 @@ function ParentTypesTable({ sharedParentTypes, props, scrollContainerRef, -}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { +}: ParentTypesItem & { scrollContainerRef: React.RefObject }) { const [searchValue, setSearchValue] = useState(''); const filteredProps = useMemo( () => diff --git a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx index c7945480b2..a3bdde2817 100644 --- a/apps/docs/src/components/page/ComponentTabsContainer/index.tsx +++ b/apps/docs/src/components/page/ComponentTabsContainer/index.tsx @@ -49,7 +49,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const ComponentTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/HookTabsContainer/index.tsx b/apps/docs/src/components/page/HookTabsContainer/index.tsx index ac0273052b..df2a2cbc95 100644 --- a/apps/docs/src/components/page/HookTabsContainer/index.tsx +++ b/apps/docs/src/components/page/HookTabsContainer/index.tsx @@ -43,7 +43,7 @@ const CustomTab = ({ id, label }: TabValue) => { }; const CustomTabsActiveIndicator = (props: TabsActiveIndicatorProps) => { - return ; + return ; }; export const HookTabsContainer: React.FC = ({ diff --git a/apps/docs/src/components/page/JSONCodeBlock/index.tsx b/apps/docs/src/components/page/JSONCodeBlock/index.tsx index f477eb4ea6..80eae8e223 100644 --- a/apps/docs/src/components/page/JSONCodeBlock/index.tsx +++ b/apps/docs/src/components/page/JSONCodeBlock/index.tsx @@ -5,8 +5,11 @@ import styles from './styles.module.css'; export const JSONCodeBlock = ({ json }: { json: Serializable }) => { return ( - - {JSON.stringify(json, null, 2)} - + <> + + {JSON.stringify(json, null, 2)} + +
+ ); }; diff --git a/apps/docs/src/components/page/ShareablePlayground/index.tsx b/apps/docs/src/components/page/ShareablePlayground/index.tsx index b459d94057..a7bb73b72d 100644 --- a/apps/docs/src/components/page/ShareablePlayground/index.tsx +++ b/apps/docs/src/components/page/ShareablePlayground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/components/page/SheetTabs/index.tsx b/apps/docs/src/components/page/SheetTabs/index.tsx index 1d778b0cc0..c6cfd46b80 100644 --- a/apps/docs/src/components/page/SheetTabs/index.tsx +++ b/apps/docs/src/components/page/SheetTabs/index.tsx @@ -43,8 +43,8 @@ export const SheetTabs = ( props: Omit, ) => ( ); diff --git a/apps/docs/src/components/page/VersionLabel/index.tsx b/apps/docs/src/components/page/VersionLabel/index.tsx index 87dcb579ef..295f4f7c29 100644 --- a/apps/docs/src/components/page/VersionLabel/index.tsx +++ b/apps/docs/src/components/page/VersionLabel/index.tsx @@ -30,12 +30,6 @@ export const VersionLabel = ({ case '@coinbase/cds-illustrations': version = versions.cdsIllustrationsVersion; break; - case '@coinbase/cds-web-visualization': - version = versions.cdsWebVisualizationVersion; - break; - case '@coinbase/cds-mobile-visualization': - version = versions.cdsMobileVisualizationVersion; - break; default: throw new Error(`VersionLabel received invalid "packageName" prop: ${packageName}`); } diff --git a/apps/docs/src/hooks/useCDSVersions.ts b/apps/docs/src/hooks/useCDSVersions.ts index 6bdfe0bd02..2c8d61a8da 100644 --- a/apps/docs/src/hooks/useCDSVersions.ts +++ b/apps/docs/src/hooks/useCDSVersions.ts @@ -3,13 +3,8 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; /** Returns the CDS package versions which are defined in the docusaurus.config.ts file. */ export const useCDSVersions = () => { const { siteConfig } = useDocusaurusContext(); - const { - cdsCommonVersion, - cdsIconsVersion, - cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, - } = siteConfig.customFields ?? {}; + const { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion } = + siteConfig.customFields ?? {}; if (typeof cdsCommonVersion !== 'string') throw Error( @@ -23,20 +18,10 @@ export const useCDSVersions = () => { throw Error( 'The "cdsIllustrationsVersion" string is not defined in docusaurus.config.ts "customFields"', ); - if (typeof cdsMobileVisualizationVersion !== 'string') - throw Error( - 'The "cdsMobileVisualizationVersion" string is not defined in docusaurus.config.ts "customFields"', - ); - if (typeof cdsWebVisualizationVersion !== 'string') - throw Error( - 'The "cdsWebVisualizationVersion" string is not defined in docusaurus.config.ts "customFields"', - ); return { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, }; }; diff --git a/apps/docs/src/theme/DocItem/Layout/index.tsx b/apps/docs/src/theme/DocItem/Layout/index.tsx index 2d673b5dc5..3c4740d6d9 100644 --- a/apps/docs/src/theme/DocItem/Layout/index.tsx +++ b/apps/docs/src/theme/DocItem/Layout/index.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { type JSX, useMemo } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { DocFrontMatter } from '@docusaurus/plugin-content-docs'; import { useDoc } from '@docusaurus/plugin-content-docs/client'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx index 030ceee955..08882be91d 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Main/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { HStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/DocRoot/Layout/Main'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx index c0afc87f1d..2fc394fef1 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/ExpandButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocRoot/Layout/Sidebar/ExpandButton'; import IconArrow from '@theme/Icon/Arrow'; diff --git a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx index 9f02321b23..762a91b61b 100644 --- a/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/Sidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { type ReactNode, useCallback, useState } from 'react'; +import React, { type JSX, type ReactNode, useCallback, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import { useLocation } from '@docusaurus/router'; diff --git a/apps/docs/src/theme/DocRoot/Layout/index.tsx b/apps/docs/src/theme/DocRoot/Layout/index.tsx index 298c4a2c14..d8f86881e6 100644 --- a/apps/docs/src/theme/DocRoot/Layout/index.tsx +++ b/apps/docs/src/theme/DocRoot/Layout/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { useDocsSidebar } from '@docusaurus/plugin-content-docs/client'; import BackToTopButton from '@theme/BackToTopButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx index 2d4404764b..e33abd8ec9 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/CollapseButton/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { translate } from '@docusaurus/Translate'; import type { Props } from '@theme/DocSidebar/Desktop/CollapseButton'; diff --git a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx index aeea018217..80e9120374 100644 --- a/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx +++ b/apps/docs/src/theme/DocSidebar/Desktop/Content/index.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { type JSX, useState } from 'react'; import { cx } from '@coinbase/cds-web'; import { VStack } from '@coinbase/cds-web/layout'; import { ThemeClassNames } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/DocSidebar/index.tsx b/apps/docs/src/theme/DocSidebar/index.tsx index 6cc90ff6c3..1d00f852ba 100644 --- a/apps/docs/src/theme/DocSidebar/index.tsx +++ b/apps/docs/src/theme/DocSidebar/index.tsx @@ -1,11 +1,11 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { PropSidebarItem } from '@docusaurus/plugin-content-docs'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; import type { Props } from '@theme/DocSidebar'; import DocSidebarDesktop from '@theme/DocSidebar/Desktop'; import DocSidebarMobile from '@theme/DocSidebar/Mobile'; -export default function DocSidebar(props: Props): JSX.Element { +export default function DocSidebar({ sidebar, ...props }: Props): JSX.Element { const windowSize = useWindowSizeWithBreakpointOverride(); const filterItems = (items: PropSidebarItem[] = []): PropSidebarItem[] => { @@ -13,7 +13,7 @@ export default function DocSidebar(props: Props): JSX.Element { }; // Filter the sidebar items - const filteredSidebar = filterItems([...props.sidebar]); + const filteredSidebar = filterItems([...sidebar]); // Desktop sidebar visible on hydration: need SSR rendering const shouldRenderSidebarDesktop = windowSize === 'desktop' || windowSize === 'ssr'; @@ -23,8 +23,8 @@ export default function DocSidebar(props: Props): JSX.Element { return ( <> - {shouldRenderSidebarDesktop && } - {shouldRenderSidebarMobile && } + {shouldRenderSidebarDesktop && } + {shouldRenderSidebarMobile && } ); } diff --git a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx index 8a2ae4f429..6a1423b0ed 100644 --- a/apps/docs/src/theme/DocSidebarItem/Category/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Category/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { type JSX, useCallback, useEffect, useMemo } from 'react'; import type { IconName } from '@coinbase/cds-common/types'; import { cx } from '@coinbase/cds-web'; import { Collapsible } from '@coinbase/cds-web/collapsible'; diff --git a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx index 8418d584b0..3eb57c8a0c 100644 --- a/apps/docs/src/theme/DocSidebarItem/Html/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Html/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { ThemeClassNames } from '@docusaurus/theme-common'; import type { Props } from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx index 7dbd328c60..dcc123b256 100644 --- a/apps/docs/src/theme/DocSidebarItem/Link/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/Link/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system'; import isInternalUrl from '@docusaurus/isInternalUrl'; diff --git a/apps/docs/src/theme/DocSidebarItem/index.tsx b/apps/docs/src/theme/DocSidebarItem/index.tsx index 13602a3add..564a375f02 100644 --- a/apps/docs/src/theme/DocSidebarItem/index.tsx +++ b/apps/docs/src/theme/DocSidebarItem/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import type { Props } from '@theme/DocSidebarItem'; import DocSidebarItemCategory from '@theme/DocSidebarItem/Category'; import DocSidebarItemHtml from '@theme/DocSidebarItem/Html'; diff --git a/apps/docs/src/theme/Footer/index.tsx b/apps/docs/src/theme/Footer/index.tsx index b9f2383dd2..d870727d7a 100644 --- a/apps/docs/src/theme/Footer/index.tsx +++ b/apps/docs/src/theme/Footer/index.tsx @@ -1,3 +1,4 @@ +import type { JSX } from 'react'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import type { FooterLinkItem } from '@docusaurus/theme-common'; diff --git a/apps/docs/src/theme/Heading/index.tsx b/apps/docs/src/theme/Heading/index.tsx index 27624ceed4..6f11e8370d 100644 --- a/apps/docs/src/theme/Heading/index.tsx +++ b/apps/docs/src/theme/Heading/index.tsx @@ -15,7 +15,7 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { } = useThemeConfig(); // H1 headings do not need an id because they don't appear in the TOC. if (As === 'h1' || !id) { - return ; + return ; } brokenLinks.collectAnchor(id); @@ -33,13 +33,13 @@ export default function Heading({ as: As, id, ...props }: Props): ReactNode { return ( {props.children} diff --git a/apps/docs/src/theme/Layout/Provider/index.tsx b/apps/docs/src/theme/Layout/Provider/index.tsx index 4e5b7edb03..4a69c5a256 100644 --- a/apps/docs/src/theme/Layout/Provider/index.tsx +++ b/apps/docs/src/theme/Layout/Provider/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { PortalProvider } from '@coinbase/cds-web/overlays/PortalProvider'; import { defaultFontStyles } from '@coinbase/cds-web/styles/defaultFont'; diff --git a/apps/docs/src/theme/Layout/index.tsx b/apps/docs/src/theme/Layout/index.tsx index 2359cd1615..ca156a67da 100644 --- a/apps/docs/src/theme/Layout/index.tsx +++ b/apps/docs/src/theme/Layout/index.tsx @@ -1,6 +1,6 @@ import '@coinbase/cds-icons/fonts/web/icon-font.css'; -import { useCallback } from 'react'; +import { type JSX, useCallback } from 'react'; import { cx } from '@coinbase/cds-web'; import type { FallbackParams } from '@docusaurus/ErrorBoundary'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; diff --git a/apps/docs/src/theme/Navbar/Content/index.tsx b/apps/docs/src/theme/Navbar/Content/index.tsx index cd955bb229..bb938da0ef 100644 --- a/apps/docs/src/theme/Navbar/Content/index.tsx +++ b/apps/docs/src/theme/Navbar/Content/index.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { type JSX, useMemo, useRef } from 'react'; import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions'; import { HStack } from '@coinbase/cds-web/layout/HStack'; import { Tooltip } from '@coinbase/cds-web/overlays/tooltip/Tooltip'; @@ -20,13 +20,7 @@ function useNavbarItems() { export default function NavbarContent(): JSX.Element { const windowSize = useWindowSizeWithBreakpointOverride(); - const { - cdsCommonVersion, - cdsIconsVersion, - cdsIllustrationsVersion, - cdsMobileVisualizationVersion, - cdsWebVisualizationVersion, - } = useCDSVersions(); + const { cdsCommonVersion, cdsIconsVersion, cdsIllustrationsVersion } = useCDSVersions(); const items = useNavbarItems(); const linkItems = useMemo( @@ -61,8 +55,6 @@ export default function NavbarContent(): JSX.Element { @coinbase/cds-web@{cdsCommonVersion} @coinbase/cds-icons@{cdsIconsVersion} @coinbase/cds-illustrations@{cdsIllustrationsVersion} - @coinbase/cds-mobile-visualization@{cdsMobileVisualizationVersion} - @coinbase/cds-web-visualization@{cdsWebVisualizationVersion}
); diff --git a/apps/docs/src/theme/Navbar/Layout/index.tsx b/apps/docs/src/theme/Navbar/Layout/index.tsx index 8270f353e3..56a1cffabd 100644 --- a/apps/docs/src/theme/Navbar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/Layout/index.tsx @@ -10,7 +10,7 @@ import { useWindowSizeWithBreakpointOverride } from '../../../utils/useWindowSiz import styles from './styles.module.css'; -function NavbarBackdrop(props: ComponentProps<'div'>) { +function NavbarBackdrop({ className, ...props }: ComponentProps<'div'>) { const mobileSidebar = useNavbarMobileSidebar(); const windowSize = useWindowSizeWithBreakpointOverride(); if (mobileSidebar.disabled || windowSize !== 'mobile') { @@ -18,10 +18,10 @@ function NavbarBackdrop(props: ComponentProps<'div'>) { } return (
); } diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx index b595cbd0ed..9095e0bd5e 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Header/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { HStack } from '@coinbase/cds-web/layout'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx index 0b137e1295..79428c1219 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Layout/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { VStack } from '@coinbase/cds-web/layout'; import type { Props } from '@theme/Navbar/MobileSidebar/Layout'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx index 49990f1468..b08dbecabd 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { Icon } from '@coinbase/cds-web/icons/Icon'; import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx index 2a799d7d13..7560695f31 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/Toggle/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { translate } from '@docusaurus/Translate'; diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx index ac0a3d8956..8aba5f2669 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { type JSX, useCallback, useEffect, useRef } from 'react'; import { FocusTrap } from '@coinbase/cds-web/overlays/FocusTrap'; import { useLockBodyScroll, useNavbarMobileSidebar } from '@docusaurus/theme-common/internal'; import { useWindowSizeWithBreakpointOverride } from '@site/src/utils/useWindowSizeWithBreakpointOverride'; diff --git a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx index 1db8fbf5b9..25c0bff7d7 100644 --- a/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx +++ b/apps/docs/src/theme/NavbarItem/NavbarNavLink/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { cx } from '@coinbase/cds-web'; import { Box } from '@coinbase/cds-web/layout'; import { Pressable } from '@coinbase/cds-web/system/Pressable'; diff --git a/apps/docs/src/theme/Playground/index.tsx b/apps/docs/src/theme/Playground/index.tsx index a14d4d04f1..0a0028900f 100644 --- a/apps/docs/src/theme/Playground/index.tsx +++ b/apps/docs/src/theme/Playground/index.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; +import React, { type JSX, memo, useCallback, useEffect, useRef, useState } from 'react'; import { LiveEditor, LiveError, LivePreview, LiveProvider, withLive } from 'react-live'; import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; import { Icon } from '@coinbase/cds-web/icons/Icon'; diff --git a/apps/docs/src/theme/Playground/sandbox/templateFiles.ts b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts index 90a92bc8dd..a758e6e001 100644 --- a/apps/docs/src/theme/Playground/sandbox/templateFiles.ts +++ b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts @@ -33,7 +33,6 @@ export const PACKAGE_JSON = JSON.stringify( '@coinbase/cds-common': 'latest', '@coinbase/cds-icons': 'latest', '@coinbase/cds-illustrations': 'latest', - '@coinbase/cds-web-visualization': 'beta', 'framer-motion': '^10.18.0', }, devDependencies: { diff --git a/apps/docs/src/theme/ReactLiveScope/index.tsx b/apps/docs/src/theme/ReactLiveScope/index.tsx index 36d184ac2e..63674fb219 100644 --- a/apps/docs/src/theme/ReactLiveScope/index.tsx +++ b/apps/docs/src/theme/ReactLiveScope/index.tsx @@ -88,8 +88,8 @@ import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import * as CDSTour from '@coinbase/cds-web/tour'; import * as CDSTypography from '@coinbase/cds-web/typography'; import * as CDSVisualizations from '@coinbase/cds-web/visualizations'; -import * as CDSChartComponents from '@coinbase/cds-web-visualization/chart'; -import * as CDSSparklineComponents from '@coinbase/cds-web-visualization/sparkline'; +import * as CDSChartComponents from '@coinbase/cds-web/visualizations/chart'; +import * as CDSSparklineComponents from '@coinbase/cds-web/visualizations/sparkline'; import * as framerMotion from 'framer-motion'; export type ImportMapEntry = { @@ -122,8 +122,8 @@ const namespaceRegistrations: [Record, string][] = [ [CDSDates, '@coinbase/cds-web/dates'], [CDSNumbers, '@coinbase/cds-web/numbers'], [CDSVisualizations, '@coinbase/cds-web/visualizations'], - [CDSChartComponents, '@coinbase/cds-web-visualization/chart'], - [CDSSparklineComponents, '@coinbase/cds-web-visualization/sparkline'], + [CDSChartComponents, '@coinbase/cds-web/visualizations/chart'], + [CDSSparklineComponents, '@coinbase/cds-web/visualizations/sparkline'], [StepperComponents, '@coinbase/cds-web/stepper'], [ContentCardComponents, '@coinbase/cds-web/cards/ContentCard'], [CDSDataAssets, '@coinbase/cds-common/internal/data/assets'], diff --git a/apps/docs/src/utils/useIsSticky.ts b/apps/docs/src/utils/useIsSticky.ts index 4581b19a1a..94a4a3757c 100644 --- a/apps/docs/src/utils/useIsSticky.ts +++ b/apps/docs/src/utils/useIsSticky.ts @@ -13,12 +13,12 @@ type UseStickyOptions = { * Optional ref to a container element. If provided, the sticky behavior will be relative * to this container instead of the viewport. */ - containerRef?: RefObject; + containerRef?: RefObject; }; type UseStickyResult = { /** Ref to attach to the element that should become sticky */ - elementRef: RefObject; + elementRef: RefObject; /** Whether the element is currently in "sticky" state */ isSticky: boolean; }; @@ -38,7 +38,7 @@ type UseStickyResult = { export function useIsSticky(options: UseStickyOptions = {}): UseStickyResult { const { top = 0, containerRef } = options; - const elementRef = useRef(null); + const elementRef = useRef(null); const [isSticky, setIsSticky] = useState(false); useEffect(() => { diff --git a/apps/docs/src/utils/useThrottledValue.ts b/apps/docs/src/utils/useThrottledValue.ts index 6697e746a2..dcd32bed76 100644 --- a/apps/docs/src/utils/useThrottledValue.ts +++ b/apps/docs/src/utils/useThrottledValue.ts @@ -18,7 +18,7 @@ export const useThrottledValue = (value: T, delay: number) => { const lastExecutedAt = useRef(0); // Ref to store the timeout ID that ensures the final synchronization of the throttled value after the value has not changed for the delay period - const throttleTimeoutIdRef = useRef>(); + const throttleTimeoutIdRef = useRef>(undefined); // updates the throttled value and schedules a final update after the delay period if needed const updateThrottledValue = useCallback( diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json index db6c554e99..fbf4dd99c9 100644 --- a/apps/docs/tsconfig.json +++ b/apps/docs/tsconfig.json @@ -29,9 +29,6 @@ { "path": "../../packages/web" }, - { - "path": "../../packages/web-visualization" - }, { "path": "../../libs/docusaurus-plugin-kbar" }, diff --git a/apps/docs/utils/generateComponentPeerDeps.ts b/apps/docs/utils/generateComponentPeerDeps.ts index 4a009a0815..ef0c88459d 100644 --- a/apps/docs/utils/generateComponentPeerDeps.ts +++ b/apps/docs/utils/generateComponentPeerDeps.ts @@ -13,11 +13,6 @@ type PackageConfig = { const PACKAGES: PackageConfig[] = [ { packageName: '@coinbase/cds-web', packageDir: 'packages/web' }, { packageName: '@coinbase/cds-mobile', packageDir: 'packages/mobile' }, - { packageName: '@coinbase/cds-web-visualization', packageDir: 'packages/web-visualization' }, - { - packageName: '@coinbase/cds-mobile-visualization', - packageDir: 'packages/mobile-visualization', - }, ]; /** diff --git a/apps/mobile-app/README.md b/apps/mobile-app/README.md deleted file mode 100644 index 373560ac0b..0000000000 --- a/apps/mobile-app/README.md +++ /dev/null @@ -1,59 +0,0 @@ -# Mobile App - -This is a playground for mobile component development. It uses `packages/ui-mobile-playground` to manage the UI components that render the mobile storybook components. This app is primarily expo logic that wraps and renders `ui-mobile-playground` components. - -## When to use Expo Go vs Expo Prebuilds? - -**[Expo Go](#expo-go)** (Recommended for most cases) - -Expo Go enables fast development and testing directly on devices with over-the-air updates from your dev server. - -- Recommended for most development -- For testing on physical device - -**[Expo Prebuilds](./docs/prebuilds.md)** - -Expo prebuilds generate full native iOS and Android binaries with embedded JS bundle for testing custom native code. - -- Required if you made dependency changes, like updating React Native -- Needed for testing any custom native modules and integrations - -## Expo Go - -1. Run `yarn install` from root - -2. Start the [expo development server](https://docs.expo.dev/more/expo-cli/#develop) by running: - -``` -yarn nx run mobile-app:go -``` - -### Run the app in the iOS/Android simulator - -Press 'i' or 'a' to open iOS or Android simulator respectively. - -### Run the app on a physical device - -> For security reasons, please make sure your device has Coinbase Security Profile installed before proceeding. - -1. Download [Expo Go](https://expo.dev/client) on your device. -2. Scan the QR code from the terminal using the Expo Go app on your phone. -3. Make sure your device and metro server are connected to the same network. You might need to disconnect VPN. - -## Creating a new route - -Whenever you want to add a new screen to the mobile-app, you'll need to run this codegen script to generate the new route(s). - -```zsh -yarn nx run codegen:mobile-routes -``` - -## Advanced - - - -- [How to generate prebuilds](./docs/prebuilds.md) -- [How to upgrade React Native version](./docs/upgrade-rn.md) -- [How and when to create new mobile build](./docs/building-mobile.md) -- [How to upgrade a native dependency](./docs/upgrading-mobile-dep.md) -- [How to debug failures & common errors](./docs/help.md) diff --git a/apps/mobile-app/app.config.ts b/apps/mobile-app/app.config.ts deleted file mode 100644 index 7ebbddb5cd..0000000000 --- a/apps/mobile-app/app.config.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { getExpoSDKVersion } from '@expo/config'; -import type { ExpoConfig } from '@expo/config-types'; - -const profile = process.env.APP_PROFILE ?? ('debug' as const); -const jsEngine = process.env.APP_JS_ENGINE ?? ('hermes' as const); -const newArchEnabled = process.env.APP_NEW_ARCH_ENABLED === '1'; -const bundleIdentifier = process.env.APP_IOS_BUNDLE_IDENTIFIER ?? 'com.ui-systems.debug-ios-hermes'; -const packageIdentifier = - process.env.APP_ANDROID_PACKAGE_IDENTIFIER ?? 'com.ui_systems.debug_hermes'; - -const lookupKey = `${profile}-${jsEngine}` as const; -const iconName = `icon-${lookupKey}` as const; -const splashName = `splash-${lookupKey}` as const; -const splashColor = { - 'debug-jsc': '#44C28D', - 'debug-hermes': '#D058C1', - 'release-jsc': '#E7C95B', - 'release-hermes': '#06BEEC', -}[lookupKey]; - -const expo: ExpoConfig = { - name: 'CDS', - slug: 'cds', // we might need to change so it's unique across builds for deep linking - scheme: 'cds', - owner: 'ui-systems', - extra: {}, - runtimeVersion: { - policy: 'sdkVersion', - }, - orientation: 'default' as const, - icon: `./assets/${iconName}.png`, - - sdkVersion: getExpoSDKVersion(__dirname), - jsEngine, - userInterfaceStyle: 'automatic' as const, - splash: { - image: `./assets/${splashName}.png`, // TODO: dynamically generate based on jsEngine https://github.com/expo/fyi/blob/main/black-screen-before-splash.md - resizeMode: 'contain', - backgroundColor: splashColor, - }, - assetBundlePatterns: ['**/*'], - ios: { - supportsTablet: true, - bundleIdentifier, - }, - android: { - adaptiveIcon: { - foregroundImage: './assets/adaptive-icon.png', - backgroundColor: splashColor, - }, - package: packageIdentifier, - }, - plugins: [ - [ - 'expo-build-properties', - { - ios: { - newArchEnabled, - }, - android: { - kotlinVersion: '1.8.0', - newArchEnabled, - /** - * https://docs.expo.dev/build-reference/e2e-tests/#51-patch-buildgradle - * Temporary patch required until detox integration is first class - * - * The Android build command that we use to produce the test build is ./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release - * Notice that it consists of two Gradle tasks. Unfortunately, when building the *AndroidTest task, some versions of the expo-modules-core module change - * what native libraries are included in the app binary. Those settings don't work with settings for assembleRelease. - * To fix the problem, add the pickFirsts list under android.packagingOptions in your android/app/build.gradle. - * The pickFirsts property overrides the setting for your project. - */ - packagingOptions: { - /** https://docs.expo.dev/versions/latest/sdk/build-properties/#pluginconfigtypeandroidpackagingoptions */ - pickFirst: [ - 'lib/**/libc++_shared.so', - 'lib/**/libreactnativejni.so', - 'lib/**/libreact_nativemodule_core.so', - 'lib/**/libglog.so', - 'lib/**/libjscexecutor.so', - 'lib/**/libfbjni.so', - 'lib/**/libfolly_json.so', - 'lib/**/libfolly_runtime.so', - 'lib/**/libhermes.so', - 'lib/**/libjsi.so', - ], - }, - }, - }, - ], - '@config-plugins/detox', - [ - 'expo-gradle-ext-vars', - { - androidXBrowser: '1.5.0', - }, - ], - ], -}; - -export default { - expo, -}; diff --git a/apps/mobile-app/assets/adaptive-icon.png b/apps/mobile-app/assets/adaptive-icon.png deleted file mode 100644 index 7f442f72c0..0000000000 Binary files a/apps/mobile-app/assets/adaptive-icon.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-debug-hermes.png b/apps/mobile-app/assets/icon-debug-hermes.png deleted file mode 100644 index 146558c347..0000000000 Binary files a/apps/mobile-app/assets/icon-debug-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-debug-jsc.png b/apps/mobile-app/assets/icon-debug-jsc.png deleted file mode 100644 index c885f43f4c..0000000000 Binary files a/apps/mobile-app/assets/icon-debug-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-release-hermes.png b/apps/mobile-app/assets/icon-release-hermes.png deleted file mode 100644 index 4327432bfe..0000000000 Binary files a/apps/mobile-app/assets/icon-release-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/icon-release-jsc.png b/apps/mobile-app/assets/icon-release-jsc.png deleted file mode 100644 index 6cfb1dd33f..0000000000 Binary files a/apps/mobile-app/assets/icon-release-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-debug-hermes.png b/apps/mobile-app/assets/splash-debug-hermes.png deleted file mode 100644 index 5a23726ec1..0000000000 Binary files a/apps/mobile-app/assets/splash-debug-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-debug-jsc.png b/apps/mobile-app/assets/splash-debug-jsc.png deleted file mode 100644 index 6aead00d11..0000000000 Binary files a/apps/mobile-app/assets/splash-debug-jsc.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-release-hermes.png b/apps/mobile-app/assets/splash-release-hermes.png deleted file mode 100644 index 027c8a8e7f..0000000000 Binary files a/apps/mobile-app/assets/splash-release-hermes.png and /dev/null differ diff --git a/apps/mobile-app/assets/splash-release-jsc.png b/apps/mobile-app/assets/splash-release-jsc.png deleted file mode 100644 index d6459011ff..0000000000 Binary files a/apps/mobile-app/assets/splash-release-jsc.png and /dev/null differ diff --git a/apps/mobile-app/babel.config.js b/apps/mobile-app/babel.config.js deleted file mode 100644 index 5158e56cd1..0000000000 --- a/apps/mobile-app/babel.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = function getBabelConfig(api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: ['transform-inline-environment-variables'], - }; -}; diff --git a/apps/mobile-app/credentials.json b/apps/mobile-app/credentials.json deleted file mode 100644 index beff010add..0000000000 --- a/apps/mobile-app/credentials.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "android": { - "keystore": { - "keystorePath": "credentials/android-release-hermes.keystore", - "keyAlias": "detoxkey", - "keystorePassword": "android", - "keyPassword": "android" - } - } -} diff --git a/apps/mobile-app/credentials/android-release-hermes.keystore b/apps/mobile-app/credentials/android-release-hermes.keystore deleted file mode 100644 index ff8e68ece8..0000000000 Binary files a/apps/mobile-app/credentials/android-release-hermes.keystore and /dev/null differ diff --git a/apps/mobile-app/detox.config.js b/apps/mobile-app/detox.config.js deleted file mode 100644 index 935147c647..0000000000 --- a/apps/mobile-app/detox.config.js +++ /dev/null @@ -1,79 +0,0 @@ -function isBuildkite() { - return !!process.env.BUILDKITE; -} - -function isGithubActions() { - return !!process.env.GITHUB_ACTIONS; -} - -function isCI() { - return !!process.env.CI || isBuildkite() || isGithubActions(); -} - -/** - * TODO: handle config automatically based on eas build profiles - */ -/** @type {Detox.DetoxConfig} */ -const config = { - testRunner: { - args: { - $0: 'jest', - config: 'e2e/jest.config.js', - }, - }, - apps: { - 'ios-debug': { - type: 'ios.app', - binaryPath: 'prebuilds/ios-debug-hermes.app', - }, - 'android-debug': { - type: 'android.apk', - binaryPath: 'prebuilds/android-debug-hermes/binary.apk', - testBinaryPath: 'prebuilds/android-debug-hermes/testBinary.apk', - }, - 'ios-release': { - type: 'ios.app', - binaryPath: 'prebuilds/ios-release-hermes.app', - }, - 'android-release': { - type: 'android.apk', - binaryPath: 'prebuilds/android-release-hermes/binary.apk', - testBinaryPath: 'prebuilds/android-release-hermes/testBinary.apk', - }, - }, - devices: { - simulator: { - type: 'ios.simulator', - device: { - type: 'iPhone 16', - }, - }, - emulator: { - type: 'android.emulator', - device: { - avdName: isCI() ? 'cds_detox' : 'cds_detox_local', - }, - bootArgs: isCI() ? '-skin 600x5000' : undefined, - }, - }, - configurations: { - 'ios-debug': { - device: 'simulator', - app: 'ios-debug', - }, - 'android-debug': { - device: 'emulator', - app: 'android-debug', - }, - 'ios-release': { - device: 'simulator', - app: 'ios-release', - }, - 'android-release': { - device: 'emulator', - app: 'android-release', - }, - }, -}; - -module.exports = config; diff --git a/apps/mobile-app/env.d.ts b/apps/mobile-app/env.d.ts deleted file mode 100644 index 14c38d5e16..0000000000 --- a/apps/mobile-app/env.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/// - -/* eslint-disable no-restricted-syntax */ - -declare module 'process' { - global { - export const testFailed: boolean; - namespace NodeJS { - interface ProcessEnv { - readonly APP_DEBUG?: `${0 | 1}`; - readonly APP_JS_ENGINE?: 'jsc' | 'hermes'; - readonly APP_PLATFORM: 'ios' | 'android'; - readonly APP_NAME?: string; - readonly APP_NEW_ARCH_ENABLED?: `${0 | 1}`; - readonly APP_PROFILE?: 'debug' | 'release'; - } - } - } -} diff --git a/apps/mobile-app/index.js b/apps/mobile-app/index.js deleted file mode 100644 index 4f5df0f978..0000000000 --- a/apps/mobile-app/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import 'react-native-gesture-handler'; -import './src/polyfills/intl'; - -import { registerRootComponent } from 'expo'; -import * as SplashScreen from 'expo-splash-screen'; - -import App from './src/App'; - -// It is recommended to call this in global scope without awaiting, rather than inside React components or hooks, -// because otherwise this might be called too late, when the splash screen is already hidden. -SplashScreen.preventAutoHideAsync(); - -// registerRootComponent calls AppRegistry.registerComponent('main', () => App); -// It also ensures that whether you load the app in Expo Go or in a native build, -// the environment is set up appropriately -registerRootComponent(App); diff --git a/apps/mobile-app/jest.config.js b/apps/mobile-app/jest.config.js deleted file mode 100644 index 0ddaf11c1f..0000000000 --- a/apps/mobile-app/jest.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - preset: '../../jest.preset.js', -}; diff --git a/apps/mobile-app/metro.config.js b/apps/mobile-app/metro.config.js deleted file mode 100644 index 78e709847d..0000000000 --- a/apps/mobile-app/metro.config.js +++ /dev/null @@ -1,118 +0,0 @@ -const exclusionList = require('metro-config/src/defaults/exclusionList'); -const { resolve } = require('metro-resolver'); -const { getDefaultConfig } = require('expo/metro-config'); -const { mergeConfig } = require('@react-native/metro-config'); -const path = require('node:path'); -const { resolve: resolveExports } = require('resolve.exports'); - -// Learn more https://docs.expo.io/guides/customizing-metro -const expoConfig = getDefaultConfig(__dirname); -const defaultSourceExts = ['ts', 'tsx', 'js', 'jsx', 'json', 'd.ts', 'cjs']; - -const aliases = { - '@coinbase/cds-common': path.resolve(__dirname, '../../packages/common/src'), - '@coinbase/cds-icons': path.resolve(__dirname, '../../packages/icons/src'), - '@coinbase/cds-illustrations': path.resolve(__dirname, '../../packages/illustrations/src'), - '@coinbase/cds-lottie-files': path.resolve(__dirname, '../../packages/lottie-files/src'), - '@coinbase/cds-mobile': path.resolve(__dirname, '../../packages/mobile/src'), - '@coinbase/cds-mobile-visualization': path.resolve( - __dirname, - '../../packages/mobile-visualization/src', - ), - '@coinbase/cds-utils': path.resolve(__dirname, '../../packages/utils/src'), - '@coinbase/ui-mobile-playground': path.resolve( - __dirname, - '../../packages/ui-mobile-playground/src', - ), -}; -const pkgCache = {}; - -const getBaseModule = (moduleName) => { - const parts = moduleName.split('/'); - if (!moduleName.startsWith('@')) return parts[0]; - return `${parts[0]}/${parts[1]}`; -}; - -function loadPackageJson(pkgPath) { - if (!pkgCache[pkgPath]) { - pkgCache[pkgPath] = require(pkgPath); - } - - return pkgCache[pkgPath]; -} - -// This custom Metro resolver will try to use the aliases defined above. -const customResolveRequest = (context, baseModuleName, platform) => { - const { resolveRequest: resolveRequestInner, ...ctx } = context; - - const moduleName = context.redirectModulePath(baseModuleName); - const baseModule = moduleName && getBaseModule(moduleName); - - // Custom resolver to map local package aliases to exports - if ( - process.env.CI !== 'true' && - process.env.NODE_ENV !== 'production' && - process.env.CDS_METRO_RESOLVER !== 'false' && - baseModule && - aliases[baseModule] - ) { - const aliasPath = moduleName.replace(baseModule, aliases[baseModule]); - return context.resolveRequest(context, aliasPath, platform); - } - - if (moduleName === false) { - return { - type: 'empty', - }; - } - - /** - * This custom resolver checks for an "exports" field in package.json and resolves accordingly. - * NOTE: This mimics the behavior of unstable_enablePackageExports which is unable to be used by CDS for some reason. - */ - if (moduleName.startsWith('@cb')) { - const pkgPath = require.resolve(`${getBaseModule(moduleName)}/package.json`); - const pkg = loadPackageJson(pkgPath); - - if ('exports' in pkg) { - const entryPoint = resolveExports(pkg, moduleName, { - conditions: ['react-native', 'browser', 'module', 'require', 'node', 'default'], - unsafe: true, - }); - - if (entryPoint) { - return { - filePath: path.join(path.dirname(pkgPath), String(entryPoint)), - type: 'sourceFile', - }; - } - } - } - - if (resolveRequestInner) { - // Nothing found, fallback to metro - return resolveRequestInner(context, moduleName, platform); - } - - return resolve(ctx, moduleName, platform); -}; - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const metroConfig = mergeConfig(expoConfig, { - resetCache: true, - resolver: { - blacklistRE: exclusionList([/dist\/@cb\/.*/]), - // https://github.com/wix/Detox/blob/master/docs/Guide.Mocking.md#Configuration - sourceExts: process.env.RN_SRC_EXT - ? process.env.RN_SRC_EXT.split(',').concat(defaultSourceExts) - : defaultSourceExts, - resolveRequest: customResolveRequest, - }, -}); - -module.exports = metroConfig; diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json deleted file mode 100644 index 30263a96ad..0000000000 --- a/apps/mobile-app/package.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "mobile-app", - "version": "0.0.0", - "private": true, - "scripts": { - "go": "expo start --go", - "start": "expo start --dev-client", - "android": "expo run:android", - "ios": "expo run:ios", - "prebuild-install": "cd ../../ && yarn install && yarn nx run mobile-app:setup", - "should-run-visreg": "node ./scripts/utils/shouldRunVisreg.mjs" - }, - "dependencies": { - "@bugsnag/react-native": "^7.18.0", - "@coinbase/cds-common": "workspace:^", - "@coinbase/cds-icons": "workspace:^", - "@coinbase/cds-illustrations": "workspace:^", - "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", - "@coinbase/ui-mobile-playground": "workspace:^", - "@config-plugins/detox": "^6.0.0", - "@expo-google-fonts/inter": "^0.3.0", - "@expo-google-fonts/source-code-pro": "^0.3.0", - "@formatjs/intl-getcanonicallocales": "^2.5.5", - "@formatjs/intl-locale": "^4.2.11", - "@formatjs/intl-numberformat": "^8.15.4", - "@formatjs/intl-pluralrules": "^5.4.4", - "@react-native/metro-config": "^0.72.9", - "@react-navigation/core": "^6.4.16", - "@react-navigation/native": "^6.1.6", - "@react-navigation/native-stack": "^6.9.26", - "@react-navigation/stack": "^6.3.16", - "@shopify/react-native-skia": "1.12.4", - "expo": "~51.0.31", - "expo-application": "~5.9.1", - "expo-asset": "~10.0.10", - "expo-build-properties": "~0.12.5", - "expo-clipboard": "~6.0.3", - "expo-dev-client": "4.0.27", - "expo-font": "~12.0.9", - "expo-gradle-ext-vars": "^0.1.1", - "expo-linking": "~6.3.1", - "expo-quick-actions": "2.0.0", - "expo-splash-screen": "~0.27.6", - "expo-status-bar": "~1.12.1", - "expo-system-ui": "~3.0.7", - "intl": "^1.2.5", - "lottie-react-native": "6.7.0", - "react": "^18.3.1", - "react-native": "0.74.5", - "react-native-gesture-handler": "2.16.2", - "react-native-inappbrowser-reborn": "3.7.0", - "react-native-navigation-bar-color": "2.0.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.32.0", - "react-native-svg": "14.1.0" - }, - "devDependencies": { - "@babel/core": "^7.28.0", - "@expo/config": "~9.0.0", - "@expo/config-types": "~51.0.2", - "@types/react": "^18.3.12", - "babel-plugin-transform-inline-environment-variables": "^0.4.4", - "detox": "^20.14.8", - "jest": "^29.7.0", - "react-native-bundle-visualizer": "^3.1.3", - "zx": "^8.1.9" - } -} diff --git a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz b/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz deleted file mode 100644 index 003859b2aa..0000000000 Binary files a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz and /dev/null differ diff --git a/apps/mobile-app/project.json b/apps/mobile-app/project.json deleted file mode 100644 index 8a30e68170..0000000000 --- a/apps/mobile-app/project.json +++ /dev/null @@ -1,147 +0,0 @@ -{ - "name": "mobile-app", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "apps/mobile-app/src", - "projectType": "application", - "tags": [], - "targets": { - "setup": { - "executor": "nx:run-commands", - "dependsOn": [ - "^build" - ], - "options": { - "cwd": "apps/mobile-app", - "commands": [ - "mkdir -p ios", - "mkdir -p android" - ] - } - }, - "launch": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/launch.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios" - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android" - } - } - }, - "start": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/start.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - } - } - }, - "build": { - "executor": "nx:run-commands", - "dependsOn": [], - "options": { - "cwd": "apps/mobile-app", - "command": "export CDS_METRO_RESOLVER=false && node ./scripts/build.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}" - }, - "defaultConfiguration": "ios-debug", - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios", - "dependsOn": [ - "^build" - ] - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android", - "dependsOn": [ - "^build" - ] - } - } - }, - "detox": { - "executor": "nx:run-commands", - "dependsOn": [ - "^build" - ], - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/detox.mjs --profile {args.profile} --jsEngine {args.jsEngine} --platform {args.platform}", - "env": { - "DETOX_TEST": "true" - } - }, - "configurations": { - "ios-debug": { - "args": "--profile debug --jsEngine hermes --platform ios" - }, - "android-debug": { - "args": "--profile debug --jsEngine hermes --platform android" - }, - "ios-release": { - "args": "--profile release --jsEngine hermes --platform ios" - }, - "android-release": { - "args": "--profile release --jsEngine hermes --platform android" - } - } - }, - "validate": { - "executor": "nx:run-commands", - "options": { - "cwd": "apps/mobile-app", - "command": "node ./scripts/validate.mjs" - } - }, - "patch-bundle-ios": { - "command": "node ./scripts/patch-bundle.mjs --platform ios --profile release --jsEngine hermes", - "options": { - "cwd": "apps/mobile-app" - } - }, - "patch-bundle-android": { - "command": "node ./scripts/patch-bundle.mjs --platform android --profile release --jsEngine hermes", - "options": { - "cwd": "apps/mobile-app" - } - }, - "lint": { - "executor": "@nx/eslint:lint" - }, - "test": { - "executor": "@nx/jest:jest", - "options": { - "jestConfig": "{projectRoot}/jest.config.js" - } - }, - "typecheck": { - "command": "tsc --build --pretty --verbose" - } - } -} diff --git a/apps/mobile-app/react-native.config.js b/apps/mobile-app/react-native.config.js deleted file mode 100644 index f053ebf797..0000000000 --- a/apps/mobile-app/react-native.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/apps/mobile-app/scripts/build.mjs b/apps/mobile-app/scripts/build.mjs deleted file mode 100644 index 24a4ad87a3..0000000000 --- a/apps/mobile-app/scripts/build.mjs +++ /dev/null @@ -1,31 +0,0 @@ -import { $, argv, fs } from 'zx'; - -import { buildAndroid } from './utils/buildAndroid.mjs'; -import { buildIOS } from './utils/buildIOS.mjs'; -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform, profile } = argv; -const { ios, android, outputDirectory } = getBuildInfo(); - -setEnvVars(); - -// Ensure output directory exists -await fs.ensureDir(outputDirectory); - -// Run prebuild to generate native projects -console.log(`Running prebuild for ${platform}...`); -await $`npx expo prebuild --platform ${platform} --clean`; - -// Build for the specific platform -if (platform === 'ios') { - await buildIOS({ profile, ios }); - await ios.unzip(); -} - -if (platform === 'android') { - await buildAndroid({ profile, android }); - await android.unzip(); -} diff --git a/apps/mobile-app/scripts/detox.mjs b/apps/mobile-app/scripts/detox.mjs deleted file mode 100644 index bb9972f7d8..0000000000 --- a/apps/mobile-app/scripts/detox.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import { $, argv, within } from 'zx'; // https://github.com/google/zx - -import detoxConfig from '../detox.config.js'; - -import { isCI } from './utils/env.mjs'; -import { getAffectedRoutes } from './utils/getAffectedRoutes.mjs'; -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform, profile, jsEngine } = argv; - -const { commonChanged, affectedRouteKeys } = await getAffectedRoutes(); - -const runAll = process.env.DETOX_RUN_ALL === 'true'; - -// Only run detox if packages/common or code in a route's parent directory changed, or DETOX_RUN_ALL is true -if (!runAll && !commonChanged && !affectedRouteKeys.length) { - console.log('No relevant changes to test, skipping detox'); - process.exit(0); -} - -// Set the affected route keys for playgroundRoutes.e2e.ts, and flag as Percy partial build -if (!runAll && !commonChanged) { - console.log('Only testing routes affected by changes:', affectedRouteKeys); - process.env.DETOX_AFFECTED_ROUTE_KEYS = affectedRouteKeys.join(','); - process.env.PERCY_PARTIAL_BUILD = '1'; -} else console.log('Testing all routes'); - -const { ios, android } = getBuildInfo(); - -setEnvVars(); - -if (platform === 'android') { - const targetAvd = detoxConfig.devices.emulator.device.avdName; - const { stdout: platformsAsString } = await $`ls ${process.env.ANDROID_SDK_ROOT}/platforms`; - const { stdout: buildToolsAsString } = await $`ls ${process.env.ANDROID_SDK_ROOT}/build-tools`; - const { stdout: emulatorsAsString } = await $`avdmanager list avd --compact`; - const platforms = platformsAsString.split('\n'); - const buildTools = buildToolsAsString.split('\n'); - const emulators = emulatorsAsString.split('\n'); - const doesNotHavePlatform = !platforms.includes(`android-${android.sdkVersions.platform}`); - const doesNotHaveBuildTools = !buildTools.includes(android.sdkVersions.buildTools); - const doesNotHaveEmulator = !emulators.includes(targetAvd); - - if (doesNotHavePlatform) { - await $`sdkmanager "platforms;android-${android.sdkVersions.platform}"`; - } - - if (doesNotHaveBuildTools) { - await $`sdkmanager "build-tools;${android.sdkVersions.buildTools}"`; - } - - if (doesNotHaveEmulator) { - const architecture = isCI ? android.architectures.ubuntu : android.architectures.m1; - const androidSdk = `system-images;android-${android.sdkVersions.systemImage};default;${architecture}`; - - await $`sdkmanager ${androidSdk}`; - await $`echo no | avdmanager create avd --force --name ${targetAvd} --package ${androidSdk}`; - } -} - -if (profile === 'debug') { - within(async () => { - await $`cd ../../ && yarn nx run mobile-app:launch --profile ${profile} --jsEngine ${jsEngine} --platform ${platform}`; - await $`cd ../../ && yarn nx run mobile-app:start --profile ${profile} --jsEngine ${jsEngine} --platform ${platform}`; - }); -} - -if (profile === 'release') { - if (platform === 'android') await android.patchBundle(); - if (platform === 'ios') await ios.patchBundle(); -} - -// Rebuild Detox cache on MacOS to mitigate errors from Xcode updates -if (platform === 'ios') { - await $`yarn workspace mobile-app detox rebuild-framework-cache`; -} - -// Clear Jest cache -await $`yarn workspace mobile-app detox test --configuration ${platform}-${profile} --clearCache`; - -const platformOptions = platform === 'android' ? '--force-adb-install' : ''; - -if (isCI) { - await $`yarn workspace mobile-app detox test --configuration ${platform}-${profile} --headless --cleanup ${platformOptions}`; -} else { - await $`yarn workspace mobile-app detox test --loglevel verbose --configuration ${platform}-${profile} --cleanup ${platformOptions}`; -} diff --git a/apps/mobile-app/scripts/launch.mjs b/apps/mobile-app/scripts/launch.mjs deleted file mode 100644 index 8af226013e..0000000000 --- a/apps/mobile-app/scripts/launch.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { android, ios } = getBuildInfo(); -const { platform } = argv; - -setEnvVars(); - -const archivePath = platform === 'android' ? android.apk.signed : ios.app; - -if (platform === 'android') { - await android.patchBundle(); -} - -if (platform === 'ios') { - await ios.patchBundle(); -} - -// Install and run the built app using platform-specific tools -if (platform === 'ios') { - await $`xcrun simctl install booted ${archivePath}`; - const bundleId = ios.bundleIdentifier; - await $`xcrun simctl launch booted ${bundleId}`; -} else { - // For Android, install the APK - await $`adb install ${archivePath}`; - // Launch the app - const packageId = android.packageIdentifier; - await $`adb shell monkey -p ${packageId} -c android.intent.category.LAUNCHER 1`; -} diff --git a/apps/mobile-app/scripts/patch-bundle.mjs b/apps/mobile-app/scripts/patch-bundle.mjs deleted file mode 100644 index 52725f70df..0000000000 --- a/apps/mobile-app/scripts/patch-bundle.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { argv } from 'zx'; - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -setEnvVars(); -const { ios, android } = getBuildInfo(); - -if (argv.platform === 'ios') await ios.patchBundle(); -if (argv.platform === 'android') await android.patchBundle(); diff --git a/apps/mobile-app/scripts/start.mjs b/apps/mobile-app/scripts/start.mjs deleted file mode 100644 index e1ad5d0aaa..0000000000 --- a/apps/mobile-app/scripts/start.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './utils/getBuildInfo.mjs'; -import { setEnvVars } from './utils/setEnvVars.mjs'; - -$.verbose = true; - -const { platform } = argv; -const { ios } = getBuildInfo(); - -setEnvVars(); - -if (platform === 'ios') { - await $`expo start --${argv.platform} --dev-client --localhost --scheme ${ios.bundleIdentifier}`; -} - -if (platform === 'android') { - await $`expo start --${argv.platform} --dev-client --localhost`; -} diff --git a/apps/mobile-app/scripts/utils/apktool.jar b/apps/mobile-app/scripts/utils/apktool.jar deleted file mode 100644 index 24a539a247..0000000000 Binary files a/apps/mobile-app/scripts/utils/apktool.jar and /dev/null differ diff --git a/apps/mobile-app/scripts/utils/buildAndroid.mjs b/apps/mobile-app/scripts/utils/buildAndroid.mjs deleted file mode 100644 index 6535cb5f7f..0000000000 --- a/apps/mobile-app/scripts/utils/buildAndroid.mjs +++ /dev/null @@ -1,60 +0,0 @@ -import path from 'path'; -import { $, fs } from 'zx'; - -export async function buildAndroid({ profile, android }) { - const androidProjectPath = path.resolve('android'); - const isDebug = profile === 'debug'; - const buildType = isDebug ? 'Debug' : 'Release'; - - console.log(`Building Android app with build type: ${buildType}`); - - // Set up gradle command based on profile - let gradleTasks; - if (isDebug) { - gradleTasks = [':app:assembleDebug', ':app:assembleAndroidTest', '-DtestBuildType=debug']; - } else { - gradleTasks = [':app:assembleRelease', ':app:assembleAndroidTest', '-DtestBuildType=release']; - } - - // Build the APK - await $`cd ${androidProjectPath} && ./gradlew ${gradleTasks} --no-daemon`; - - // Find the built APKs - const buildOutputDir = path.join(androidProjectPath, 'app', 'build', 'outputs', 'apk'); - const testOutputDir = path.join(buildOutputDir, 'androidTest', profile); - const appOutputDir = path.join(buildOutputDir, profile); - - // Create output directory for our builds - const outputDir = path.dirname(android.zipFile); - await fs.ensureDir(outputDir); - - // Create a temporary directory for the zip contents - const tempDir = path.join(outputDir, 'temp'); - await fs.ensureDir(tempDir); - - // Create the expected directory structure for the zip - const testFolder = path.join(tempDir, 'androidTest', profile); - const buildFolder = path.join(tempDir, profile); - await fs.ensureDir(testFolder); - await fs.ensureDir(buildFolder); - - // Copy APKs to the temporary directory with expected names - const appApkName = `app-${profile}.apk`; - const testApkName = `app-${profile}-androidTest.apk`; - - // Find and copy the actual APK files - const appApkSource = path.join(appOutputDir, appApkName); - const testApkSource = path.join(testOutputDir, testApkName); - - await fs.copy(appApkSource, path.join(buildFolder, appApkName)); - await fs.copy(testApkSource, path.join(testFolder, testApkName)); - - // Create the zip file (tar format to match the original unzip logic) - console.log(`Creating archive: ${android.zipFile}`); - await $`cd ${tempDir} && zip -r ${path.resolve(android.zipFile)} .`; - - // Clean up temporary directory - await $`rm -rf ${tempDir}`; - - console.log(`Android build completed: ${android.zipFile}`); -} diff --git a/apps/mobile-app/scripts/utils/buildIOS.mjs b/apps/mobile-app/scripts/utils/buildIOS.mjs deleted file mode 100644 index 0cfcc31da3..0000000000 --- a/apps/mobile-app/scripts/utils/buildIOS.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import path from 'path'; -import { $, fs } from 'zx'; - -export async function buildIOS({ profile, ios }) { - const buildConfiguration = profile === 'debug' ? 'Debug' : 'Release'; - const iosProjectPath = path.resolve('ios'); - const workspacePath = path.join(iosProjectPath, 'CDS.xcworkspace'); - const scheme = 'CDS'; - - console.log(`Building iOS app with configuration: ${buildConfiguration}`); - - // Build directory for output - const buildDir = path.resolve('build'); - await fs.ensureDir(buildDir); - - // Build command for simulator - await $`xcodebuild -workspace ${workspacePath} -scheme ${scheme} -configuration ${buildConfiguration} -derivedDataPath ./build -destination 'generic/platform=iOS Simulator' build`; - - // Find the built app - const appPath = path.join( - buildDir, - 'Build', - 'Products', - `${buildConfiguration}-iphonesimulator`, - 'CDS.app', - ); - - // Create tarball - console.log(`Creating tarball: ${ios.tarball}`); - await $`cd ${path.dirname(appPath)} && tar -czf ${path.resolve(ios.tarball)} ${path.basename( - appPath, - )}`; - - // Clean up build directory - await $`rm -rf ${buildDir}`; - - console.log(`iOS build completed: ${ios.tarball}`); -} diff --git a/apps/mobile-app/scripts/utils/env.mjs b/apps/mobile-app/scripts/utils/env.mjs deleted file mode 100644 index d4de4464ee..0000000000 --- a/apps/mobile-app/scripts/utils/env.mjs +++ /dev/null @@ -1 +0,0 @@ -export const isCI = process.env.CI === 'true' || process.env.BUILDKITE === 'true'; diff --git a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs b/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs deleted file mode 100644 index af260a8619..0000000000 --- a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs +++ /dev/null @@ -1,146 +0,0 @@ -import { spawnSync } from 'node:child_process'; -import path from 'node:path'; - -import pkg from '../../package.json' with { type: 'json' }; - -import { routes } from './routes.mjs'; - -const IGNORE_CHANGED_FILES_REGEX = - /^((CHANGELOG|README|MIGRATION|CONTRIBUTING)(\.md)?|[^/]+\.yml|OWNERS|project\.json|[^/]+\.[dD]ockerfile|tsconfig\.json|jest\.config\.js|\.?eslint.*)$/; -const DEV_FILES_REGEX = /(\.(spec|test|figma)\.[jt]sx?(\.snap)?$)/; - -/** - * Returns an array of changed filepaths between a branch and another base branch - */ -const getChangedFilesOnBranch = (branch, baseBranch) => { - const command = `git diff --name-only ${branch} $(git merge-base ${branch} ${baseBranch})`.split( - ' ', - ); - const changedFiles = spawnSync(command.shift() ?? '', command, { encoding: 'utf8', shell: true }); - return changedFiles.stdout.split('\n').filter(Boolean); -}; - -/** - * Returns an array of workspace dependency package names - */ -const getWorkspaceDependencies = (dependencies) => - Object.entries(dependencies) - .filter(([, version]) => version.startsWith('workspace:')) - .map(([dependency]) => dependency); - -/** - * Returns a map of workspace dependencies to their directories resolved from tsconfig paths - */ -const getWorkspaceDirectoryMap = (workspaceDependencies, tsconfigPaths) => - Object.fromEntries( - workspaceDependencies.map((dependency) => { - if (!tsconfigPaths[dependency]) - throw Error(`Missing dependency in tsconfig "paths": ${dependency}`); - return [ - dependency, - tsconfigPaths[dependency].map((dependencyPath) => dependencyPath.replace('/*', '')), - ]; - }), - ); - -/** - * Returns the workspace dependency that maps to the given directory - */ -const getWorkspaceDependencyByDirectory = (workspaceDirectory, workspaceDirectoryMap) => - Object.entries(workspaceDirectoryMap).find(([, directories]) => - directories.includes(workspaceDirectory), - )[0]; - -/** - * Returns an array of objects with playground route keys and import paths - */ -const getRoutesData = (generatedRoutes) => - generatedRoutes.map((route) => ({ - key: route.key, - importPath: route.getComponent.toString().split("'")[1], - })); - -/** - * Returns an array of import paths for the changed files - */ -const getImportPathsFromFiles = (files, sourceDirectories, workspaceDirectoryMap) => - files.map((file) => { - const matchingDirectory = sourceDirectories.find((directory) => file.startsWith(directory)); - const workspaceDependency = getWorkspaceDependencyByDirectory( - matchingDirectory, - workspaceDirectoryMap, - ); - const matchingDirectoryPathsLength = matchingDirectory.split('/').length; - const filePaths = file.split('/'); - const truncatedFilepath = filePaths.slice(0, matchingDirectoryPathsLength + 1).join('/'); - return truncatedFilepath.replace(matchingDirectory, workspaceDependency); - }); - -/** - * Returns true when a changed file should impact mobile visreg. - */ -const isFileVisregRelevant = (file, sourceDirectories) => { - const matchingDirectory = sourceDirectories.find((directory) => file.startsWith(directory)); - if (!matchingDirectory) { - return false; - } - - const relativeFilePath = file.slice(matchingDirectory.length + 1); - return ( - !DEV_FILES_REGEX.test(relativeFilePath) && !IGNORE_CHANGED_FILES_REGEX.test(relativeFilePath) - ); -}; - -/** - * Returns an object with a boolean for whether the common package changed and an array of - * ui-mobile-playground route keys that were affected by the changed files. - */ -export const getAffectedRoutes = async (log = false) => { - const baseBranch = process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH || 'master'; - const changedFiles = getChangedFilesOnBranch('HEAD', baseBranch); - const workspaceDependencies = getWorkspaceDependencies(pkg.dependencies); - - const MONOREPO_ROOT = process.env.PROJECT_CWD ?? process.env.NX_MONOREPO_ROOT; - const tsconfigPath = path.resolve(MONOREPO_ROOT, 'tsconfig.base.json'); - const tsconfig = (await import(tsconfigPath, { assert: { type: 'json' } })).default; - const tsconfigPaths = tsconfig.compilerOptions.paths; - - const workspaceDirectoryMap = getWorkspaceDirectoryMap(workspaceDependencies, tsconfigPaths); - const sourceDirectories = Object.values(workspaceDirectoryMap).flat(); - - const relevantChangedFiles = changedFiles.filter((file) => - isFileVisregRelevant(file, sourceDirectories), - ); - const commonChanged = relevantChangedFiles.some((file) => file.startsWith('packages/common/')); - - const affectedImportPaths = getImportPathsFromFiles( - relevantChangedFiles, - sourceDirectories, - workspaceDirectoryMap, - ); - - const routesData = getRoutesData(routes); - - const affectedRoutesData = routesData.filter((routeData) => - affectedImportPaths.some((changedImportPath) => - routeData.importPath.startsWith(changedImportPath), - ), - ); - - const affectedRouteKeys = affectedRoutesData.map((routeData) => routeData.key); - - if (log) { - console.log('changedFiles', changedFiles); - console.log('commonChanged', commonChanged); - console.log('workspaceDependencies', workspaceDependencies); - console.log('workspaceDirectoryMap', workspaceDirectoryMap); - console.log('sourceDirectories', sourceDirectories); - console.log('relevantChangedFiles', relevantChangedFiles); - console.log('affectedImportPaths', affectedImportPaths); - console.log('routesData', routesData); - console.log('affectedRoutesData', affectedRoutesData); - console.log('affectedRouteKeys', affectedRouteKeys); - } - - return { commonChanged, affectedRouteKeys }; -}; diff --git a/apps/mobile-app/scripts/utils/getBuildInfo.mjs b/apps/mobile-app/scripts/utils/getBuildInfo.mjs deleted file mode 100644 index dba2660cc0..0000000000 --- a/apps/mobile-app/scripts/utils/getBuildInfo.mjs +++ /dev/null @@ -1,142 +0,0 @@ -import path from 'node:path'; -import { $, argv, glob } from 'zx'; // https://github.com/google/zx - -import credentials from '../../credentials.json' with { type: 'json' }; - -$.verbose = true; - -const outputDirectory = 'prebuilds'; -const filePath = new URL(import.meta.url).pathname; -const scriptUtilsDirectory = path.dirname(filePath); -const { platform, profile, jsEngine, newArchEnabled = false } = argv; - -async function patchBundleForPlatform({ platform: platformParam, fileToPatch }) { - await $`expo export --output-dir lib -p ${platformParam}`; - const matches = await glob([`lib/_expo/static/js/${platformParam}/index-*`]); - if (matches.length) { - const jsBundle = matches[0]; - await $`mv ${jsBundle} ${fileToPatch}`; - await $`rm -rf lib`; - } else { - throw new Error(`Unable to find jsbundle for ${platformParam}`); - } -} - -export function getBuildInfo() { - const kebabCaseId = `${platform}-${profile}-${jsEngine}${newArchEnabled ? '-newArch' : ''}`; - const snakeCaseId = kebabCaseId.replaceAll('-', '_'); - const outputName = `${outputDirectory}/${kebabCaseId}`; - - const ios = { - tarball: `${outputName}.tar.gz`, - bundleIdentifier: `com.ui-systems.${kebabCaseId}`, - app: `${outputName}.app`, - unzip: async function unzip() { - await $`rm -rf ${this.app}`; - await $`tar -zxvf ${this.tarball}`; - await $`mv CDS.app ${this.app}`; - }, - patchBundle: async function patchBundle() { - if (process.env.SKIP_PATCH_BUNDLE) return; - await this.unzip(); - if (profile !== 'debug') { - await patchBundleForPlatform({ - platform: 'ios', - fileToPatch: `${this.app}/main.jsbundle`, - }); - } - }, - }; - - const android = { - sdkVersions: { - platform: '34', - buildTools: '35.0.0', - systemImage: '30', - }, - zipFile: `${outputName}.zip`, - packageIdentifier: `com.ui_systems.${snakeCaseId}`, - keystore: credentials.android.keystore, - apk: { - contents: `${outputName}/build`, - rebuilt: `${outputName}/binary-rebuilt.apk`, - rebuiltAligned: `${outputName}/binary-rebuilt-aligned.apk`, - signed: `${outputName}/binary.apk`, - }, - testApk: `${outputName}/testBinary.apk`, - // https://expo.canny.io/feature-requests/p/add-reactnativearchitecture-support-in-expo-build-properties - // There archs are also set in eas.json, ORG_GRADLE_PROJECT_reactNativeArchitectures env variable - architectures: { - ubuntu: 'x86_64', - m1: 'arm64-v8a', - }, - getBuildTool: async function getBuildTool(name) { - return path.join( - process.env.ANDROID_SDK_ROOT, - 'build-tools', - this.sdkVersions.buildTools, - name, - ); - }, - unzip: async function unzip() { - await $`rm -rf ${outputName}`; - await $`mkdir -p ${outputName}`; - const testFolder = `${outputName}/androidTest/${profile}`; - const buildFolder = `${outputName}/${profile}`; - await $`unzip -q ${this.zipFile} -d ${outputName}`; - await $`mv ${testFolder}/app-${profile}-androidTest.apk ${this.testApk}`; - await $`mv ${buildFolder}/app-${profile}.apk ${this.apk.signed}`; - await $`rm -rf ${path.dirname(testFolder)} && rm -rf ${buildFolder}`; - }, - /** - * What's java -jar? - * https://bitbucket.org/iBotPeaches/apktool/downloads/ - * Instead of installing apktool locally and in CI, we commit the apktool.jar and - * run executable via java -jar and point to the .jar file we commit. - * - * Why String.split? - * https://github.com/google/zx/blob/main/docs/quotes.md#array-of-arguments - * zx escapes and adds quotes to any interpolations used in a $ command. - * Because this string as command + args we have to convert it into an array of args - * to avoid it being treated as one single string when used in zx's $ template literal. - */ - apktool: `java -jar ${scriptUtilsDirectory}/apktool.jar`.split(' '), - decodeApk: async function decode() { - await this.unzip(); - await $`${this.apktool} decode -f ${this.apk.signed} --output ${this.apk.contents}`; - }, - rebuildApk: async function rebuildApk() { - const [apksigner, zipalign] = await Promise.all([ - this.getBuildTool('apksigner'), - this.getBuildTool('zipalign'), - ]); - const ksPass = `pass:${this.keystore.keystorePassword}`; - const keyPass = `pass:${this.keystore.keyPassword}`; - await $`${this.apktool} build ${this.apk.contents} --output ${this.apk.rebuilt}`; - await $`${zipalign} 4 ${this.apk.rebuilt} ${this.apk.rebuiltAligned}`; - await $`${apksigner} sign --ks ${this.keystore.keystorePath} --ks-key-alias ${this.keystore.keyAlias} --ks-pass ${ksPass} --key-pass ${keyPass} --out ${this.apk.signed} ${this.apk.rebuiltAligned}`; - await Promise.all([$`rm -rf ${this.apk.rebuilt}`, $`rm -rf ${this.apk.rebuiltAligned}`]); - }, - patchBundle: async function patchBundle() { - if (process.env.SKIP_PATCH_BUNDLE) return; - if (profile === 'debug') { - await this.unzip(); - return; - } - - await this.decodeApk(); - await patchBundleForPlatform({ - platform: 'android', - fileToPatch: `${this.apk.contents}/assets/index.android.bundle`, - }); - await this.rebuildApk(); - await $`rm -rf ${this.apk.contents}`; - }, - }; - - return { - ios, - android, - outputDirectory, - }; -} diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs deleted file mode 100644 index 9aaf028390..0000000000 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ /dev/null @@ -1,904 +0,0 @@ -/** - * DO NOT MODIFY - * Generated from scripts/codegen/main.ts - */ -export const routes = [ - { - key: 'Accordion', - getComponent: () => - require('@coinbase/cds-mobile/accordion/__stories__/Accordion.stories').default, - }, - { - key: 'AlertBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertBasic.stories').default, - }, - { - key: 'AlertLongTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertLongTitle.stories').default, - }, - { - key: 'AlertOverModal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertOverModal.stories').default, - }, - { - key: 'AlertPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertPortal.stories').default, - }, - { - key: 'AlertSingleAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertSingleAction.stories').default, - }, - { - key: 'AlertVerticalActions', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, - }, - { - key: 'AlphaSelect', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, - }, - { - key: 'AlphaSelectChip', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, - }, - { - key: 'AlphaTabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') - .default, - }, - { - key: 'AnimatedCaret', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, - }, - { - key: 'AreaChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') - .default, - }, - { - key: 'Avatar', - getComponent: () => require('@coinbase/cds-mobile/media/__stories__/Avatar.stories').default, - }, - { - key: 'AvatarButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/AvatarButton.stories').default, - }, - { - key: 'Axis', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, - }, - { - key: 'Banner', - getComponent: () => require('@coinbase/cds-mobile/banner/__stories__/Banner.stories').default, - }, - { - key: 'BannerActions', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerActions.stories').default, - }, - { - key: 'BannerLayout', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerLayout.stories').default, - }, - { - key: 'BarChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, - }, - { - key: 'Box', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Box.stories').default, - }, - { - key: 'BrowserBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBar.stories').default, - }, - { - key: 'BrowserBarSearchInput', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBarSearchInput.stories').default, - }, - { - key: 'Button', - getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/Button.stories').default, - }, - { - key: 'ButtonGroup', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, - }, - { - key: 'Calendar', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/Calendar.stories').default, - }, - { - key: 'Card', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, - }, - { - key: 'Carousel', - getComponent: () => - require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, - }, - { - key: 'CarouselMedia', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, - }, - { - key: 'CartesianChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') - .default, - }, - { - key: 'ChartAccessibility', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') - .default, - }, - { - key: 'ChartTransitions', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') - .default, - }, - { - key: 'Checkbox', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/Checkbox.stories').default, - }, - { - key: 'CheckboxCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/CheckboxCell.stories').default, - }, - { - key: 'Chip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/Chip.stories').default, - }, - { - key: 'Coachmark', - getComponent: () => - require('@coinbase/cds-mobile/coachmark/__stories__/Coachmark.stories').default, - }, - { - key: 'Collapsible', - getComponent: () => - require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, - }, - { - key: 'Combobox', - getComponent: () => - require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, - }, - { - key: 'ComponentConfigProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, - }, - { - key: 'ComponentConfigProviderCustom', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') - .default, - }, - { - key: 'ContainedAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContainedAssetCard.stories').default, - }, - { - key: 'ContentCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContentCard.stories').default, - }, - { - key: 'ContentCell', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCell.stories').default, - }, - { - key: 'ContentCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCellFallback.stories').default, - }, - { - key: 'ControlGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, - }, - { - key: 'DataCard', - getComponent: () => - require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, - }, - { - key: 'DateInput', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, - }, - { - key: 'DatePicker', - getComponent: () => - require('@coinbase/cds-mobile/dates/__stories__/DatePicker.stories').default, - }, - { - key: 'Divider', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Divider.stories').default, - }, - { - key: 'Dot', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/Dot.stories').default, - }, - { - key: 'DotMisc', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/DotMisc.stories').default, - }, - { - key: 'DrawerBottom', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerBottom.stories').default, - }, - { - key: 'DrawerFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerFallback.stories').default, - }, - { - key: 'DrawerLeft', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerLeft.stories').default, - }, - { - key: 'DrawerMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerMisc.stories').default, - }, - { - key: 'DrawerReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerReduceMotion.stories').default, - }, - { - key: 'DrawerRight', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerRight.stories').default, - }, - { - key: 'DrawerScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerScrollable.stories').default, - }, - { - key: 'DrawerTop', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerTop.stories').default, - }, - { - key: 'Fallback', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Fallback.stories').default, - }, - { - key: 'FloatingAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/FloatingAssetCard.stories').default, - }, - { - key: 'Frontier', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, - }, - { - key: 'Group', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, - }, - { - key: 'HeroSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/HeroSquare.stories').default, - }, - { - key: 'HintMotion', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/HintMotion.stories').default, - }, - { - key: 'IconButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconButton.stories').default, - }, - { - key: 'IconCounterButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconCounterButton.stories').default, - }, - { - key: 'InputChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/InputChip.stories').default, - }, - { - key: 'InputIcon', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIcon.stories').default, - }, - { - key: 'InputIconButton', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIconButton.stories').default, - }, - { - key: 'InputStack', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, - }, - { - key: 'Legend', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, - }, - { - key: 'LinearGradient', - getComponent: () => - require('@coinbase/cds-mobile/gradients/__stories__/LinearGradient.stories').default, - }, - { - key: 'LineChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') - .default, - }, - { - key: 'Link', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Link.stories').default, - }, - { - key: 'ListCell', - getComponent: () => require('@coinbase/cds-mobile/cells/__stories__/ListCell.stories').default, - }, - { - key: 'ListCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ListCellFallback.stories').default, - }, - { - key: 'Logo', - getComponent: () => require('@coinbase/cds-mobile/icons/__stories__/Logo.stories').default, - }, - { - key: 'Lottie', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/Lottie.stories').default, - }, - { - key: 'LottieStatusAnimation', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default, - }, - { - key: 'MediaCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default, - }, - { - key: 'MediaChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default, - }, - { - key: 'MessagingCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, - }, - { - key: 'ModalBackButton', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBackButton.stories').default, - }, - { - key: 'ModalBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBasic.stories').default, - }, - { - key: 'ModalCustomPadding', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalCustomPadding.stories').default, - }, - { - key: 'ModalLong', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalLong.stories').default, - }, - { - key: 'ModalPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalPortal.stories').default, - }, - { - key: 'MultiContentModule', - getComponent: () => - require('@coinbase/cds-mobile/multi-content-module/__stories__/MultiContentModule.stories') - .default, - }, - { - key: 'NavBarIconButton', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavBarIconButton.stories').default, - }, - { - key: 'NavigationSubtitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationSubtitle.stories').default, - }, - { - key: 'NavigationTitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitle.stories').default, - }, - { - key: 'NavigationTitleSelect', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitleSelect.stories').default, - }, - { - key: 'NudgeCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/NudgeCard.stories').default, - }, - { - key: 'Numpad', - getComponent: () => require('@coinbase/cds-mobile/numpad/__stories__/Numpad.stories').default, - }, - { - key: 'Overlay', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/Overlay.stories').default, - }, - { - key: 'PageFooter', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageFooter.stories').default, - }, - { - key: 'PageFooterInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageFooterInPage.stories').default, - }, - { - key: 'PageHeader', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageHeader.stories').default, - }, - { - key: 'PageHeaderInErrorEmptyState', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInErrorEmptyState.stories').default, - }, - { - key: 'PageHeaderInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInPage.stories').default, - }, - { - key: 'Palette', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Palette.stories').default, - }, - { - key: 'PatternDisclosureHighFrictionBenefit', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionBenefit.stories') - .default, - }, - { - key: 'PatternDisclosureHighFrictionRisk', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionRisk.stories') - .default, - }, - { - key: 'PatternDisclosureLowFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureLowFriction.stories') - .default, - }, - { - key: 'PatternDisclosureMedFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureMedFriction.stories') - .default, - }, - { - key: 'PatternError', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, - }, - { - key: 'PeriodSelector', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') - .default, - }, - { - key: 'Pictogram', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, - }, - { - key: 'Pressable', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/Pressable.stories').default, - }, - { - key: 'PressableOpacity', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PressableOpacity.stories').default, - }, - { - key: 'ProgressBar', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressBar.stories').default, - }, - { - key: 'ProgressCircle', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressCircle.stories').default, - }, - { - key: 'RadioCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioCell.stories').default, - }, - { - key: 'RadioGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioGroup.stories').default, - }, - { - key: 'ReferenceLine', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') - .default, - }, - { - key: 'RemoteImage', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImage.stories').default, - }, - { - key: 'RemoteImageGroup', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImageGroup.stories').default, - }, - { - key: 'RollingNumber', - getComponent: () => - require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, - }, - { - key: 'Scrubber', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') - .default, - }, - { - key: 'SearchInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SearchInput.stories').default, - }, - { - key: 'SectionHeader', - getComponent: () => - require('@coinbase/cds-mobile/section-header/__stories__/SectionHeader.stories').default, - }, - { - key: 'SegmentedTabs', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/SegmentedTabs.stories').default, - }, - { - key: 'Select', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Select.stories').default, - }, - { - key: 'SelectChip', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, - }, - { - key: 'SelectOption', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SelectOption.stories').default, - }, - { - key: 'SlideButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/SlideButton.stories').default, - }, - { - key: 'Spacer', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Spacer.stories').default, - }, - { - key: 'Sparkline', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, - }, - { - key: 'SparklineGradient', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') - .default, - }, - { - key: 'SparklineInteractive', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') - .default, - }, - { - key: 'SparklineInteractiveHeader', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') - .default, - }, - { - key: 'Spectrum', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Spectrum.stories').default, - }, - { - key: 'Spinner', - getComponent: () => require('@coinbase/cds-mobile/loaders/__stories__/Spinner.stories').default, - }, - { - key: 'SpotIcon', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotIcon.stories').default, - }, - { - key: 'SpotRectangle', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotRectangle.stories').default, - }, - { - key: 'SpotSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotSquare.stories').default, - }, - { - key: 'StepperHorizontal', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperHorizontal.stories').default, - }, - { - key: 'StepperVertical', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperVertical.stories').default, - }, - { - key: 'StickyFooter', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooter.stories').default, - }, - { - key: 'StickyFooterWithTray', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooterWithTray.stories') - .default, - }, - { - key: 'Switch', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Switch.stories').default, - }, - { - key: 'TabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/TabbedChips.stories').default, - }, - { - key: 'TabIndicator', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabIndicator.stories').default, - }, - { - key: 'TabLabel', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/TabLabel.stories').default, - }, - { - key: 'TabNavigation', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabNavigation.stories').default, - }, - { - key: 'Tabs', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, - }, - { - key: 'Tag', - getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, - }, - { - key: 'Text', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Text.stories').default, - }, - { - key: 'TextBody', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextBody.stories').default, - }, - { - key: 'TextCaption', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCaption.stories').default, - }, - { - key: 'TextCore', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCore.stories').default, - }, - { - key: 'TextDisplay1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay1.stories').default, - }, - { - key: 'TextDisplay2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay2.stories').default, - }, - { - key: 'TextDisplay3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay3.stories').default, - }, - { - key: 'TextHeadline', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextHeadline.stories').default, - }, - { - key: 'TextInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/TextInput.stories').default, - }, - { - key: 'TextLabel1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel1.stories').default, - }, - { - key: 'TextLabel2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel2.stories').default, - }, - { - key: 'TextLegal', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLegal.stories').default, - }, - { - key: 'TextTitle1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle1.stories').default, - }, - { - key: 'TextTitle2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle2.stories').default, - }, - { - key: 'TextTitle3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle3.stories').default, - }, - { - key: 'TextTitle4', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle4.stories').default, - }, - { - key: 'ThemeProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ThemeProvider.stories').default, - }, - { - key: 'Toast', - getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/Toast.stories').default, - }, - { - key: 'TooltipV2', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TooltipV2.stories').default, - }, - { - key: 'TopNavBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/TopNavBar.stories').default, - }, - { - key: 'Tour', - getComponent: () => require('@coinbase/cds-mobile/tour/__stories__/Tour.stories').default, - }, - { - key: 'TrayAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayAction.stories').default, - }, - { - key: 'TrayBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayBasic.stories').default, - }, - { - key: 'TrayFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFallback.stories').default, - }, - { - key: 'TrayFeedCard', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, - }, - { - key: 'TrayInformational', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayInformational.stories').default, - }, - { - key: 'TrayMessaging', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMessaging.stories').default, - }, - { - key: 'TrayMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMisc.stories').default, - }, - { - key: 'TrayNavigation', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayNavigation.stories').default, - }, - { - key: 'TrayPromotional', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayPromotional.stories').default, - }, - { - key: 'TrayRedesign', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayRedesign.stories').default, - }, - { - key: 'TrayReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayReduceMotion.stories').default, - }, - { - key: 'TrayScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayScrollable.stories').default, - }, - { - key: 'TrayTall', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayTall.stories').default, - }, - { - key: 'TrayWithTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayWithTitle.stories').default, - }, - { - key: 'UpsellCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/UpsellCard.stories').default, - }, -]; diff --git a/apps/mobile-app/scripts/utils/setEnvVars.mjs b/apps/mobile-app/scripts/utils/setEnvVars.mjs deleted file mode 100644 index c2b501d05b..0000000000 --- a/apps/mobile-app/scripts/utils/setEnvVars.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import { $, argv } from 'zx'; // https://github.com/google/zx - -import { getBuildInfo } from './getBuildInfo.mjs'; - -export function setEnvVars() { - const { debug = false, newArchEnabled = false, jsEngine, profile, platform } = argv; - const { ios, android, outputDirectory } = getBuildInfo(); - - /** - * Environment variables for Expo CLI builds - */ - $.prefix += ` - export RCT_NO_LAUNCH_PACKAGER=1; - export APP_PROFILE=${profile}; - export APP_PLATFORM=${platform}; - export APP_JS_ENGINE=${jsEngine}; - export APP_IOS_BUNDLE_IDENTIFIER=${ios.bundleIdentifier}; - export APP_ANDROID_PACKAGE_IDENTIFIER=${android.packageIdentifier}; - export EXPO_NO_TELEMETRY=1; - export EXPO_USE_CUSTOM_INSPECTOR_PROXY=1; - export BUILD_ARTIFACTS_DIR=${outputDirectory}; - export EXPO_NO_REDIRECT_PAGE=1; - export EXPO_USE_UPDATES=1; - `; - - if (debug) { - $.prefix += ` - export DEBUG=*; - export APP_DEBUG=1; - export BUILD_SKIP_CLEANUP=1; - export EXPO_PROFILE=1; - `; - } - - if (newArchEnabled) { - $.prefix += `export APP_NEW_ARCH_ENABLED=1;`; - } -} diff --git a/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs b/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs deleted file mode 100644 index 29a6dce323..0000000000 --- a/apps/mobile-app/scripts/utils/shouldRunVisreg.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { getAffectedRoutes } from './getAffectedRoutes.mjs'; - -const { commonChanged, affectedRouteKeys } = await getAffectedRoutes(); - -// If we're not on the master branch and nothing relevant has changed, we don't need to run detox -if (!commonChanged && !affectedRouteKeys.length) process.exit(1); - -process.exit(0); diff --git a/apps/mobile-app/scripts/validate.mjs b/apps/mobile-app/scripts/validate.mjs deleted file mode 100644 index 09da5d919c..0000000000 --- a/apps/mobile-app/scripts/validate.mjs +++ /dev/null @@ -1,16 +0,0 @@ -import { $, log } from 'zx'; // https://github.com/google/zx - -$.verbose = true; - -/** - * Fail if any installed packages are outdated. - * https://docs.expo.dev/workflow/expo-cli/#environment-variables:~:text=on%20your%20machine.-,CI,-boolean - */ -const { stdout, stderr } = await $`expo install --check`; -if (stdout) { - log({ kind: 'stdout', data: stdout }); -} - -if (stderr) { - log({ kind: 'stderr', data: stderr }); -} diff --git a/apps/mobile-app/src/index.ts b/apps/mobile-app/src/index.ts deleted file mode 100644 index 2bd5dc541f..0000000000 --- a/apps/mobile-app/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -// Empty file to please tsconfig `include` `src` config diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 9549d3063d..2c48226e3c 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -24,8 +24,8 @@ const bundleStatsFilename = path.resolve( ); const addons = [ // '@chromatic-com/storybook', - '@storybook/addon-storysource', '@storybook-community/storybook-dark-mode', + '@storybook/addon-docs', ...(!isPercyBuild ? ['@storybook/addon-a11y', '@storybook/addon-vitest'] : []), ]; @@ -46,10 +46,7 @@ const config: StorybookConfig = { options: {}, }, addons, - stories: [ - '../../../packages/web/**/*.stories.@(tsx|mdx)', - '../../../packages/web-visualization/**/*.stories.@(tsx|mdx)', - ], + stories: ['../../../packages/web/**/*.stories.@(tsx|mdx)'], staticDirs: [ { from: path.resolve(MONOREPO_ROOT, 'packages/icons/src'), @@ -91,10 +88,6 @@ const config: StorybookConfig = { '@coinbase/cds-lottie-files': path.resolve(MONOREPO_ROOT, 'packages/lottie-files/src'), '@coinbase/cds-utils': path.resolve(MONOREPO_ROOT, 'packages/utils/src'), '@coinbase/cds-web': path.resolve(MONOREPO_ROOT, 'packages/web/src'), - '@coinbase/cds-web-visualization': path.resolve( - MONOREPO_ROOT, - 'packages/web-visualization/src', - ), }, }, }); diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index 7090edef8b..618f80314f 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -54,7 +54,7 @@ const preview: Preview = { decorators: [StoryContainer], parameters: { layout: 'fullscreen', - backgrounds: { disable: true }, + backgrounds: { disabled: true }, globalStyles: `${globalStyles} ${defaultFontStyles}`, controls: { matchers: { diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 561f5f3ab3..dc1ae3331c 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -11,36 +11,35 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { "@linaria/babel-preset": "^3.0.0-beta.22", "@linaria/core": "^3.0.0-beta.22", "@linaria/rollup": "^3.0.0-beta.22", "@percy/cli": "^1.31.1", - "@percy/storybook": "^9.0.0", + "@percy/storybook": "^9.1.0", "@shopify/storybook-a11y-test": "^1.2.1", "@storybook-community/storybook-dark-mode": "^6.0.0", "@storybook/addon-a11y": "^9.1.19", - "@storybook/addon-storysource": "^8.6.14", + "@storybook/addon-docs": "9.1.17", "@storybook/addon-vitest": "^9.1.2", "@storybook/jest": "^0.2.3", - "@storybook/react-vite": "^9.1.2", + "@storybook/react-vite": "9.1.17", "@storybook/testing-library": "^0.2.2", "@types/diff": "^5.0.9", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "@vitest/browser-playwright": "^4.0.18", "@vitest/coverage-v8": "^4.0.18", "diff": "^5.1.0", "playwright": "^1.58.2", "rollup-plugin-visualizer": "^6.0.3", - "storybook": "^9.1.2", + "storybook": "9.1.17", "typescript": "~5.9.2", - "vite": "^7.1.2", + "vite": "^7.3.1", "vitest": "^4.0.18" } } diff --git a/apps/storybook/project.json b/apps/storybook/project.json index 0706bfb3da..84bd2bfebf 100644 --- a/apps/storybook/project.json +++ b/apps/storybook/project.json @@ -22,7 +22,6 @@ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*", "!{projectRoot}/scripts/**" ], "outputs": [ @@ -40,7 +39,6 @@ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*", "!{projectRoot}/scripts/**" ], "outputs": [ @@ -70,8 +68,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist" @@ -87,8 +84,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist", @@ -130,8 +126,7 @@ "inputs": [ "{projectRoot}/**/__stories__/**", "{projectRoot}/**/*.stories.*", - "{workspaceRoot}/packages/web/**/*.stories.*", - "{workspaceRoot}/packages/web-visualization/**/*.stories.*" + "{workspaceRoot}/packages/web/**/*.stories.*" ], "outputs": [ "{projectRoot}/dist" diff --git a/apps/storybook/scripts/shouldRunVisreg.mjs b/apps/storybook/scripts/shouldRunVisreg.mjs index b8185fef25..6183656dae 100644 --- a/apps/storybook/scripts/shouldRunVisreg.mjs +++ b/apps/storybook/scripts/shouldRunVisreg.mjs @@ -4,7 +4,6 @@ const RELEVANT_ROOTS = [ 'apps/storybook', 'packages/common', 'packages/web', - 'packages/web-visualization', 'packages/icons', 'packages/illustrations', ]; diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index f1dd49fa55..24977feae5 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -26,9 +26,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/apps/test-expo/.gitignore b/apps/test-expo/.gitignore new file mode 100644 index 0000000000..66cc3840a6 --- /dev/null +++ b/apps/test-expo/.gitignore @@ -0,0 +1,49 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo +dts/ +*.d.ts +*.d.ts.map + +# generated native folders +/ios +/android + +# generated build artifacts (xcodebuild/gradle output, not committed) +builds/ +# extracted prebuilt .app directories (recreated by patch-bundle from the committed tarballs) +prebuilds/**/*.app diff --git a/apps/mobile-app/src/App.tsx b/apps/test-expo/App.tsx similarity index 69% rename from apps/mobile-app/src/App.tsx rename to apps/test-expo/App.tsx index 901d982d8b..9ba7f3ebc8 100644 --- a/apps/mobile-app/src/App.tsx +++ b/apps/test-expo/App.tsx @@ -1,5 +1,6 @@ -import React, { memo, StrictMode, useCallback, useMemo, useState } from 'react'; +import React, { memo, useMemo, useState } from 'react'; import { Platform } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import type { ColorScheme } from '@coinbase/cds-common/core/theme'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; @@ -7,14 +8,14 @@ import { PortalProvider } from '@coinbase/cds-mobile/overlays/PortalProvider'; import { StatusBar } from '@coinbase/cds-mobile/system/StatusBar'; import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; -import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization/chart'; -import { Playground } from '@coinbase/ui-mobile-playground'; +import { ChartBridgeProvider } from '@coinbase/cds-mobile/visualizations/chart'; import { CommonActions, NavigationContainer } from '@react-navigation/native'; import * as Linking from 'expo-linking'; import * as SplashScreen from 'expo-splash-screen'; -import { useFonts } from './hooks/useFonts'; -import { routes as codegenRoutes } from './routes'; +import { useFonts } from './src/hooks/useFonts'; +import { Playground } from './src/playground'; +import { routes as codegenRoutes } from './src/routes'; const linking = { prefixes: [Linking.createURL('/')], @@ -32,36 +33,26 @@ const linking = { CommonActions.reset({ index: 1, routes: [{ name: 'DebugExamples' }, ...state.routes] }), }; -// this code allows the use of toLocaleString() on Android if (Platform.OS === 'android') { require('intl'); require('intl/locale-data/jsonp/en-US'); } +const gestureHandlerStyle = { flex: 1 }; + const CdsSafeAreaProvider: React.FC> = memo(({ children }) => { const theme = useTheme(); const style = useMemo(() => ({ backgroundColor: theme.color.bg }), [theme.color.bg]); return {children}; }); -const LocalStrictMode = ({ children }: { children: React.ReactNode }) => { - const strict = process.env.CI !== 'true'; - return strict ? {children} : <>{children}; -}; - const App = memo(() => { - const [colorScheme, setColorScheme] = useState('light'); - const [fontsLoaded] = useFonts(); + const [colorScheme, setColorScheme] = useState('light'); - const handleOnReady = useCallback(async () => { + React.useEffect(() => { if (fontsLoaded) { - // This tells the splash screen to hide immediately! If we call this after - // `setAppIsReady`, then we may see a blank screen while the app is - // loading its initial state and rendering its first pixels. So instead, - // we hide the splash screen once we know the root view has already - // performed layout. - await SplashScreen.hideAsync(); + SplashScreen.hideAsync(); } }, [fontsLoaded]); @@ -70,20 +61,20 @@ const App = memo(() => { } return ( - + - + ); }); diff --git a/apps/test-expo/README.md b/apps/test-expo/README.md new file mode 100644 index 0000000000..d203b8d0c7 --- /dev/null +++ b/apps/test-expo/README.md @@ -0,0 +1,136 @@ +# test-expo + +Expo-based demo app for testing CDS mobile components. Used as the visual regression (visreg) target app for the CDS v9 branch. + +## Nx targets + +| Command | Description | +| ------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `yarn nx run test-expo:ios` | Build (if needed), install, launch, and start Metro — full dev loop for iOS (debug) | +| `yarn nx run test-expo:ios --configuration=release` | Install and launch the release build artifact (no Metro) | +| `yarn nx run test-expo:android` | Build (if needed), install, launch, and start Metro — full dev loop for Android (debug) | +| `yarn nx run test-expo:android --configuration=release` | Install and launch the release build artifact (no Metro) | +| `yarn nx run test-expo:start` | Start Metro bundler only (assumes app is already installed) | +| `yarn nx run test-expo:build --configuration=` | Compile the native app and archive to a tarball in `prebuilds/` | +| `yarn nx run test-expo:launch --configuration=` | Install + launch an existing build artifact on a simulator/emulator | +| `yarn nx run test-expo:patch-bundle-ios` | Swap the JS bundle inside the committed iOS Release prebuild — used by visreg CI | +| `yarn nx run test-expo:patch-bundle-android` | Swap the JS bundle inside the committed Android Release prebuild — used by visreg CI | +| `yarn nx run test-expo:validate` | Check Expo dependency versions for compatibility | +| `yarn nx run test-expo:lint` | Lint the app source | +| `yarn nx run test-expo:typecheck` | Type-check the app source | + +## Build configurations + +| Configuration | Platform | Profile | Target | Output | +| -------------------- | -------- | ------- | --------- | ------------------------------------------- | +| `ios-debug` | iOS | Debug | Simulator | `prebuilds/ios-debug/testexpo.tar.gz` | +| `ios-release` | iOS | Release | Simulator | `prebuilds/ios-release/testexpo.tar.gz` | +| `ios-debug-device` | iOS | Debug | Device | `prebuilds/ios-debug-device/testexpo.ipa` | +| `ios-release-device` | iOS | Release | Device | `prebuilds/ios-release-device/testexpo.ipa` | +| `android-debug` | Android | Debug | Emulator | `prebuilds/android-debug/testexpo.apk` | +| `android-release` | Android | Release | Emulator | `prebuilds/android-release/testexpo.apk` | + +## Prebuilds + +The `prebuilds/` directory contains pre-compiled native artifacts (tarballs) that are committed to the repo. This means CI and team members never need to run a full native build just to run visreg or launch the app for JS-only development. + +**Committed:** iOS tarballs (`.tar.gz`), Android release zips (`.zip`) +**Not committed:** Extracted `.app` directories (recreated at runtime from the tarball), Android debug APKs + +### Updating prebuilds + +Rebuild and commit a new tarball whenever native dependencies change (e.g. a new native module, an RN upgrade, or an Expo SDK bump): + +```bash +# iOS release (used by visreg CI) +yarn nx run test-expo:build --configuration=ios-release + +# iOS debug (used for local development) +yarn nx run test-expo:build --configuration=ios-debug + +# Then commit the updated tarballs +git add apps/test-expo/prebuilds/ +git commit -m "chore: update test-expo prebuilds" +``` + +### patch-bundle targets + +`patch-bundle-ios` and `patch-bundle-android` update the JS bundle inside an already-extracted prebuild without recompiling native code. This is what visreg CI runs instead of a full build: + +1. Extracts `prebuilds/ios-release/testexpo.tar.gz` → `prebuilds/ios-release/testexpo.app` +2. Runs `expo export` to produce a fresh JS bundle from the current branch +3. Replaces the JS bundle inside the `.app` + +The patched `.app` is then installed directly onto the simulator for screenshot capture. + +## Local development + +### iOS Simulator + +For first-time setup, see the [Expo iOS Simulator guide](https://docs.expo.dev/workflow/ios-simulator/). + +1. **Run the app**: + + ```bash + yarn nx run test-expo:ios + ``` + + This will: + - Build the app if no artifact exists at `prebuilds/ios-debug/testexpo.tar.gz` + - Boot the iOS Simulator if not already running + - Extract, install, and launch the app + - Start Metro bundler (debug only — release builds launch standalone) + +2. **Rebuild when native dependencies change**: + ```bash + rm -rf prebuilds/ios-debug + yarn nx run test-expo:ios + ``` + +### Android Emulator + +Android requires more manual steps due to expo-dev-client limitations. + +For first-time setup, see the [Expo Android Studio Emulator guide](https://docs.expo.dev/workflow/android-studio-emulator/). + +1. **Prerequisites**: + - Android Studio installed with an emulator configured + - `ANDROID_HOME` environment variable set + +2. **Run the app**: + + ```bash + yarn nx run test-expo:android + ``` + + This will: + - Build the APK if no artifact exists at `prebuilds/android-debug/testexpo.apk` + - Start the Android emulator if not already running + - Install and launch the app via adb + - Start Metro bundler + +3. **Troubleshooting**: + + If the app doesn't connect to Metro automatically: + - Press `r` in the Metro terminal to reload the app + - Or shake the device / press Cmd+M to open the dev menu and select "Reload" + + If Metro connection fails entirely: + + ```bash + adb reverse tcp:8081 tcp:8081 + ``` + + Then reload the app. + +4. **Rebuild when native dependencies change**: + ```bash + rm -rf prebuilds/android-debug + yarn nx run test-expo:android + ``` + +## Expo Go compatibility + +This app cannot run in Expo Go due to dependencies on native modules. Specifically, `@react-native-community/datetimepicker` (used by cds-mobile) contains native code not included in Expo Go. + +You must use the development build workflow described above. diff --git a/apps/test-expo/app.json b/apps/test-expo/app.json new file mode 100644 index 0000000000..be9e59cb0b --- /dev/null +++ b/apps/test-expo/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "test-expo", + "slug": "test-expo", + "version": "1.0.0", + "scheme": "testexpo", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.test-expo" + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "edgeToEdgeEnabled": true, + "package": "com.anonymous.testexpo" + }, + "web": { + "favicon": "./assets/favicon.png" + } + } +} diff --git a/apps/test-expo/assets/adaptive-icon.png b/apps/test-expo/assets/adaptive-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/test-expo/assets/adaptive-icon.png differ diff --git a/apps/test-expo/assets/favicon.png b/apps/test-expo/assets/favicon.png new file mode 100644 index 0000000000..e75f697b18 Binary files /dev/null and b/apps/test-expo/assets/favicon.png differ diff --git a/apps/test-expo/assets/icon.png b/apps/test-expo/assets/icon.png new file mode 100644 index 0000000000..a0b1526fc7 Binary files /dev/null and b/apps/test-expo/assets/icon.png differ diff --git a/apps/test-expo/assets/splash-icon.png b/apps/test-expo/assets/splash-icon.png new file mode 100644 index 0000000000..03d6f6b6c6 Binary files /dev/null and b/apps/test-expo/assets/splash-icon.png differ diff --git a/apps/test-expo/babel.config.js b/apps/test-expo/babel.config.js new file mode 100644 index 0000000000..8c5c851083 --- /dev/null +++ b/apps/test-expo/babel.config.js @@ -0,0 +1,10 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + plugins: [ + // IMPORTANT: react-native-worklets/plugin must be listed LAST + 'react-native-worklets/plugin', + ], + }; +}; diff --git a/apps/mobile-app/docs/building-mobile.md b/apps/test-expo/docs/building-mobile.md similarity index 52% rename from apps/mobile-app/docs/building-mobile.md rename to apps/test-expo/docs/building-mobile.md index 11d5215062..5f7cf1eeab 100644 --- a/apps/mobile-app/docs/building-mobile.md +++ b/apps/test-expo/docs/building-mobile.md @@ -25,14 +25,14 @@ It is a native module build of your application that is: ### When do you need to rebuild debug builds? - If you don't have any local debug build to develop off of -- If there's any dependency change in `apps/mobile-app/package.json` and `packages/mobile/package.json` +- If there's any dependency change in `apps/test-expo/package.json` and `packages/mobile/package.json` ### How do I rebuild a debug build? -| Platform | Profile - engine type | Command | -| -------- | --------------------- | -------------------------------------------- | -| ios | local - hermes | `yarn nx run mobile-app:build:ios-debug` | -| android | local -hermes | `yarn nx run mobile-app:build:android-debug` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | ------------------------------------------- | +| ios | local - hermes | `yarn nx run test-expo:build:ios-debug` | +| android | local -hermes | `yarn nx run test-expo:build:android-debug` | ## Release Builds @@ -46,8 +46,10 @@ It is a native module build of your application that is: ### When do you need to rebuild release builds? -- Any dependency change in `apps/mobile-app/package.json` and `packages/mobile/package.json` -- Any JS change in `packages/mobile/*`. +- Any native dependency change in `apps/test-expo/package.json` or `packages/mobile/package.json` +- Any change to native Expo config or build tooling + +> JS-only changes do not require a full rebuild — use `yarn nx run test-expo:patch-bundle-ios` / `patch-bundle-android` to swap the JS bundle into the existing prebuild instead. ### How do I rebuild a release build? @@ -56,30 +58,16 @@ Generate the new shared, native module builds for everyone to use. **Be sure to **Note: Committing these builds reduces CI time drastically by 14min for ios and 7 mins for android** ```shell -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run test-expo:build:ios-release +yarn nx run test-expo:build:android-release ``` ## Advanced ### Creating new build configurations -You can create other build types using [app.config.js](/apps/mobile-app/app.config.ts) and [project.json](/apps/mobile-app/project.json). - -Create a new config in [project.json](/apps/mobile-app/project.json) `targets.build.configurations`. The key will be come your new command for `yarn nx run mobile-app:build:`. +You can create other build types using [app.json](/apps/test-expo/app.json) and [project.json](/apps/test-expo/project.json). -Pass ENVs to configure your build. See [setEnvVars](/apps/mobile-app/scripts/utils/setEnvVars.mjs) for options and [project.json](/apps/mobile-app/project.json) for examples. +Create a new config in [project.json](/apps/test-expo/project.json) `targets.build.configurations`. The key will become your new command for `yarn nx run test-expo:build:`. [Here is the reference guide on app configurations from Expo](https://docs.expo.dev/versions/latest/config/app/). - -### Run on real device - -With [Expo Go](https://docs.expo.dev/get-started/expo-go/), you can easily run the app on a real physical device by following these steps: - -**NOTE:** For security reasons, please make sure your device has Coinbase Security Profile installed before proceeding. - -1. Download [Expo Go](https://expo.dev/client) to your device. -2. Run `yarn nx run mobile-app:go` to start the development server. This will output a QR code in your terminal. -3. Make sure your device and metro are connected to the same network. You might also need to disconnect VPN. -4. On your device, scan the QR code generated in step2. It will redirect you to Expo Go and install the debug app. -5. The app will now reload whenever you save changes in your code. diff --git a/apps/mobile-app/docs/help.md b/apps/test-expo/docs/help.md similarity index 64% rename from apps/mobile-app/docs/help.md rename to apps/test-expo/docs/help.md index d418151496..cd46859c1a 100644 --- a/apps/mobile-app/docs/help.md +++ b/apps/test-expo/docs/help.md @@ -3,28 +3,28 @@ ## Debugging Tools 1. How do I run `gradlew` locally for Android debugging? - Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `mobile-app`. + Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `test-expo`. To generate a local android directory to run `gradle`: -- `yarn workspace mobile-app expo prebuild --platform android` -- `cd apps/mobile-app/android` -- Run gradle command. You can always find the gradle commands executed for debug and release builds in [eas.json](/apps/mobile-app/eas.json). For debug, you can run `./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug`. For release, you can run `./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release` -- `yarn clean-expo` from root when you're done! Leaving the `android` directory will impact future builds. +- `yarn workspace test-expo expo prebuild --platform android` +- `cd apps/test-expo/android` +- Run gradle command. For debug, you can run `./gradlew :app:assembleDebug :app:assembleAndroidTest -DtestBuildType=debug`. For release, you can run `./gradlew :app:assembleRelease :app:assembleAndroidTest -DtestBuildType=release` +- `rm -rf apps/test-expo/android` from root when you're done! Leaving the `android` directory will impact future builds. 2. How do I run `pod` locally for iOS debugging? - Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `mobile-app`. + Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. This means we do not keep `/ios` or `/android` directories in our `test-expo`. To generate a local ios directory to run `pod`, you can run the prebuild command from expo. This will call `pod install` for you and show you a local failure: -- `yarn workspace mobile-app expo prebuild --platform ios` -- `yarn clean-expo` from root when you're done! Leaving the `ios` directory will impact future builds. +- `yarn workspace test-expo expo prebuild --platform ios` +- `rm -rf apps/test-expo/ios apps/test-expo/.expo` from root when you're done! Leaving the `ios` directory will impact future builds. 3. Access expo build output directly for logs & to debug build failures. -Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. In order to see the build output or logs generated from the `yarn nx run mobile-app:build:`, you need to skip expo cleanup. This can also be used for `launch` +Our expo builds are 'managed', and therefore are built in a temp directory outside of our repo that is cleaned at the end of an `eas` command. In order to see the build output or logs generated from the `yarn nx run test-expo:build:`, you need to skip expo cleanup. This can also be used for `launch` -- Go to our [build script](/apps/mobile-app/scripts/build.mjs) +- Go to our [build script](/apps/test-expo/scripts/build.mjs) - Prepend `export EAS_LOCAL_BUILD_SKIP_CLEANUP=1 && ` prior to `eas build`... - Run build like normal - At the end of the build, you'll see an output `Skipping cleanup, /var/folders/.... won't be removed.` @@ -36,21 +36,21 @@ Our expo builds are 'managed', and therefore are built in a temp directory outsi ## Common Errors -1. `yarn nx run mobile-app:build:ios-debug` is throwing a `Error: spawn pod ENOENT` error. +1. `yarn nx run test-expo:build:ios-debug` is throwing a `Error: spawn pod ENOENT` error. -- Run `yarn workspace mobile-app run expo prebuild -p ios --clean` -- `yarn clean-expo` -- `yarn nx run mobile-app:build:ios-debug` should work as expected +- Run `yarn workspace test-expo run expo prebuild -p ios --clean` +- `rm -rf apps/test-expo/ios apps/test-expo/.expo` +- `yarn nx run test-expo:build:ios-debug` should work as expected -2. `yarn nx run mobile-app:build:android-debug` is throwing this error `mobile-app/android directory not found` +2. `yarn nx run test-expo:build:android-debug` is throwing this error `test-expo/android directory not found` -- Run `mkdir apps/mobile-app/android` -- `yarn nx run mobile-app:build:android-debug` should work as expected -- Delete the `mobile-app/android` directory +- Run `mkdir apps/test-expo/android` +- `yarn nx run test-expo:build:android-debug` should work as expected +- Delete the `test-expo/android` directory 3. An error like "You are on eas-cli@3.7.2 which does not satisfy the CLI version constraint in eas.json (3.8.1)" -Look up the `cli.version` in `apps/mobile-app/eas.json`. +Look up the required version in `apps/test-expo/package.json`. ```shell npm -g install eas-cli@ @@ -73,12 +73,12 @@ This error can occur for a number of reasons. See debugging section above for ho This error can occur for a number of reasons. See debugging section above for how to run pod install locally. -7. No development build (com.ui-systems.debug-ios-hermes) for this project is installed. Please make and install development build on the device first. +7. No development build (com.anonymous.test-expo) for this project is installed. Please make and install development build on the device first. -This error occurs because mobile-app/ios directory was present at the time of launching the build onto a simulator. This interferes with expos naming of the app on the device. +This error occurs because test-expo/ios directory was present at the time of launching the build onto a simulator. This interferes with expo's naming of the app on the device. To resolve from root: -`yarn clean-expo` +`rm -rf apps/test-expo/ios apps/test-expo/.expo` 8. I'm seeing this build error in my CDS log file in the expo temp directory: @@ -88,8 +88,8 @@ To resolve from root: This error can be caused by two things: -- The packages are incorrectly built. All packages should be built in the preinstall `eas-build-pre-install` script found in the `mobile-app/package.json` file. If the package has an empty or missing `lib` in the expo temp directory, it didn't correctly build. -- Metro error. Package exports are processed from the metro.config.js for local development. You can verify through `yarn.lock`. +- The packages are incorrectly built. All packages should be built in the preinstall `eas-build-pre-install` script found in the `test-expo/package.json` file. If the package has an empty or missing `lib` in the expo temp directory, it didn't correctly build. +- Metro error. Package exports are resolved by Metro during local development. You can verify the resolution through `yarn.lock`. 9. I'm seeing lots of warnings about watchman recrawling. How can I remove these from my terminal output? @@ -130,16 +130,16 @@ which watchman - Press `shift + Command + .` at the same time to see all the directories list - Drill into the directory that `which watchman` printed and find all executables prefixed with `watchman` and add each one to have Full Disk Access list - even if `watchman*` processes were previously provided full-disk-access, make sure to re-add access after reinstalling watchman -- Run `yarn nx run mobile-app:start` multiple times to confirm you are no longer seeing the warning +- Run `yarn nx run test-expo:start` multiple times to confirm you are no longer seeing the warning -10. Expo is throwing this error while I'm running a build or launch command (`yarn nx run mobile-app::`): Props Authentication Token not found, Props token or EXPO login error. +10. Expo is throwing this error while I'm running a build or launch command (`yarn nx run test-expo::`): Props Authentication Token not found, Props token or EXPO login error. Approaches to resolve: - Run the following in the root directory ```shell -cd apps/mobile-app && eas build --local --non-interactive --json --clear-cache --platform ios --profile debug +cd apps/test-expo && eas build --local --non-interactive --json --clear-cache --platform ios --profile debug ``` 11. Cocoapods is not setup or the following error is thrown: Cocoapods is not available, make sure it's installed and in your PATH. diff --git a/apps/mobile-app/docs/prebuilds.md b/apps/test-expo/docs/prebuilds.md similarity index 54% rename from apps/mobile-app/docs/prebuilds.md rename to apps/test-expo/docs/prebuilds.md index 56b283e728..ee33c22724 100644 --- a/apps/mobile-app/docs/prebuilds.md +++ b/apps/test-expo/docs/prebuilds.md @@ -1,19 +1,14 @@ ## When to use prebuilds? -Use Expo Go for normal dev workflow and testing simple JS code. Only use prebuilds if you: - -- Made dependencies changes -- Want to test native modules that require prebuilds +`test-expo` does not support Expo Go — it requires native modules (e.g. `react-native-date-picker`) that cannot run in the Expo Go sandbox. All development uses prebuilds. Use debug prebuilds for local development with hot reloading, and release prebuilds for visreg. ## Setup 1. Setup your dependencies - fastlane, and eas-cli -Get the eas version in [eas.json](/apps/mobile-app/eas.json) at `cli.version`. - ```shell brew install fastlane -npm install -g eas-cli@ +npm install -g eas-cli ``` 2. Run `yarn install` from root @@ -24,38 +19,37 @@ npm install -g eas-cli@ 1. Build the application you wish to develop on with `build` -**Note: you should only need to build if you're missing your desired build in [prebuilds](/apps/mobile-app/prebuilds) or if there's been a recent native dependency upgrade.** +**Note: you should only need to build if you're missing your desired build in [prebuilds](/apps/test-expo/prebuilds) or if there's been a recent native dependency upgrade.** -See more info about mobile builds [here](/apps/mobile-app/docs/building-mobile.md). +See more info about mobile builds [here](/apps/test-expo/docs/building-mobile.md). -| Platform | Profile - engine type | Command | -| -------- | --------------------- | ---------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:build:ios-debug` | -| ios | release - hermes | `yarn nx run mobile-app:build:ios-release` | -| android | debug - hermes | `yarn nx run mobile-app:build:android-debug` | -| android | release - hermes | `yarn nx run mobile-app:build:android-release` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | --------------------------------------------- | +| ios | debug - hermes | `yarn nx run test-expo:build:ios-debug` | +| ios | release - hermes | `yarn nx run test-expo:build:ios-release` | +| android | debug - hermes | `yarn nx run test-expo:build:android-debug` | +| android | release - hermes | `yarn nx run test-expo:build:android-release` | -**Note: If you run into errors when trying to prebuild, check out our [Help](/apps/mobile-app/docs/help.md) page to debug.** +**Note: If you run into errors when trying to prebuild, check out our [Help](/apps/test-expo/docs/help.md) page to debug.** 2. Install app in your simulator with `launch` configuration. -**Note: You can skip this if you've already launched the build in your [prebuilds](/apps/mobile-app/prebuilds) in your simulator.** +**Note: You can skip this if you've already launched the build in your [prebuilds](/apps/test-expo/prebuilds) in your simulator.** -| Platform | Profile - engine type | Command | -| -------- | --------------------- | ----------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:launch:ios-debug` | -| ios | release - hermes | `yarn nx run mobile-app:launch:ios-release` | -| android | debug - hermes | `yarn nx run mobile-app:launch:android-debug` | -| android | release - hermes | `yarn nx run mobile-app:launch:android-release` | +| Platform | Profile - engine type | Command | +| -------- | --------------------- | ---------------------------------------------- | +| ios | debug - hermes | `yarn nx run test-expo:launch:ios-debug` | +| ios | release - hermes | `yarn nx run test-expo:launch:ios-release` | +| android | debug - hermes | `yarn nx run test-expo:launch:android-debug` | +| android | release - hermes | `yarn nx run test-expo:launch:android-release` | 3. Start the metro server for installed application. Only relevant for debug builds because release builds do not have hot reloading. -| Platform | Profile - engine type | Command | -| -------- | --------------------- | -------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:start:ios-debug` | -| android | debug - hermes | `yarn nx run mobile-app:start:android-debug` | +```shell +yarn nx run test-expo:start +``` -**Note: If you see `CommandError: No development build (com.ui-systems.debug-ios-hermes) for this project is installed. Please make and install a development build on the device first.` run `yarn clean-expo` and rerun the `start` script. See more debug gotchas [here](/apps/mobile-app/docs/help.md)** +**Note: If you see `CommandError: No development build (com.anonymous.test-expo) for this project is installed. Please make and install a development build on the device first.` run `rm -rf apps/test-expo/ios apps/test-expo/.expo` and rerun the `start` script. See more debug gotchas [here](/apps/test-expo/docs/help.md)** When running the debug app after a rebuild or restart, you'll most likely need to close out the Debug app and reopen it to trigger the bundler to recompile. @@ -72,21 +66,21 @@ See the [mobile-visreg README](/packages/mobile-visreg/README.md) for full setup ## An overview of Expo NX Targets -There are three core NX targets associated with Expo that we leverage to build and run mobile-app. The various contexts can be summarized as debug and release modes for development, with release builds also serving as the basis for visual regression testing. +There are three core NX targets associated with Expo that we leverage to build and run test-expo. The various contexts can be summarized as debug and release modes for development, with release builds also serving as the basis for visual regression testing. -The three NX Targets (also declared in `/apps/mobile-app/project.json`): +The three NX Targets (also declared in `/apps/test-expo/project.json`): 1. launch 2. start 3. build -These targets call node scripts that live in the [scripts directory of mobile-app](/apps/mobile-app/scripts/). These scripts are intuitively named the same as their respective nx targets. +`launch` and `build` call node scripts that live in the [scripts directory of test-expo](/apps/test-expo/scripts/). `start` runs `npx expo start` directly. Visual regression testing is handled separately by the [`packages/mobile-visreg`](/packages/mobile-visreg/README.md) package using Maestro and BrowserStack App Percy. ## Expo Debug vs Release Builds -There are four relevant build variations associated with mobile-app: +There are four relevant build variations associated with test-expo: Release builds: @@ -105,7 +99,7 @@ There are two key ideas to understand about these build variations: ## The difference between a release and a debug build -The key difference between release and debug builds is how the javascript is bundled with the native portion of mobile-app. In release builds a fully optimized version of the javascript bundle is packaged into the iOS ipa or Android apk and is referenced by the native app entry point. In a debug build the javascript bundle is not bundled into the app artifact, instead it is kept external to the shippable native portion and the native entry point references a bundle managed by the metro bundler (the metro bundler is what runs in your terminal when you run the start target). This difference is key to understanding why hot-reloading works in debug builds but not in release builds. It is also important to note here that debug is clearly a very different environment compared to release, which is why our visreg tests must be run in the context of release build as opposed to debug. +The key difference between release and debug builds is how the javascript is bundled with the native portion of test-expo. In release builds a fully optimized version of the javascript bundle is packaged into the iOS ipa or Android apk and is referenced by the native app entry point. In a debug build the javascript bundle is not bundled into the app artifact, instead it is kept external to the shippable native portion and the native entry point references a bundle managed by the metro bundler (the metro bundler is what runs in your terminal when you run the start target). This difference is key to understanding why hot-reloading works in debug builds but not in release builds. It is also important to note here that debug is clearly a very different environment compared to release, which is why our visreg tests must be run in the context of release build as opposed to debug. ## Why visreg uses release builds @@ -129,8 +123,8 @@ A key performance optimization keeps the committed prebuilds (native `.ipa` / `. Instead, CI uses a patch step: ```bash -yarn nx run mobile-app:patch-bundle-ios # iOS -yarn nx run mobile-app:patch-bundle-android # Android +yarn nx run test-expo:patch-bundle-ios # iOS +yarn nx run test-expo:patch-bundle-android # Android ``` These scripts uncompress the committed release artifact, swap in the freshly bundled JS, and re-compress it into a valid platform artifact. This makes CI visreg runs fast while keeping the native prebuilds in sync with the JS codebase. @@ -139,6 +133,6 @@ These scripts uncompress the committed release artifact, swap in the freshly bun Any time native dependencies, native Expo configs, or relevant build tooling changes. When this happens, regenerate and commit the updated prebuilds: ```bash -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run test-expo:build:ios-release +yarn nx run test-expo:build:android-release ``` diff --git a/apps/mobile-app/docs/upgrade-rn.md b/apps/test-expo/docs/upgrade-rn.md similarity index 57% rename from apps/mobile-app/docs/upgrade-rn.md rename to apps/test-expo/docs/upgrade-rn.md index 01e871fcde..a2b9ed582b 100644 --- a/apps/mobile-app/docs/upgrade-rn.md +++ b/apps/test-expo/docs/upgrade-rn.md @@ -5,42 +5,42 @@ Expo handles react native upgrades through their [SDK](https://docs.expo.dev/wor 1. Update to the new SDK version from root. You can check the [latest patch version on npm](https://www.npmjs.com/package/expo): ```shell -yarn workspace mobile-app add expo@^ +yarn workspace test-expo add expo@^ ``` 2. Fixes native and expo dependencies to match recommended versions. You can override versions in package.json after running the fix command. ```shell -cd apps/mobile-app && npx expo install --fix +cd apps/test-expo && npx expo install --fix ``` 3. Upgrade all native dependencies within our repo (cds-mobile, etc) to match the versions provided by expo. -**This is super important because that native versions must match for the mobile-app build to be successful** +**This is super important because that native versions must match for the test-expo build to be successful** -4. Nuke your repo. Cached versions will be compiled in the expo build step and lead to version mismatches. .nx, apps/mobile-app/expo, apps/mobile-app/ios, apps/mobile-app/android should all be removed. Node Modules should be removed because of version mismatches as well. Start fresh :) +4. Nuke your repo. Cached versions will be compiled in the expo build step and lead to version mismatches. `.nx`, `apps/test-expo/.expo`, `apps/test-expo/ios`, `apps/test-expo/android` should all be removed. Node modules should be removed because of version mismatches as well. Start fresh :) ```shell -cd ../../ && yarn clean-expo && yarn clean && rm -rf node_modules +cd ../../ && rm -rf apps/test-expo/ios apps/test-expo/android apps/test-expo/.expo && yarn clean && rm -rf node_modules ``` -6. Reboot with `yarn` +5. Reboot with `yarn` -7. Resolve any errors generated from dependency bumps or the react native upgrade. +6. Resolve any errors generated from dependency bumps or the react native upgrade. ```shell yarn nx run mobile:build ``` -8. Test debug builds and generate the new shared, native module builds for everyone to use. Be sure to commit ios debug, ios release, and android release builds to your PR. +7. Test debug builds and generate the new shared, native module builds for everyone to use. Be sure to commit ios debug, ios release, and android release builds to your PR. ```shell -yarn nx run mobile-app:build:ios-debug -yarn nx run mobile-app:build:android-debug -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run test-expo:build:ios-debug +yarn nx run test-expo:build:android-debug +yarn nx run test-expo:build:ios-release +yarn nx run test-expo:build:android-release ``` ## Having trouble? -[See our help docs](/apps/mobile-app/docs/help.md) +[See our help docs](/apps/test-expo/docs/help.md) diff --git a/apps/mobile-app/docs/upgrading-mobile-dep.md b/apps/test-expo/docs/upgrading-mobile-dep.md similarity index 82% rename from apps/mobile-app/docs/upgrading-mobile-dep.md rename to apps/test-expo/docs/upgrading-mobile-dep.md index 8143c17799..c72fdf7fdf 100644 --- a/apps/mobile-app/docs/upgrading-mobile-dep.md +++ b/apps/test-expo/docs/upgrading-mobile-dep.md @@ -2,14 +2,14 @@ Expo handles react native upgrades through their [SDK](https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/). Their SDK will handle updating native modules, as well as recommend native package versions that are compatible with the new react native version. -Check out this doc [for more about mobile builds in general](/apps/mobile-app/docs/building-mobile.md) +Check out this doc [for more about mobile builds in general](/apps/test-expo/docs/building-mobile.md) **We can stray from their recommendations, but with caution.** 1. Update to the new package in all relevant packages. ```shell -yarn workspace mobile-app add @ +yarn workspace test-expo add @ yarn workspace @coinbase/cds-mobile add @ yarn ``` @@ -20,12 +20,12 @@ yarn yarn nx run mobile:test ``` -3. Test that your applications work locally as expected. You will need to build a new debug build & likely uninstall the previous application and reinstall your new build, following [setup instructions](/apps/mobile-app/README.md). +3. Test that your applications work locally as expected. You will need to build a new debug build & likely uninstall the previous application and reinstall your new build, following [setup instructions](/apps/test-expo/README.md). 4. Generate the new shared, native module builds for everyone to use. Be sure to commit the release builds. Visreg (via `packages/mobile-visreg`) uses the release builds to capture and compare screenshots. The android-debug build is too large to be committed locally, but should be tested. ```shell -yarn nx run mobile-app:build:ios-debug -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:build:android-release +yarn nx run test-expo:build:ios-debug +yarn nx run test-expo:build:ios-release +yarn nx run test-expo:build:android-release ``` diff --git a/apps/test-expo/index.js b/apps/test-expo/index.js new file mode 100644 index 0000000000..131fa8562d --- /dev/null +++ b/apps/test-expo/index.js @@ -0,0 +1,10 @@ +import './polyfills/intl'; + +import { registerRootComponent } from 'expo'; + +import App from './App'; + +// registerRootComponent calls AppRegistry.registerComponent('main', () => App); +// It also ensures that whether you load the app in Expo Go or in a native build, +// the environment is set up appropriately +registerRootComponent(App); diff --git a/apps/test-expo/package.json b/apps/test-expo/package.json new file mode 100644 index 0000000000..bfab91a0aa --- /dev/null +++ b/apps/test-expo/package.json @@ -0,0 +1,42 @@ +{ + "name": "test-expo", + "version": "1.0.0", + "main": "index.js", + "dependencies": { + "@coinbase/cds-icons": "workspace:^", + "@coinbase/cds-mobile": "workspace:^", + "@expo-google-fonts/inter": "^0.3.0", + "@expo-google-fonts/source-code-pro": "^0.3.0", + "@formatjs/intl-getcanonicallocales": "^2.5.5", + "@formatjs/intl-locale": "^4.2.11", + "@formatjs/intl-numberformat": "^8.15.4", + "@formatjs/intl-pluralrules": "^5.4.4", + "@react-navigation/native": "6.1.17", + "@react-navigation/native-stack": "6.9.26", + "@shopify/react-native-skia": "2.2.12", + "expo": "54.0.32", + "expo-dev-client": "6.0.20", + "expo-font": "14.0.11", + "expo-linking": "~8.0.11", + "expo-splash-screen": "31.0.13", + "expo-status-bar": "3.0.9", + "intl": "^1.2.5", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-date-picker": "5.0.12", + "react-native-gesture-handler": "2.28.0", + "react-native-inappbrowser-reborn": "3.7.0", + "react-native-navigation-bar-color": "2.0.2", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-screens": "4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" + }, + "private": true, + "scripts": { + "android": "expo run:android", + "ios": "expo run:ios" + } +} diff --git a/apps/mobile-app/src/polyfills/intl.ts b/apps/test-expo/polyfills/intl.ts similarity index 100% rename from apps/mobile-app/src/polyfills/intl.ts rename to apps/test-expo/polyfills/intl.ts diff --git a/apps/mobile-app/prebuilds/android-release-hermes.zip b/apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz similarity index 70% rename from apps/mobile-app/prebuilds/android-release-hermes.zip rename to apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz index 84eee2c3fb..f2ee0339c6 100644 Binary files a/apps/mobile-app/prebuilds/android-release-hermes.zip and b/apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz differ diff --git a/apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz b/apps/test-expo/prebuilds/ios-release/testexpo.tar.gz similarity index 69% rename from apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz rename to apps/test-expo/prebuilds/ios-release/testexpo.tar.gz index 5ca70c1bd8..6ae611f077 100644 Binary files a/apps/mobile-app/prebuilds/ios-debug-hermes.tar.gz and b/apps/test-expo/prebuilds/ios-release/testexpo.tar.gz differ diff --git a/apps/test-expo/project.json b/apps/test-expo/project.json new file mode 100644 index 0000000000..f0f9aa24d6 --- /dev/null +++ b/apps/test-expo/project.json @@ -0,0 +1,117 @@ +{ + "name": "test-expo", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "application", + "sourceRoot": "apps/test-expo", + "tags": [], + "targets": { + "start": { + "command": "npx expo start", + "options": { + "cwd": "apps/test-expo" + } + }, + "ios": { + "executor": "nx:run-commands", + "defaultConfiguration": "debug", + "configurations": { + "debug": { + "cwd": "apps/test-expo", + "command": "node ./scripts/run.mjs --platform ios --profile debug" + }, + "release": { + "cwd": "apps/test-expo", + "command": "node ./scripts/run.mjs --platform ios --profile release" + } + } + }, + "android": { + "executor": "nx:run-commands", + "defaultConfiguration": "debug", + "configurations": { + "debug": { + "cwd": "apps/test-expo", + "command": "node ./scripts/run.mjs --platform android --profile debug" + }, + "release": { + "cwd": "apps/test-expo", + "command": "node ./scripts/run.mjs --platform android --profile release" + } + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "typecheck": { + "command": "tsc --build --pretty --verbose" + }, + "validate": { + "command": "npx expo install --check", + "options": { + "cwd": "apps/test-expo" + } + }, + "launch": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/test-expo", + "command": "node ./scripts/launch.mjs --platform {args.platform} --profile {args.profile}" + }, + "defaultConfiguration": "ios-debug", + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug" + }, + "ios-release": { + "args": "--platform ios --profile release" + }, + "android-debug": { + "args": "--platform android --profile debug" + }, + "android-release": { + "args": "--platform android --profile release" + } + } + }, + "patch-bundle-ios": { + "command": "node ./scripts/patch-bundle.mjs --platform ios --profile release", + "options": { + "cwd": "apps/test-expo" + } + }, + "patch-bundle-android": { + "command": "node ./scripts/patch-bundle.mjs --platform android --profile release", + "options": { + "cwd": "apps/test-expo" + } + }, + "build": { + "executor": "nx:run-commands", + "options": { + "cwd": "apps/test-expo", + "command": "node ./scripts/build.mjs --platform {args.platform} --profile {args.profile} --target {args.target}" + }, + "defaultConfiguration": "ios-debug", + "configurations": { + "ios-debug": { + "args": "--platform ios --profile debug --target simulator" + }, + "ios-release": { + "args": "--platform ios --profile release --target simulator" + }, + "ios-debug-device": { + "args": "--platform ios --profile debug --target device" + }, + "ios-release-device": { + "args": "--platform ios --profile release --target device" + }, + "android-debug": { + "args": "--platform android --profile debug --target simulator" + }, + "android-release": { + "args": "--platform android --profile release --target simulator" + } + } + } + } +} diff --git a/apps/test-expo/scripts/build.mjs b/apps/test-expo/scripts/build.mjs new file mode 100644 index 0000000000..f01219aeae --- /dev/null +++ b/apps/test-expo/scripts/build.mjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + target: { type: 'string', default: 'simulator' }, + }, +}); + +const { platform, profile, target } = values; + +if (!platform) { + console.error( + 'Usage: node build.mjs --platform [--profile ] [--target ]', + ); + process.exit(1); +} + +if (target !== 'simulator' && target !== 'device') { + console.error('Error: --target must be "simulator" or "device"'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target }); +const builder = createBuilder(buildInfo); + +await builder.build(); + +console.log(`\nBuild artifacts are in: ${buildInfo.outputPath}/`); +process.exit(0); diff --git a/apps/test-expo/scripts/launch.mjs b/apps/test-expo/scripts/launch.mjs new file mode 100644 index 0000000000..717a872168 --- /dev/null +++ b/apps/test-expo/scripts/launch.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node launch.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +// Check that build artifact exists +if (!(await builder.hasBuildArtifact())) { + const config = `${platform}-${profile}`; + console.error(`Error: Build artifact not found.`); + console.error(`Run: yarn nx run test-expo:build --configuration=${config}`); + process.exit(1); +} + +// Install and launch +await builder.install(); +await builder.launch(); + +console.log('\nApp launched! Run "yarn nx run test-expo:start" to connect Metro.'); diff --git a/apps/test-expo/scripts/patch-bundle.mjs b/apps/test-expo/scripts/patch-bundle.mjs new file mode 100644 index 0000000000..3a0ec3a58b --- /dev/null +++ b/apps/test-expo/scripts/patch-bundle.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Patches a fresh JS bundle into a pre-built native artifact (iOS .app / Android APK). + * This avoids a full native rebuild in CI — only the JS layer is updated. + * + * Usage: + * node scripts/patch-bundle.mjs --platform ios [--profile release] + * node scripts/patch-bundle.mjs --platform android [--profile release] + * + * Prerequisites: a build artifact must already exist at builds/{platform}-{profile}/. + * Build one with: yarn nx run test-expo:build --configuration={platform}-{profile} + */ +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'release' }, + }, +}); + +const { platform, profile } = values; + +if (!platform || !['ios', 'android'].includes(platform)) { + console.error( + 'Usage: node patch-bundle.mjs --platform [--profile ]', + ); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +await builder.patchBundle(); diff --git a/apps/test-expo/scripts/run.mjs b/apps/test-expo/scripts/run.mjs new file mode 100644 index 0000000000..34a3a4f4fc --- /dev/null +++ b/apps/test-expo/scripts/run.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * Smart run script that uses pre-built artifacts if available, + * otherwise falls back to building from source. + */ +import { parseArgs } from 'node:util'; + +import { createBuilder } from './utils/createBuilder.mjs'; +import { getBuildInfo } from './utils/getBuildInfo.mjs'; + +const { values } = parseArgs({ + options: { + platform: { type: 'string' }, + profile: { type: 'string', default: 'debug' }, + }, +}); + +const { platform, profile } = values; + +if (!platform) { + console.error('Usage: node run.mjs --platform [--profile ]'); + process.exit(1); +} + +const buildInfo = getBuildInfo({ platform, profile, target: 'simulator' }); +const builder = createBuilder(buildInfo); + +await builder.buildIfNeeded(); + +console.log(`Launching ${platform}...`); +await builder.ensureSimulatorRunning(); +await builder.install(); +await builder.launch(); + +if (profile === 'debug') { + await builder.startMetro(); +} diff --git a/apps/test-expo/scripts/utils/AndroidBuilder.mjs b/apps/test-expo/scripts/utils/AndroidBuilder.mjs new file mode 100644 index 0000000000..cac31d5706 --- /dev/null +++ b/apps/test-expo/scripts/utils/AndroidBuilder.mjs @@ -0,0 +1,155 @@ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class AndroidBuilder extends PlatformBuilder { + get android() { + return this.buildInfo.android; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.android.apk); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const isDebug = this.buildInfo.profile === 'debug'; + const buildType = isDebug ? 'Debug' : 'Release'; + const buildTypeLC = buildType.toLowerCase(); + + console.log(`Building Android app (${buildType})...`); + + await fs.mkdir(outputPath, { recursive: true }); + + const gradleTask = isDebug ? 'assembleDebug' : 'assembleRelease'; + await run('./gradlew', [`:app:${gradleTask}`, '--no-daemon'], { + cwd: this.android.projectPath, + }); + + // Copy the built APK to output directory + const builtApkDir = path.join( + this.android.projectPath, + 'app', + 'build', + 'outputs', + 'apk', + buildTypeLC, + ); + const builtApkPath = path.join(builtApkDir, `app-${buildTypeLC}.apk`); + + try { + await fs.access(builtApkPath); + await fs.copyFile(builtApkPath, this.android.apk); + console.log(`Android APK created: ${this.android.apk}`); + } catch { + throw new Error(`APK not found at ${builtApkPath}`); + } + } + + // ───────────────────────────────────────────────────────────────── + // Emulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('adb', ['devices']); + const lines = output.split('\n').slice(1); // Skip header + return lines.some((line) => line.trim() && line.includes('\tdevice')); + } + + async bootSimulator() { + console.log('No Android emulator running, starting one...'); + + const avdList = await runCapture('emulator', ['-list-avds']); + const avds = avdList.trim().split('\n').filter(Boolean); + + if (avds.length === 0) { + throw new Error('No Android Virtual Devices found. Create one in Android Studio first.'); + } + + const avd = avds[0]; + console.log(`Starting emulator: ${avd}`); + + // Start emulator in background (detached) + spawn('emulator', ['-avd', avd], { + detached: true, + stdio: 'ignore', + }).unref(); + + console.log('Waiting for emulator to boot...'); + await run('adb', ['wait-for-device']); + } + + async waitForSimulator() { + const maxAttempts = 60; + for (let i = 0; i < maxAttempts; i++) { + try { + const result = await runCapture('adb', ['shell', 'getprop', 'sys.boot_completed']); + if (result.trim() === '1') return; + } catch { + // Device not ready yet + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error('Emulator failed to boot within timeout'); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + // Android APKs don't need extraction + } + + async install() { + console.log('Installing on Android Emulator...'); + await run('adb', ['install', '-r', this.android.apk]); + } + + async launch() { + console.log(`Launching ${this.android.packageId}...`); + await run('adb', ['shell', 'am', 'start', '-n', `${this.android.packageId}/.MainActivity`]); + } + + async applyBundle(bundlePath) { + const apk = path.resolve(this.android.apk); + const patchDir = `/tmp/apk-patch-${Date.now()}`; + const assetsDir = `${patchDir}/assets`; + const patchedBundle = `${assetsDir}/index.android.bundle`; + const alignedApk = `${apk}.aligned`; + + await fs.mkdir(assetsDir, { recursive: true }); + await fs.copyFile(bundlePath, patchedBundle); + + // Replace assets/index.android.bundle in the APK (cd into patchDir so zip path is correct) + console.log(`\nPatching bundle into APK: ${apk}...`); + execSync(`zip -u ${apk} assets/index.android.bundle`, { cwd: patchDir, stdio: 'inherit' }); + + // Re-align (zip modification breaks alignment) then re-sign with debug keystore + execSync(`zipalign -f 4 ${apk} ${alignedApk}`, { stdio: 'inherit' }); + await fs.rename(alignedApk, apk); + + const debugKeystore = path.resolve(process.env.HOME, '.android/debug.keystore'); + execSync( + `apksigner sign --ks ${debugKeystore} --ks-pass pass:android --key-pass pass:android ${apk}`, + { stdio: 'inherit' }, + ); + + await fs.rm(patchDir, { recursive: true }); + + console.log('Android bundle patched successfully.'); + console.log(`APK ready at: ${apk}`); + } +} diff --git a/apps/test-expo/scripts/utils/IOSBuilder.mjs b/apps/test-expo/scripts/utils/IOSBuilder.mjs new file mode 100644 index 0000000000..5268cf584a --- /dev/null +++ b/apps/test-expo/scripts/utils/IOSBuilder.mjs @@ -0,0 +1,196 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { PlatformBuilder } from './PlatformBuilder.mjs'; +import { run, runCapture } from './shell.mjs'; + +export class IOSBuilder extends PlatformBuilder { + get ios() { + return this.buildInfo.ios; + } + + // ───────────────────────────────────────────────────────────────── + // Build artifact management + // ───────────────────────────────────────────────────────────────── + + async hasBuildArtifact() { + try { + await fs.access(this.ios.appTarball); + return true; + } catch { + return false; + } + } + + async compile() { + const { outputPath } = this.buildInfo; + const configuration = this.buildInfo.profile === 'debug' ? 'Debug' : 'Release'; + + if (this.ios.isDevice) { + await this.#compileForDevice(configuration); + } else { + await this.#compileForSimulator(configuration, outputPath); + } + } + + async #compileForSimulator(configuration, outputPath) { + const buildDir = path.resolve('build'); + + console.log(`Building iOS app (${configuration}) for simulator...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-derivedDataPath', + buildDir, + 'build', + ]); + + // Find the built .app and create tarball + const configFolder = `${configuration}-iphonesimulator`; + const appPath = path.join( + buildDir, + 'Build', + 'Products', + configFolder, + `${this.ios.scheme}.app`, + ); + const appDir = path.dirname(appPath); + const appName = path.basename(appPath); + + console.log(`Creating tarball: ${this.ios.appTarball}`); + await run('tar', ['-czf', path.resolve(this.ios.appTarball), '-C', appDir, appName]); + + // Clean up + await fs.rm(buildDir, { recursive: true, force: true }); + console.log(`iOS simulator build created: ${this.ios.appTarball}`); + } + + async #compileForDevice(configuration) { + const { outputPath } = this.buildInfo; + + console.log(`Archiving iOS app (${configuration}) for device...`); + await run('xcodebuild', [ + '-workspace', + this.ios.workspace, + '-scheme', + this.ios.scheme, + '-configuration', + configuration, + '-destination', + this.ios.destination, + '-archivePath', + this.ios.archivePath, + 'archive', + 'CODE_SIGN_IDENTITY=-', + 'AD_HOC_CODE_SIGNING_ALLOWED=YES', + ]); + + console.log('Exporting IPA...'); + await run('xcodebuild', [ + '-exportArchive', + '-archivePath', + this.ios.archivePath, + '-exportPath', + outputPath, + '-exportOptionsPlist', + this.ios.exportOptionsPlist, + '-allowProvisioningUpdates', + ]); + + // Rename if needed + const exportedIpa = path.join(outputPath, `${this.ios.scheme}.ipa`); + try { + await fs.access(exportedIpa); + await fs.rename(exportedIpa, this.ios.ipa); + } catch { + // Already named correctly + } + + // Clean up archive + await fs.rm(this.ios.archivePath, { recursive: true, force: true }); + console.log(`iOS device build created: ${this.ios.ipa}`); + } + + // ───────────────────────────────────────────────────────────────── + // Simulator management + // ───────────────────────────────────────────────────────────────── + + async isSimulatorRunning() { + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'booted', '-j']); + const json = JSON.parse(output); + const bootedDevices = Object.values(json.devices).flat(); + return bootedDevices.length > 0; + } + + async bootSimulator() { + console.log('No iOS Simulator running, booting one...'); + + // Find an available iPhone + const output = await runCapture('xcrun', ['simctl', 'list', 'devices', 'available', '-j']); + const json = JSON.parse(output); + + let deviceUDID = null; + let deviceName = null; + + for (const [runtime, devices] of Object.entries(json.devices)) { + if (runtime.includes('iOS')) { + const iphone = devices.find((d) => d.name.includes('iPhone') && d.isAvailable); + if (iphone) { + deviceUDID = iphone.udid; + deviceName = iphone.name; + break; + } + } + } + + if (!deviceUDID) { + throw new Error('No available iPhone simulator found.'); + } + + console.log(`Booting ${deviceName}...`); + await run('xcrun', ['simctl', 'boot', deviceUDID]); + await run('open', ['-a', 'Simulator']); + } + + async waitForSimulator() { + await run('xcrun', ['simctl', 'bootstatus', 'booted', '-b']); + } + + // ───────────────────────────────────────────────────────────────── + // App installation and launch + // ───────────────────────────────────────────────────────────────── + + async extractArtifact() { + try { + await fs.access(this.ios.app); + } catch { + console.log(`Extracting ${this.ios.appTarball}...`); + await run('tar', ['-xzf', this.ios.appTarball, '-C', this.buildInfo.outputPath]); + } + } + + async install() { + await this.extractArtifact(); + console.log('Installing on iOS Simulator...'); + await run('xcrun', ['simctl', 'install', 'booted', this.ios.app]); + } + + async launch() { + console.log(`Launching ${this.ios.bundleId}...`); + await run('xcrun', ['simctl', 'launch', 'booted', this.ios.bundleId]); + } + + async applyBundle(bundlePath) { + const outBundle = `${this.ios.app}/main.jsbundle`; + console.log(`\nCopying bundle → ${outBundle}...`); + await fs.copyFile(bundlePath, outBundle); + console.log('iOS bundle patched successfully.'); + console.log(`App ready at: ${this.ios.app}`); + } +} diff --git a/apps/test-expo/scripts/utils/PlatformBuilder.mjs b/apps/test-expo/scripts/utils/PlatformBuilder.mjs new file mode 100644 index 0000000000..cdd2e180b7 --- /dev/null +++ b/apps/test-expo/scripts/utils/PlatformBuilder.mjs @@ -0,0 +1,134 @@ +import { execSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; + +import { run } from './shell.mjs'; + +/** + * Abstract base class for platform-specific build operations. + * iOS and Android implement the abstract methods differently. + */ +export class PlatformBuilder { + constructor(buildInfo) { + this.buildInfo = buildInfo; + } + + // ───────────────────────────────────────────────────────────────── + // Abstract methods - must be implemented by subclasses + // ───────────────────────────────────────────────────────────────── + + /** Check if the build artifact exists */ + async hasBuildArtifact() { + throw new Error('Not implemented'); + } + + /** Compile the native app (xcodebuild / gradle) */ + async compile() { + throw new Error('Not implemented'); + } + + /** Check if a simulator/emulator is currently running */ + async isSimulatorRunning() { + throw new Error('Not implemented'); + } + + /** Boot a simulator/emulator */ + async bootSimulator() { + throw new Error('Not implemented'); + } + + /** Wait for the simulator/emulator to be fully ready */ + async waitForSimulator() { + throw new Error('Not implemented'); + } + + /** Extract build artifact if needed (e.g., untar .tar.gz) */ + async extractArtifact() { + throw new Error('Not implemented'); + } + + /** Install the app on the simulator/emulator */ + async install() { + throw new Error('Not implemented'); + } + + /** Launch the app */ + async launch() { + throw new Error('Not implemented'); + } + + /** Apply a bundle file to the native artifact — platform-specific */ + async applyBundle(_bundlePath) { + throw new Error('Not implemented'); + } + + // ───────────────────────────────────────────────────────────────── + // Shared methods - common to both platforms + // ───────────────────────────────────────────────────────────────── + + /** Run expo prebuild to generate native project files */ + async prebuild() { + const { platform } = this.buildInfo; + console.log(`Running prebuild for ${platform}...`); + await run('npx', ['expo', 'prebuild', '--platform', platform, '--clean']); + } + + /** Full build: prebuild + compile */ + async build() { + const { platform, profile, outputPath } = this.buildInfo; + console.log(`Building ${platform} (${profile})...`); + + await fs.mkdir(outputPath, { recursive: true }); + await this.prebuild(); + await this.compile(); + } + + /** Build only if artifact doesn't exist */ + async buildIfNeeded() { + if (!(await this.hasBuildArtifact())) { + console.log('No build artifact found, building...'); + await this.build(); + } + } + + /** Ensure simulator is running, boot if needed */ + async ensureSimulatorRunning() { + if (!(await this.isSimulatorRunning())) { + await this.bootSimulator(); + } + await this.waitForSimulator(); + } + + /** + * Patches a fresh JS bundle into the pre-built native artifact. + * Runs expo export, finds the output bundle, then delegates platform-specific + * placement to applyBundle(). + */ + async patchBundle() { + const { platform } = this.buildInfo; + const exportDir = `/tmp/expo-export-${platform}`; + + await this.extractArtifact(); + + console.log(`\nExporting JS bundle to ${exportDir}...`); + execSync(`npx expo export --platform ${platform} --output-dir ${exportDir}`, { + stdio: 'inherit', + }); + + const jsDir = `${exportDir}/_expo/static/js/${platform}`; + const bundleFiles = readdirSync(jsDir).filter((f) => f.startsWith('index-')); + if (bundleFiles.length === 0) { + throw new Error(`No bundle found in ${jsDir}`); + } + const bundlePath = `${jsDir}/${bundleFiles[0]}`; + console.log(`\nFound bundle: ${bundlePath}`); + + await this.applyBundle(bundlePath); + } + + /** Start Metro bundler */ + async startMetro() { + console.log('\nStarting Metro bundler...'); + await run('npx', ['expo', 'start'], { interactive: true }); + } +} diff --git a/apps/test-expo/scripts/utils/createBuilder.mjs b/apps/test-expo/scripts/utils/createBuilder.mjs new file mode 100644 index 0000000000..e090a8f9f9 --- /dev/null +++ b/apps/test-expo/scripts/utils/createBuilder.mjs @@ -0,0 +1,12 @@ +import { AndroidBuilder } from './AndroidBuilder.mjs'; +import { IOSBuilder } from './IOSBuilder.mjs'; + +/** + * Factory function to create the appropriate platform builder. + */ +export function createBuilder(buildInfo) { + if (buildInfo.platform === 'ios') { + return new IOSBuilder(buildInfo); + } + return new AndroidBuilder(buildInfo); +} diff --git a/apps/test-expo/scripts/utils/exportOptions.plist b/apps/test-expo/scripts/utils/exportOptions.plist new file mode 100644 index 0000000000..d5b9e3ee1d --- /dev/null +++ b/apps/test-expo/scripts/utils/exportOptions.plist @@ -0,0 +1,14 @@ + + + + + method + development + compileBitcode + + thinning + <none> + signingStyle + automatic + + diff --git a/apps/test-expo/scripts/utils/getBuildInfo.mjs b/apps/test-expo/scripts/utils/getBuildInfo.mjs new file mode 100644 index 0000000000..30e1518500 --- /dev/null +++ b/apps/test-expo/scripts/utils/getBuildInfo.mjs @@ -0,0 +1,45 @@ +import path from 'node:path'; + +const OUTPUT_DIRECTORY = 'prebuilds'; +const APP_NAME = 'testexpo'; +const IOS_SCHEME = 'testexpo'; +const IOS_BUNDLE_ID = 'com.anonymous.test-expo'; +const ANDROID_PACKAGE_ID = 'com.anonymous.testexpo'; + +export function getBuildInfo({ platform, profile, target = 'simulator' }) { + const isDevice = target === 'device'; + // Default builds are for simulator/emulator, device builds get -device suffix + const buildId = isDevice ? `${platform}-${profile}-device` : `${platform}-${profile}`; + const outputPath = `${OUTPUT_DIRECTORY}/${buildId}`; + + const ios = { + scheme: IOS_SCHEME, + bundleId: IOS_BUNDLE_ID, + workspace: path.resolve('ios', 'testexpo.xcworkspace'), + isDevice, + destination: isDevice ? 'generic/platform=iOS' : 'generic/platform=iOS Simulator', + archivePath: `${outputPath}/${APP_NAME}.xcarchive`, + app: `${outputPath}/${APP_NAME}.app`, + appTarball: `${outputPath}/${APP_NAME}.tar.gz`, + ipa: `${outputPath}/${APP_NAME}.ipa`, + exportOptionsPlist: path.resolve('scripts/utils/exportOptions.plist'), + }; + + const android = { + packageId: ANDROID_PACKAGE_ID, + projectPath: path.resolve('android'), + apk: `${outputPath}/${APP_NAME}.apk`, + testApk: `${outputPath}/${APP_NAME}-androidTest.apk`, + }; + + return { + platform, + profile, + target, + buildId, + outputDirectory: OUTPUT_DIRECTORY, + outputPath, + ios, + android, + }; +} diff --git a/apps/test-expo/scripts/utils/shell.mjs b/apps/test-expo/scripts/utils/shell.mjs new file mode 100644 index 0000000000..ef577fce6f --- /dev/null +++ b/apps/test-expo/scripts/utils/shell.mjs @@ -0,0 +1,44 @@ +import { spawn } from 'node:child_process'; + +/** + * Runs a command with inherited stdio (output goes to terminal). + */ +export function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + if (!options.silent) { + console.log(`> ${command} ${args.join(' ')}`); + } + const child = spawn(command, args, { + stdio: 'inherit', + shell: false, + ...options, + }); + child.on('close', (code) => { + if (code === 0 || options.ignoreError) resolve(); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', (err) => { + if (options.ignoreError) resolve(); + else reject(err); + }); + }); +} + +/** + * Runs a command and captures its stdout (instead of inheriting stdio). + * Used when we need to parse the output of a command. + */ +export function runCapture(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ''; + child.stdout.on('data', (data) => { + stdout += data; + }); + child.on('close', (code) => { + if (code === 0) resolve(stdout); + else reject(new Error(`Command failed with code ${code}`)); + }); + child.on('error', reject); + }); +} diff --git a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts b/apps/test-expo/src/__generated__/iconSvgMap.ts similarity index 97% rename from packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts rename to apps/test-expo/src/__generated__/iconSvgMap.ts index 4ed7b29254..14cedc3187 100644 --- a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts +++ b/apps/test-expo/src/__generated__/iconSvgMap.ts @@ -1405,12 +1405,6 @@ export const svgMap: Record = { 'filter-16-inactive': { content: "" }, 'filter-24-active': { content: "" }, 'filter-24-inactive': { content: "" }, - 'filterLineStack-12-active': { content: "" }, - 'filterLineStack-12-inactive': { content: "" }, - 'filterLineStack-16-active': { content: "" }, - 'filterLineStack-16-inactive': { content: "" }, - 'filterLineStack-24-active': { content: "" }, - 'filterLineStack-24-inactive': { content: "" }, 'fingerprint-12-active': { content: "" }, 'fingerprint-12-inactive': { content: "" }, 'fingerprint-16-active': { content: "" }, @@ -1717,12 +1711,12 @@ export const svgMap: Record = { 'hurricane-16-inactive': { content: "" }, 'hurricane-24-active': { content: "" }, 'hurricane-24-inactive': { content: "" }, - 'ideal-12-active': { content: "" }, - 'ideal-12-inactive': { content: "" }, - 'ideal-16-active': { content: "" }, - 'ideal-16-inactive': { content: "" }, - 'ideal-24-active': { content: "" }, - 'ideal-24-inactive': { content: "" }, + 'ideal-12-active': { content: "" }, + 'ideal-12-inactive': { content: "" }, + 'ideal-16-active': { content: "" }, + 'ideal-16-inactive': { content: "" }, + 'ideal-24-active': { content: "" }, + 'ideal-24-inactive': { content: "" }, 'identityCard-12-active': { content: "" }, 'identityCard-12-inactive': { content: "" }, 'identityCard-16-active': { content: "" }, @@ -2227,12 +2221,6 @@ export const svgMap: Record = { 'pieChartData-16-inactive': { content: "" }, 'pieChartData-24-active': { content: "" }, 'pieChartData-24-inactive': { content: "" }, - 'pieChartWithArrow-12-active': { content: "" }, - 'pieChartWithArrow-12-inactive': { content: "" }, - 'pieChartWithArrow-16-active': { content: "" }, - 'pieChartWithArrow-16-inactive': { content: "" }, - 'pieChartWithArrow-24-active': { content: "" }, - 'pieChartWithArrow-24-inactive': { content: "" }, 'pillBottle-12-active': { content: "" }, 'pillBottle-12-inactive': { content: "" }, 'pillBottle-16-active': { content: "" }, @@ -3103,12 +3091,6 @@ export const svgMap: Record = { 'upload-16-inactive': { content: "" }, 'upload-24-active': { content: "" }, 'upload-24-inactive': { content: "" }, - 'usdc-12-active': { content: "" }, - 'usdc-12-inactive': { content: "" }, - 'usdc-16-active': { content: "" }, - 'usdc-16-inactive': { content: "" }, - 'usdc-24-active': { content: "" }, - 'usdc-24-inactive': { content: "" }, 'venturesProduct-12-active': { content: "" }, 'venturesProduct-12-inactive': { content: "" }, 'venturesProduct-16-active': { content: "" }, diff --git a/apps/mobile-app/src/hooks/useFonts.ts b/apps/test-expo/src/hooks/useFonts.ts similarity index 96% rename from apps/mobile-app/src/hooks/useFonts.ts rename to apps/test-expo/src/hooks/useFonts.ts index 133dc772ab..dbd71e608a 100644 --- a/apps/mobile-app/src/hooks/useFonts.ts +++ b/apps/test-expo/src/hooks/useFonts.ts @@ -1,4 +1,3 @@ -// import { useEffect, useState } from 'react'; import { Inter_400Regular } from '@expo-google-fonts/inter/400Regular'; import { Inter_600SemiBold } from '@expo-google-fonts/inter/600SemiBold'; import { useFonts as useFontsInter } from '@expo-google-fonts/inter/useFonts'; diff --git a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx b/apps/test-expo/src/playground/ExamplesListScreen.tsx similarity index 70% rename from packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx rename to apps/test-expo/src/playground/ExamplesListScreen.tsx index a780a1499f..c5b15f77ca 100644 --- a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx +++ b/apps/test-expo/src/playground/ExamplesListScreen.tsx @@ -1,29 +1,30 @@ import React, { useCallback, useContext } from 'react'; import { FlatList } from 'react-native'; import type { ListRenderItem } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { CellSpacing } from '@coinbase/cds-mobile/cells/Cell'; import { ListCell } from '@coinbase/cds-mobile/cells/ListCell'; import { Box } from '@coinbase/cds-mobile/layout/Box'; -import { useNavigation, useRoute } from '@react-navigation/native'; -import includes from 'lodash/includes'; +import { useNavigation } from '@react-navigation/native'; import { SearchFilterContext } from './ExamplesSearchProvider'; import { keyToRouteName } from './keyToRouteName'; -import { initialRouteKey, searchRouteKey } from './staticRoutes'; +import type { ExamplesListScreenProps } from './types'; + +const initialRouteKey = 'Examples'; +const searchRouteKey = 'Search'; const innerSpacingConfig: CellSpacing = { paddingX: 1 }; -export function ExamplesListScreen() { +export function ExamplesListScreen({ route }: ExamplesListScreenProps) { const searchFilter = useContext(SearchFilterContext); - - // React Navigation Route Param typing is not clean because our routes are dynamic - const routeKeys = (useRoute().params as { routeKeys: string[] } | undefined)?.routeKeys ?? []; + const routeKeys = route.params?.routeKeys ?? []; const { navigate } = useNavigation(); + const { bottom } = useSafeAreaInsets(); const renderItem: ListRenderItem = useCallback( ({ item }) => { const handlePress = () => { - // typing not clean due to dynamic routes navigate(keyToRouteName(item) as never); }; @@ -46,13 +47,18 @@ export function ExamplesListScreen() { .filter((key) => key !== initialRouteKey && key !== searchRouteKey) .filter((key) => { if (searchFilter !== '') { - return includes(key.toLowerCase(), searchFilter.toLowerCase()); + return key.toLowerCase().includes(searchFilter.toLowerCase()); } return true; }); return ( - + { if (iconSize <= 12) return 12; if (iconSize <= 16) return 16; diff --git a/apps/test-expo/src/playground/Playground.tsx b/apps/test-expo/src/playground/Playground.tsx new file mode 100644 index 0000000000..78badfc0ed --- /dev/null +++ b/apps/test-expo/src/playground/Playground.tsx @@ -0,0 +1,260 @@ +import React, { memo, useContext, useMemo } from 'react'; +import type { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import type { ColorScheme } from '@coinbase/cds-common/core/theme'; +import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; +import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; +import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Box } from '@coinbase/cds-mobile/layout/Box'; +import { HStack } from '@coinbase/cds-mobile/layout/HStack'; +import { Spacer } from '@coinbase/cds-mobile/layout/Spacer'; +import { Text } from '@coinbase/cds-mobile/typography/Text'; +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; + +import { ExamplesListScreen } from './ExamplesListScreen'; +import { + ExamplesSearchProvider, + SearchFilterContext, + SetSearchFilterContext, +} from './ExamplesSearchProvider'; +import { IconSheetScreen } from './IconSheetScreen'; +import { keyToRouteName } from './keyToRouteName'; +import type { PlaygroundRoute } from './PlaygroundRoute'; +import type { PlaygroundStackParamList } from './types'; + +const initialRouteName = keyToRouteName('Examples'); +const searchRouteName = keyToRouteName('Search'); + +const Stack = createNativeStackNavigator(); + +const titleOverrides: Record = { + Examples: 'CDS', + Text: 'Text (all)', +}; + +type PlaygroundProps = { + routes?: PlaygroundRoute[]; + listScreenTitle?: string; + setColorScheme?: React.Dispatch>; +}; + +type HeaderProps = { + isSearch: boolean; + showBackButton: boolean; + showSearch: boolean; + title: string; + onGoBack: () => void; + onGoBackFromSearch: () => void; + onGoToSearch: () => void; + onToggleTheme: () => void; + onSearchChange: (e: NativeSyntheticEvent) => void; + searchFilter: string; + isDark: boolean; +}; + +const HeaderContent = memo( + ({ + isSearch, + showBackButton, + showSearch, + title, + onGoBack, + onGoBackFromSearch, + onGoToSearch, + onToggleTheme, + onSearchChange, + searchFilter, + isDark, + }: HeaderProps) => { + const { top } = useSafeAreaInsets(); + const style = useMemo(() => ({ paddingTop: top }), [top]); + + const iconButtonPlaceholder = ( + + + + ); + + const leftHeaderButton = showSearch ? ( + + + + ) : showBackButton ? ( + + + + ) : ( + iconButtonPlaceholder + ); + + const rightHeaderButton = isSearch ? ( + iconButtonPlaceholder + ) : ( + + + + ); + + return ( + + + {leftHeaderButton} + + + {isSearch ? ( + + } + value={searchFilter} + /> + ) : ( + + {title} + + )} + + + {rightHeaderButton} + + + ); + }, +); + +const PlaygroundContent = memo( + ({ routes = [], listScreenTitle, setColorScheme }: PlaygroundProps) => { + const theme = useTheme(); + const searchFilter = useContext(SearchFilterContext); + const setFilter = useContext(SetSearchFilterContext); + + const routeKeys = useMemo(() => routes.map(({ key }) => key), [routes]); + + const screenOptions = useMemo( + (): NativeStackNavigationOptions => ({ + headerBackTitleVisible: false, + headerStyle: { + backgroundColor: theme.color.bg, + }, + headerShadowVisible: false, + header: ({ navigation, route, options }) => { + const routeName = route.name; + const isSearch = routeName === searchRouteName; + const showSearch = routeName === initialRouteName; + const canGoBack = navigation.canGoBack(); + const isFocused = navigation.isFocused(); + const showBackButton = isFocused && canGoBack && !isSearch; + + const handleGoBack = () => navigation.goBack(); + const handleGoBackFromSearch = () => { + setFilter(''); + navigation.goBack(); + }; + const handleGoToSearch = () => navigation.navigate(searchRouteName); + const handleToggleTheme = () => + setColorScheme?.((s) => (s === 'dark' ? 'light' : 'dark')); + const handleSearchChange = (e: NativeSyntheticEvent) => + setFilter(e.nativeEvent.text); + + return ( + + ); + }, + }), + [theme.color.bg, theme.activeColorScheme, searchFilter, setFilter, setColorScheme], + ); + + const exampleScreens = useMemo( + () => + [...routes].map((route) => { + const { key, getComponent } = route; + const name = keyToRouteName(key); + const title = titleOverrides[key] ?? key; + return ( + } + name={name} + options={{ title }} + /> + ); + }), + [routes], + ); + + return ( + + + + + {exampleScreens} + + ); + }, +); + +export const Playground = memo((props: PlaygroundProps) => { + return ( + + + + ); +}); diff --git a/apps/test-expo/src/playground/PlaygroundRoute.ts b/apps/test-expo/src/playground/PlaygroundRoute.ts new file mode 100644 index 0000000000..6ce4a62e0c --- /dev/null +++ b/apps/test-expo/src/playground/PlaygroundRoute.ts @@ -0,0 +1,6 @@ +import type React from 'react'; + +export type PlaygroundRoute = { + key: string; + getComponent: () => React.ComponentType; +}; diff --git a/apps/test-expo/src/playground/index.ts b/apps/test-expo/src/playground/index.ts new file mode 100644 index 0000000000..b163092b89 --- /dev/null +++ b/apps/test-expo/src/playground/index.ts @@ -0,0 +1,2 @@ +export { Playground } from './Playground'; +export type { PlaygroundRoute } from './PlaygroundRoute'; diff --git a/packages/ui-mobile-playground/src/components/keyToRouteName.ts b/apps/test-expo/src/playground/keyToRouteName.ts similarity index 100% rename from packages/ui-mobile-playground/src/components/keyToRouteName.ts rename to apps/test-expo/src/playground/keyToRouteName.ts diff --git a/apps/test-expo/src/playground/types.ts b/apps/test-expo/src/playground/types.ts new file mode 100644 index 0000000000..a62d3d9f05 --- /dev/null +++ b/apps/test-expo/src/playground/types.ts @@ -0,0 +1,28 @@ +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; + +type RouteParams = { routeKeys: string[] } | undefined; + +export type PlaygroundStackParamList = { + DebugExamples: { routeKeys: string[] }; + DebugSearch: { routeKeys: string[] }; + DebugIconSheet: undefined; +} & { + [key: string]: RouteParams; +}; + +export type ExamplesListScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugExamples' | 'DebugSearch' +>; + +export type IconSheetScreenProps = NativeStackScreenProps< + PlaygroundStackParamList, + 'DebugIconSheet' +>; + +declare global { + namespace ReactNavigation { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface RootParamList extends PlaygroundStackParamList {} + } +} diff --git a/apps/mobile-app/src/routes.ts b/apps/test-expo/src/routes.ts similarity index 93% rename from apps/mobile-app/src/routes.ts rename to apps/test-expo/src/routes.ts index 9aaf028390..d80ead566f 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/test-expo/src/routes.ts @@ -54,6 +54,11 @@ export const routes = [ require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') .default, }, + { + key: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, { key: 'AnimatedCaret', getComponent: () => @@ -62,7 +67,7 @@ export const routes = [ { key: 'AreaChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/area/__stories__/AreaChart.stories') .default, }, { @@ -77,7 +82,7 @@ export const routes = [ { key: 'Axis', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/axis/__stories__/Axis.stories').default, }, { key: 'Banner', @@ -96,7 +101,7 @@ export const routes = [ { key: 'BarChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/bar/__stories__/BarChart.stories').default, }, { key: 'Box', @@ -142,19 +147,19 @@ export const routes = [ { key: 'CartesianChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/CartesianChart.stories') .default, }, { key: 'ChartAccessibility', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/ChartAccessibility.stories') .default, }, { key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/ChartTransitions.stories') .default, }, { @@ -186,17 +191,6 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, }, - { - key: 'ComponentConfigProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, - }, - { - key: 'ComponentConfigProviderCustom', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') - .default, - }, { key: 'ContainedAssetCard', getComponent: () => @@ -347,7 +341,8 @@ export const routes = [ { key: 'Legend', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + require('@coinbase/cds-mobile/visualizations/chart/legend/__stories__/Legend.stories') + .default, }, { key: 'LinearGradient', @@ -357,7 +352,7 @@ export const routes = [ { key: 'LineChart', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') + require('@coinbase/cds-mobile/visualizations/chart/line/__stories__/LineChart.stories') .default, }, { @@ -523,7 +518,7 @@ export const routes = [ { key: 'PeriodSelector', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') + require('@coinbase/cds-mobile/visualizations/chart/__stories__/PeriodSelector.stories') .default, }, { @@ -564,7 +559,7 @@ export const routes = [ { key: 'ReferenceLine', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') + require('@coinbase/cds-mobile/visualizations/chart/line/__stories__/ReferenceLine.stories') .default, }, { @@ -585,7 +580,7 @@ export const routes = [ { key: 'Scrubber', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + require('@coinbase/cds-mobile/visualizations/chart/scrubber/__stories__/Scrubber.stories') .default, }, { @@ -629,24 +624,25 @@ export const routes = [ { key: 'Sparkline', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, + require('@coinbase/cds-mobile/visualizations/sparkline/__stories__/Sparkline.stories') + .default, }, { key: 'SparklineGradient', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/__stories__/SparklineGradient.stories') .default, }, { key: 'SparklineInteractive', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') .default, }, { key: 'SparklineInteractiveHeader', getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') + require('@coinbase/cds-mobile/visualizations/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') .default, }, { diff --git a/apps/mobile-app/tsconfig.json b/apps/test-expo/tsconfig.json similarity index 73% rename from apps/mobile-app/tsconfig.json rename to apps/test-expo/tsconfig.json index 1352a8ed5f..754926c77e 100644 --- a/apps/mobile-app/tsconfig.json +++ b/apps/test-expo/tsconfig.json @@ -13,13 +13,10 @@ }, "include": [ "src/**/*", + "*.tsx", "*.config.js", "*.config.ts", - "scripts", - ".eslintrc.cjs", - "*.d.ts", - "*.json", - "e2e" + "*.json" ], "exclude": [], "references": [ @@ -34,12 +31,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/ui-mobile-playground" - }, - { - "path": "../../packages/mobile-visualization" } ] } diff --git a/apps/vite-app/index.html b/apps/vite-app/index.html index c19f9c4a27..a14497510e 100644 --- a/apps/vite-app/index.html +++ b/apps/vite-app/index.html @@ -2,7 +2,6 @@ - CDS Vite App diff --git a/apps/vite-app/package.json b/apps/vite-app/package.json index c8f9a300e1..888ade3742 100644 --- a/apps/vite-app/package.json +++ b/apps/vite-app/package.json @@ -13,17 +13,17 @@ "@coinbase/cds-icons": "workspace:^", "@coinbase/cds-illustrations": "workspace:^", "@coinbase/cds-web": "workspace:^", - "@coinbase/cds-web-visualization": "workspace:^", "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "19.1.2", + "react-dom": "19.1.2" }, "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^5.0.0", + "@coinbase/cds-migrator": "workspace:^", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "@vitejs/plugin-react": "^5.1.2", "typescript": "~5.9.2", - "vite": "^7.1.2" + "vite": "^7.3.1" }, "packageManager": "yarn@4.7.0" } diff --git a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx index 4d976dbebb..b33bda12f7 100644 --- a/apps/vite-app/src/components/CardList/ETHStakingCard.tsx +++ b/apps/vite-app/src/components/CardList/ETHStakingCard.tsx @@ -7,7 +7,6 @@ export const ETHStakingCard = () => { return ( Earn staking rewards on ETH by holding it on Coinbase @@ -18,6 +17,7 @@ export const ETHStakingCard = () => { } + style={{ backgroundColor: 'rgb(var(--purple70))' }} title={ Up to 3.29% APR on ETHs diff --git a/apps/vite-app/tsconfig.app.json b/apps/vite-app/tsconfig.app.json index e61b84d78c..bb78b43086 100644 --- a/apps/vite-app/tsconfig.app.json +++ b/apps/vite-app/tsconfig.app.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.project.json", "compilerOptions": { "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, "noEmit": true, "noUncheckedSideEffectImports": true @@ -22,9 +22,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/apps/vite-app/tsconfig.node.json b/apps/vite-app/tsconfig.node.json index 09d8b6a68f..8381935dd9 100644 --- a/apps/vite-app/tsconfig.node.json +++ b/apps/vite-app/tsconfig.node.json @@ -21,9 +21,6 @@ }, { "path": "../../packages/illustrations" - }, - { - "path": "../../packages/web-visualization" } ] } diff --git a/eslint.config.mjs b/eslint.config.mjs index aa1085e4b6..b2ab7f4566 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,24 +32,35 @@ const ignores = [ '**/esm/**', '**/lib/**', '**/templates/**', + '**/__testfixtures__/**', '**/.next/**', // These files use assert { type: 'json' } syntax that breaks eslint and must be fully ignored '**/getAffectedRoutes.mjs', '**/getBuildInfo.mjs', - 'apps/mobile-app/prebuilds', // within their NX project, these files are not included by the Typescript config // when linting with TS types (e.g. internal/safely-spread-props) this will raise an error 'packages/web/optimize-css.ts', 'packages/icons/scripts/*.ts', 'packages/illustrations/scripts/*.ts', - 'packages/ui-mobile-playground/scripts/*.ts', 'libs/docusaurus-plugin-docgen/module-declarations.d.ts', ]; +// TODO (CDS-1412): Fix these react-hooks rule violations and re-enable them +const disabledNewReactHooksRules = { + 'react-hooks/immutability': 'off', + 'react-hooks/purity': 'off', + 'react-hooks/refs': 'off', + 'react-hooks/set-state-in-effect': 'off', + 'react-hooks/set-state-in-render': 'off', + 'react-hooks/static-components': 'off', + 'react-hooks/preserve-manual-memoization': 'off', +}; + // These rules apply to all files const sharedRules = { 'internal/no-object-rest-spread-in-worklet': 'error', 'internal/deprecated-jsdoc-has-removal-version': 'error', + 'internal/spread-props-last': 'warn', 'import/default': 'off', 'import/extensions': 'off', 'import/named': 'off', @@ -68,6 +79,16 @@ const sharedRules = { message: 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, ], patterns: [ { @@ -137,6 +158,96 @@ const sharedRules = { ], 'react/prop-types': 'off', 'react/react-in-jsx-scope': 'off', + ...disabledNewReactHooksRules, +}; + +// React 19 introduced new APIs that do not exist in React 18. +// CDS must remain compatible with React 18 consumers, so we restrict these imports +// in all publishable packages. The `no-restricted-imports` rule in this object +// is a superset of the one in `sharedRules` to avoid flat-config override issues. +const react19CompatibilityRules = { + 'no-restricted-imports': [ + 'error', + { + paths: [ + // Existing restrictions (duplicated because flat config replaces, not merges) + { + name: '@linaria/core', + importNames: ['cx'], + message: + 'Do not import `cx` from Linaria. Use the `cx` function from @coinbase/cds-web instead.', + }, + { + name: 'react-popper', + message: + 'Do not import `react-popper` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + { + name: '@popperjs/core', + message: + 'Do not import `@popperjs/core` directly. Use Floating UI instead; the legacy dependency is only allowed in `packages/web/src/overlays/popover/usePopper.ts` for backwards compatibility.', + }, + // React 19-only runtime APIs + { + name: 'react', + importNames: ['cache', 'captureOwnerStack', 'use', 'useActionState', 'useOptimistic'], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React 19-only types (would break .d.ts output for React 18 consumers) + { + name: 'react', + importNames: ['ActionDispatch', 'AnyActionArg', 'AwaitedReactNode', 'Usable'], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only runtime APIs + { + name: 'react-dom', + importNames: [ + 'preconnect', + 'prefetchDNS', + 'preinit', + 'preinitModule', + 'preload', + 'preloadModule', + 'requestFormReset', + 'useFormState', + 'useFormStatus', + ], + message: + 'This is a React 19-only API. CDS must remain compatible with React 18 consumers.', + }, + // React DOM 19-only types + { + name: 'react-dom', + importNames: [ + 'FormStatus', + 'FormStatusNotPending', + 'FormStatusPending', + 'PreconnectOptions', + 'PreinitAs', + 'PreinitModuleAs', + 'PreinitModuleOptions', + 'PreinitOptions', + 'PreloadAs', + 'PreloadModuleAs', + 'PreloadModuleOptions', + 'PreloadOptions', + ], + message: + 'This is a React 19-only type. CDS must remain compatible with React 18 consumers.', + }, + ], + patterns: [ + { + group: ['*/booleanStyles', '*/responsive/*'], + message: + 'Do not import these styles directly, as it will cause non-deterministic CSS generation. Use the `getStyles` function from @coinbase/cds-web/styles/styleProps.ts or the component StyleProps API instead.', + }, + ], + }, + ], }; // These rules only apply to TS/TSX files in packages/**, and do not apply to stories or tests @@ -176,6 +287,7 @@ const typescriptRules = { // These rules only apply to test files const testRules = { + 'internal/spread-props-last': 'off', 'jest/no-mocks-import': 'off', 'testing-library/await-async-events': 'off', 'testing-library/await-async-queries': 'off', @@ -209,7 +321,7 @@ const sharedExtends = [ eslintJs.configs.recommended, eslintImport.flatConfigs.recommended, eslintReact.configs.flat.recommended, - eslintReactHooks.configs['recommended-latest'], + eslintReactHooks.configs.flat['recommended-latest'], eslintReactPerf.configs.flat.recommended, eslintJsxA11y.flatConfigs.recommended, ]; @@ -274,7 +386,6 @@ export default tseslint.config( files: ['packages/**/*.{ts,tsx}'], ignores: [ 'packages/illustrations/src/__generated__/**', - 'packages/ui-mobile-playground/**', 'packages/**/__stories__/**', 'packages/**/__tests__/**', 'packages/**/__mocks__/**', @@ -309,6 +420,23 @@ export default tseslint.config( ...typescriptRules, }, }, + // Restrict React 19-only APIs in publishable packages to maintain React 18 compatibility + { + files: [ + 'packages/web/**/*.{ts,tsx}', + 'packages/common/**/*.{ts,tsx}', + 'packages/mobile/**/*.{ts,tsx}', + ], + rules: { + ...react19CompatibilityRules, + }, + }, + { + files: ['**/*.stories.{js,jsx,ts,tsx}', '**/__stories__/**'], + rules: { + 'internal/spread-props-last': 'off', + }, + }, // Rules specific to mobile story files { files: ['packages/mobile/**/*.stories.tsx'], @@ -318,13 +446,16 @@ export default tseslint.config( { files: ['**/*.figma.tsx'], extends: [internalPlugin.configs.figmaConnectRules], + rules: { + 'internal/spread-props-last': 'off', + }, }, { files: ['**/*.mdx'], processor: internalPlugin.processors.mdx, }, { - files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/setup.js'], + files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/jest/**/*.js'], settings: sharedSettings, languageOptions: { globals: { diff --git a/figma.config.mobile.json b/figma.config.mobile.json index ca11d800b8..29fedf2496 100644 --- a/figma.config.mobile.json +++ b/figma.config.mobile.json @@ -3,8 +3,7 @@ "parser": "react", "label": "React Native", "include": [ - "packages/mobile/src/**/*.tsx", - "packages/mobile-visualization/src/**/*.tsx" + "packages/mobile/src/**/*.tsx" ], "exclude": [ "**/__tests__/**", diff --git a/figma.config.web.json b/figma.config.web.json index 4a3dbbe934..d18cd6edf9 100644 --- a/figma.config.web.json +++ b/figma.config.web.json @@ -3,8 +3,7 @@ "parser": "react", "label": "React", "include": [ - "packages/web/src/**/*.tsx", - "packages/web-visualization/src/**/*.tsx" + "packages/web/src/**/*.tsx" ], "exclude": [ "**/__tests__/**", diff --git a/jest.preset-mobile.js b/jest.preset-mobile.js index 7e9524b889..b732cfd97e 100644 --- a/jest.preset-mobile.js +++ b/jest.preset-mobile.js @@ -18,7 +18,7 @@ const config = { '\\.(jpg|jpeg|png|gif)$': 'identity-obj-proxy', }, setupFiles: [...reactNativePreset.setupFiles], - setupFilesAfterEnv: ['jest-extended', '@testing-library/jest-native/extend-expect'], + setupFilesAfterEnv: ['jest-extended'], testMatch: ['**/*.test.[jt]s?(x)'], testPathIgnorePatterns: [ '/node_modules/', diff --git a/libs/codegen/package.json b/libs/codegen/package.json index e936f26d08..a1e43fe203 100644 --- a/libs/codegen/package.json +++ b/libs/codegen/package.json @@ -46,7 +46,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/codegen/src/playground/prepareRoutes.ts b/libs/codegen/src/playground/prepareRoutes.ts index d4eb5cbb47..77d7d797fa 100644 --- a/libs/codegen/src/playground/prepareRoutes.ts +++ b/libs/codegen/src/playground/prepareRoutes.ts @@ -5,20 +5,10 @@ import { getSourcePath } from '../utils/getSourcePath'; import { writeFile } from '../utils/writeFile'; /** + * Computes the package import path for a given story file path. * - * We need to compute the relative path given a filePath. It should - * be relative to the 'packages/mobile/examples' folder. - * - * Previously, we hardcoded the relative path in - * mobileRoutes.ejs. But with more mobile packages, stories can be live in - * other packages other than packages/mobile. Thus, adding '../' is - * insufficient. You can't reach mobile-visualization by going 1 folder up from - * 'packages/mobile/examples'. You need to go 2 folders up, so you need to - * appending '../../' to the filePath. This function determines its folder and - * computes the correct relative path. - * - * @param filePath The path of the file - * @returns The relative path. It is relative to 'packages/mobile/examples' + * @param filePath The path of the file relative to packages/ + * @returns The package import path (e.g. `@coinbase/cds-mobile/...`) */ function getRelativePath(filePath: string) { const relativePath = filePath.replace('.tsx', ''); @@ -30,18 +20,12 @@ async function getRoutes() { try { const rootDir = getSourcePath('packages'); - // Our stories may come from other packages not within mobile, so - // we are adding a new regular expression to capture stories that are - // in other mobile packages - const files = await glob( - ['**/(mobile|mobile-visualization)/src/**/__stories__/*.stories.(ts|tsx|js|jsx)'], - { - ignore: ['__tests__/*'], - onlyFiles: true, - cwd: rootDir, - absolute: false, - }, - ); + const files = await glob(['**/mobile/src/**/__stories__/*.stories.(ts|tsx|js|jsx)'], { + ignore: ['__tests__/*'], + onlyFiles: true, + cwd: rootDir, + absolute: false, + }); const processedFiles = files .map((file) => { @@ -69,48 +53,16 @@ export async function prepare() { try { const routes = await getRoutes(); - const hotReloadRoutes = routes.map((route) => ({ - name: route.name, - path: route.path, - })); const consumerRoutes = routes.map((route) => ({ name: route.name, path: route.consumerPath, })); - // Write to ui-mobile-playground package. This includes the route paths that consumers would use. - await writeFile({ - data: { routes: consumerRoutes }, - template: 'mobileRoutes.ejs', - dest: `packages/ui-mobile-playground/src/routes.ts`, - }); - - // Write to mobile-app. This is required for hot reload - internal packages need src in path for hot reload, while consumers do not. - await writeFile({ - data: { routes: hotReloadRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/src/routes.ts`, - }); - - // Write to mobile-app. This is required for hot reload - internal packages need src in path for hot reload, while consumers do not. - await writeFile({ - data: { routes: hotReloadRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/src/routes.ts`, - }); - - // Write to mobile-app. This is required for evaluating which routes to run during visreg testing. - await writeFile({ - data: { routes: consumerRoutes }, - template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/scripts/utils/routes.mjs`, - }); - - // Write to mobile-app. This is required for evaluating which routes to run during visreg testing. + // Write to test-expo for Expo demo app await writeFile({ data: { routes: consumerRoutes }, template: 'mobileRoutes.ejs', - dest: `apps/mobile-app/scripts/utils/routes.mjs`, + dest: `apps/test-expo/src/routes.ts`, }); } catch (err) { if (err instanceof Error) { diff --git a/libs/docusaurus-plugin-docgen/package.json b/libs/docusaurus-plugin-docgen/package.json index 5e988e810a..89d5e0da1d 100644 --- a/libs/docusaurus-plugin-docgen/package.json +++ b/libs/docusaurus-plugin-docgen/package.json @@ -46,10 +46,14 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/ejs": "^3.1.0", - "@types/lodash": "^4.14.178" + "@types/lodash": "^4.14.178", + "fast-glob": "^3.2.11", + "lodash": "^4.17.21", + "prettier": "^3.6.2", + "type-fest": "^2.19.0" } } diff --git a/libs/docusaurus-plugin-kbar/package.json b/libs/docusaurus-plugin-kbar/package.json index 6d32eeef4a..aa9487fbaa 100644 --- a/libs/docusaurus-plugin-kbar/package.json +++ b/libs/docusaurus-plugin-kbar/package.json @@ -39,7 +39,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/libs/docusaurus-plugin-llm-dev-server/package.json b/libs/docusaurus-plugin-llm-dev-server/package.json index a5140a1d2e..84bc994fb1 100644 --- a/libs/docusaurus-plugin-llm-dev-server/package.json +++ b/libs/docusaurus-plugin-llm-dev-server/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@docusaurus/types": "~3.7.0", "@types/express": "^4.17.21" diff --git a/libs/eslint-plugin-internal/README.md b/libs/eslint-plugin-internal/README.md index 9fdc7a32a8..553a2375a8 100644 --- a/libs/eslint-plugin-internal/README.md +++ b/libs/eslint-plugin-internal/README.md @@ -100,6 +100,16 @@ We have encountered situations where developers accidentally forgot to destructu At this time this rule is intended to only be used within this repo in the cds-web and cds-mobile packages. However, after a trial period we may consider opening it up to a wider audience. +## spread-props-last + +Requires JSX spread props that come from a component's own `props` parameter to appear after all explicit JSX props in an element. + +This helps avoid accidental prop overrides and keeps prop ordering predictable: + +- Good: ` + +
+ ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/dynamic-expression.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/dynamic-expression.output.tsx new file mode 100644 index 0000000000..a4c66b03ec --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/dynamic-expression.output.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button } from '@coinbase/cds-web'; + +type Props = { + variant: 'tertiary' | 'primary'; + isActive: boolean; +}; + +export function DynamicButton({ variant, isActive }: Props) { + return ( +
+ // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + + // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + +
+ ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.input.tsx new file mode 100644 index 0000000000..03e235ebdd --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.input.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button, IconButton } from '@coinbase/cds-web'; + +export function Toolbar() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.output.tsx new file mode 100644 index 0000000000..50bc138d19 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/mixed-variants.output.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Button, IconButton } from '@coinbase/cds-web'; + +export function Toolbar() { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/no-cds-imports.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/no-cds-imports.input.tsx new file mode 100644 index 0000000000..790d62f424 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/button-variant-values/no-cds-imports.input.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Button } from './components/Button'; + +export function CustomToolbar() { + return ( +
+ + +
+ ); +} diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.input.tsx new file mode 100644 index 0000000000..4d049435c7 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.input.tsx @@ -0,0 +1,6 @@ +import { useMergeRefs } from '@example/cds-common/hooks/useMergeRefs'; + +export const X = () => { + const ref = useMergeRefs(a, b); + return ref; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.output.tsx new file mode 100644 index 0000000000..2ce39f1091 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/alternate-scope-basic.output.tsx @@ -0,0 +1,6 @@ +import { mergeRefs } from "@example/cds-common/utils/mergeRefs"; + +export const X = () => { + const ref = mergeRefs(a, b); + return ref; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.input.tsx new file mode 100644 index 0000000000..1afc71e426 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.input.tsx @@ -0,0 +1,6 @@ +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +export const X = () => { + const ref = useMergeRefs(a, b); + return ref; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.output.tsx new file mode 100644 index 0000000000..26d382827b --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/basic.output.tsx @@ -0,0 +1,6 @@ +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; + +export const X = () => { + const ref = mergeRefs(a, b); + return ref; +}; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.input.tsx new file mode 100644 index 0000000000..7957b38aed --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.input.tsx @@ -0,0 +1,2 @@ +import { useMergeRefs as combineRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; +combineRefs(r1, r2); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.output.tsx new file mode 100644 index 0000000000..156e3fc956 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/import-alias.output.tsx @@ -0,0 +1,2 @@ +import { mergeRefs as combineRefs } from "@coinbase/cds-common/utils/mergeRefs"; +combineRefs(r1, r2); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.input.tsx new file mode 100644 index 0000000000..343c04fbd7 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.input.tsx @@ -0,0 +1,3 @@ +jest.mock('@coinbase/cds-common/hooks/useMergeRefs'); +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; +useMergeRefs(x); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.output.tsx new file mode 100644 index 0000000000..9b3ecd8a66 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/jest-mock.output.tsx @@ -0,0 +1,3 @@ +jest.mock("@coinbase/cds-common/utils/mergeRefs"); +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; +mergeRefs(x); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.input.tsx new file mode 100644 index 0000000000..c576f4c029 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.input.tsx @@ -0,0 +1,4 @@ +import { mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; + +const cb = mergeRefs(useMergeRefs(a)); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.output.tsx new file mode 100644 index 0000000000..c2f60c0da7 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/merge-duplicate-imports.output.tsx @@ -0,0 +1,3 @@ +import { mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; + +const cb = mergeRefs(mergeRefs(a)); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/nothing-to-migrate.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/nothing-to-migrate.input.tsx new file mode 100644 index 0000000000..53104a603d --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/nothing-to-migrate.input.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export const x = 1; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.input.tsx new file mode 100644 index 0000000000..3b87fb4845 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.input.tsx @@ -0,0 +1,3 @@ +import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; +const o = { useMergeRefs: 1 }; +useMergeRefs(r); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.output.tsx new file mode 100644 index 0000000000..157eedebe5 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/object-literal-key.output.tsx @@ -0,0 +1,3 @@ +import { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; +const o = { useMergeRefs: 1 }; +mergeRefs(r); diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.input.tsx new file mode 100644 index 0000000000..bd0600883b --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.input.tsx @@ -0,0 +1 @@ +export { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.output.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.output.tsx new file mode 100644 index 0000000000..ab01380452 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/re-export.output.tsx @@ -0,0 +1 @@ +export { mergeRefs } from "@coinbase/cds-common/utils/mergeRefs"; diff --git a/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/third-party-import.input.tsx b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/third-party-import.input.tsx new file mode 100644 index 0000000000..ad9f2797e7 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__testfixtures__/migrate-use-merge-refs/third-party-import.input.tsx @@ -0,0 +1,5 @@ +import { useMergeRefs } from 'some-other-library'; + +export function f() { + return useMergeRefs(a, b); +} diff --git a/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts b/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts new file mode 100644 index 0000000000..f2b46bc309 --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/button-variant-values.test.ts @@ -0,0 +1,202 @@ +import { applyTransform } from 'jscodeshift/src/testUtils'; + +import { readTransformFixture } from '../../../test-utils/readTransformFixture'; +import transform from '../button-variant-values'; + +const FIXTURE_SUITE = 'button-variant-values'; + +function applyButtonVariantTransform( + source: string, + jscodeshiftOptions: Record = {}, +) { + return applyTransform(transform, jscodeshiftOptions, { source }, { parser: 'tsx' }); +} + +function readFixture(name: string) { + return readTransformFixture(__dirname, FIXTURE_SUITE, `${name}.tsx`); +} + +describe('button-variant-values', () => { + describe('string literal rewrites', () => { + it('rewrites variant="tertiary" to variant="inverse" on Button from @coinbase/cds-web', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on Button from @coinbase/cds-web', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="secondary"'); + expect(output).not.toContain('variant="foregroundMuted"'); + }); + + it('rewrites variant="tertiary" to variant="inverse" on Button from a non-@coinbase scope', () => { + const input = ` +import { Button } from '@example/cds-web'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + + it('skips non-matching scope when --package-scope is set', () => { + const input = ` +import { Button } from '@example/cds-web'; +const App = () => ; +`; + expect(applyButtonVariantTransform(input, { packageScope: '@coinbase' })).toBe(''); + }); + + it('rewrites variant="tertiary" to variant="inverse" on IconButton from @coinbase/cds-mobile', () => { + const input = ` +import { IconButton } from '@coinbase/cds-mobile'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + + it('rewrites variant="foregroundMuted" to variant="secondary" on IconButton from @coinbase/cds-mobile', () => { + const input = ` +import { IconButton } from '@coinbase/cds-mobile'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="secondary"'); + expect(output).not.toContain('variant="foregroundMuted"'); + }); + }); + + describe('dynamic expressions', () => { + it('adds a TODO comment for dynamic variant expressions', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = ({ v }) => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('TODO(cds-migration)'); + expect(output).toContain('variant values changed in v9'); + }); + + it('does not add duplicate TODO if already present', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +const App = ({ v }) => + // TODO(cds-migration): Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating. + ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + }); + + describe('skipped cases', () => { + it('returns empty string for files with no CDS imports', () => { + const input = ` +import { Button } from './MyButton'; +const App = () => ; +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + + it('does not modify non-CDS Button components', () => { + const input = ` +import { Button } from '@coinbase/cds-web'; +import { Button as ThirdPartyButton } from 'third-party-lib'; +const App = () => ( + <> + + Other + +); +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain(' + + + +); +`; + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + }); + + describe('aliased imports', () => { + it('transforms aliased CDS Button imports', () => { + const input = ` +import { Button as CdsButton } from '@coinbase/cds-web'; +const App = () => Click; +`; + const output = applyButtonVariantTransform(input); + expect(output).toContain('variant="inverse"'); + expect(output).not.toContain('variant="tertiary"'); + }); + }); + + describe('idempotency', () => { + it('produces the same result when run twice', () => { + const input = ` +import { Button, IconButton } from '@coinbase/cds-web'; +const App = () => ( + <> + + + +); +`; + const firstPass = applyButtonVariantTransform(input); + const secondPass = applyButtonVariantTransform(firstPass); + expect(secondPass).toBe(''); + }); + }); + + describe('e2e fixtures', () => { + it('transforms mixed variants correctly', () => { + const input = readFixture('mixed-variants.input'); + const expected = readFixture('mixed-variants.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + + it('adds TODO for dynamic expressions', () => { + const input = readFixture('dynamic-expression.input'); + const expected = readFixture('dynamic-expression.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + + it('skips files with no CDS imports', () => { + const input = readFixture('no-cds-imports.input'); + const output = applyButtonVariantTransform(input); + expect(output).toBe(''); + }); + + it('transforms aliased imports correctly', () => { + const input = readFixture('aliased-imports.input'); + const expected = readFixture('aliased-imports.output'); + const output = applyButtonVariantTransform(input); + expect(output.trim()).toEqual(expected.trim()); + }); + }); +}); diff --git a/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts b/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts new file mode 100644 index 0000000000..b4b4877e9b --- /dev/null +++ b/packages/migrator/src/transforms/v9/__tests__/migrate-use-merge-refs.test.ts @@ -0,0 +1,69 @@ +import { applyTransform } from 'jscodeshift/src/testUtils'; + +import { readTransformFixture } from '../../../test-utils/readTransformFixture'; +import transform from '../migrate-use-merge-refs'; + +const FIXTURE_SUITE = 'migrate-use-merge-refs'; + +function readFixtureFile(name: string): string { + return readTransformFixture(__dirname, FIXTURE_SUITE, name); +} + +function applyMigrateTransform( + source: string, + jscodeshiftOptions: Record = {}, +): string { + return applyTransform(transform, jscodeshiftOptions, { source }, { parser: 'tsx' }); +} + +describe('migrate-use-merge-refs', () => { + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + ['basic'], + ['alternate-scope-basic'], + ['import-alias'], + ['jest-mock'], + ['re-export'], + ['merge-duplicate-imports'], + ['object-literal-key'], + ])('transforms %s fixture', (basename) => { + const input = readFixtureFile(`${basename}.input.tsx`); + const expected = readFixtureFile(`${basename}.output.tsx`); + const out = applyMigrateTransform(input); + expect(out).not.toBe(''); + expect(out).toBe(expected.trim()); + }); + + it('does not modify third-party useMergeRefs import', () => { + const input = readFixtureFile('third-party-import.input.tsx'); + expect(applyMigrateTransform(input)).toBe(''); + }); + + it('does not migrate alternate scope when --package-scope is @coinbase', () => { + const input = readFixtureFile('alternate-scope-basic.input.tsx'); + expect(applyMigrateTransform(input, { packageScope: '@coinbase' })).toBe(''); + }); + + it('migrates alternate scope when --package-scope matches', () => { + const input = readFixtureFile('alternate-scope-basic.input.tsx'); + const expected = readFixtureFile('alternate-scope-basic.output.tsx'); + expect(applyMigrateTransform(input, { packageScope: '@example' })).toBe(expected.trim()); + }); + + it('makes no changes when there is nothing to migrate', () => { + const input = readFixtureFile('nothing-to-migrate.input.tsx'); + expect(applyMigrateTransform(input)).toBe(''); + }); + + it('is idempotent: second run on transformed output makes no changes', () => { + const transformed = readFixtureFile('basic.output.tsx'); + expect(applyMigrateTransform(transformed)).toBe(''); + }); +}); diff --git a/packages/migrator/src/transforms/v9/button-variant-values.ts b/packages/migrator/src/transforms/v9/button-variant-values.ts new file mode 100644 index 0000000000..0729f3b413 --- /dev/null +++ b/packages/migrator/src/transforms/v9/button-variant-values.ts @@ -0,0 +1,121 @@ +/** + * Button Variant Values Transform (v8 → v9) + * + * Remaps Button/IconButton `variant` prop values to reflect v9 naming: + * - "tertiary" → "inverse" (old tertiary used bgInverse; v9 gives tertiary new semantics) + * - "foregroundMuted" → "secondary" (foregroundMuted deprecated per design) + * + * Only targets components imported from `@/cds-web` or `@/cds-mobile`. Use CLI + * `-ps` / `--package-scope` to limit to one scope, or omit to match every scope. + * Adds TODO comments for dynamic variant expressions that need manual review. + */ +import type { API, FileInfo, Options } from 'jscodeshift'; + +import { escapeRegExp, getPackageScopeFromOptions } from '../../utils/package-scope'; +import { addTodoComment, hasMigrationTodo, transformLogger } from '../../utils/transform-utils'; + +const VARIANT_MAP: Record = { + tertiary: 'inverse', + foregroundMuted: 'secondary', +}; + +const CDS_WEB_OR_MOBILE_PACKAGE_RE = /^@[^/]+\/(cds-web|cds-mobile)$/; + +function buildCdsWebOrMobilePackageRe(packageScope: string | undefined): RegExp { + if (packageScope) { + return new RegExp(`^${escapeRegExp(packageScope)}/(cds-web|cds-mobile)$`); + } + return CDS_WEB_OR_MOBILE_PACKAGE_RE; +} + +const TARGET_COMPONENTS = ['Button', 'IconButton']; + +// eslint-disable-next-line no-restricted-exports -- jscodeshift requires default export +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const cdsPackageRe = buildCdsWebOrMobilePackageRe(packageScope); + + const cdsComponentLocalNames = new Set(); + + root + .find(j.ImportDeclaration) + .filter((path) => { + const src = path.value.source; + return j.StringLiteral.check(src) && cdsPackageRe.test(src.value); + }) + .forEach((path) => { + path.value.specifiers?.forEach((specifier) => { + if ( + j.ImportSpecifier.check(specifier) && + TARGET_COMPONENTS.includes(specifier.imported.name) + ) { + cdsComponentLocalNames.add(specifier.local?.name ?? specifier.imported.name); + } + }); + }); + + if (cdsComponentLocalNames.size === 0) { + return null; + } + + let hasChanges = false; + + root + .find(j.JSXElement) + .filter((path) => { + const name = path.value.openingElement.name; + return j.JSXIdentifier.check(name) && cdsComponentLocalNames.has(name.name); + }) + .forEach((path) => { + const variantAttr = path.value.openingElement.attributes?.find( + (attr) => + j.JSXAttribute.check(attr) && + j.JSXIdentifier.check(attr.name) && + attr.name.name === 'variant', + ); + + if (!variantAttr || !j.JSXAttribute.check(variantAttr)) return; + + const value = variantAttr.value; + + if (j.StringLiteral.check(value)) { + const oldVariant = value.value; + const newVariant = VARIANT_MAP[oldVariant]; + if (newVariant) { + value.value = newVariant; + hasChanges = true; + + transformLogger.success( + `Updated variant: ${oldVariant} → ${newVariant}`, + file.path, + path.value.loc?.start.line, + ); + } + } else if (j.JSXExpressionContainer.check(value)) { + if (!hasMigrationTodo(path)) { + addTodoComment( + j, + path, + 'Button variant values changed in v9: "tertiary" is now "inverse", "foregroundMuted" is now "secondary". Check if this dynamic value needs updating.', + ); + + transformLogger.warn( + 'Dynamic variant expression requires manual review', + file.path, + path.value.loc?.start.line, + ); + + hasChanges = true; + } + } + }); + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts b/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts new file mode 100644 index 0000000000..93eb1f022a --- /dev/null +++ b/packages/migrator/src/transforms/v9/migrate-use-merge-refs.ts @@ -0,0 +1,420 @@ +/** + * Migrate `useMergeRefs` from the deprecated hooks entry to `mergeRefs` on `utils/mergeRefs`. + * + * Goal: use `…/utils/mergeRefs` and the `mergeRefs` binding (the deprecated `useMergeRefs` export is + * identical at runtime to `mergeRefs`). Matches `@/cds-common/…`; use CLI `-ps` / `--package-scope` + * to limit to one scope, or omit to match every scope. + * + * Cases handled: + * - A: `import { useMergeRefs } from '…hooks/useMergeRefs'` → `import { mergeRefs } from '…utils/mergeRefs'`. + * - B: `import { useMergeRefs as x } from '…'` → `import { mergeRefs as x } from '…utils/mergeRefs'`. + * - C: `export { useMergeRefs } from '…hooks/useMergeRefs'` → path + `export { mergeRefs } from '…utils/mergeRefs'`. + * - D: Any string literal exactly equal to the deprecated module path → new path. + * - E: Call sites and other references `useMergeRefs` → `mergeRefs` when they refer to CDS (see below). + * - F: After rewrites, multiple `import … from '…utils/mergeRefs'` → merged; named specifiers deduped. + * + * Reference rename safety: + * - Global `useMergeRefs` → `mergeRefs` renames run only if this file touched a CDS mergeRefs + * module (deprecated path, mock string, or `useMergeRefs` in a CDS import/re-export). Files that + * only import `useMergeRefs` from other packages are left unchanged. + * - Skips non-computed object literal keys (`{ useMergeRefs: 1 }`), TS property keys, class method + * names, and non-computed member expression properties (`obj.useMergeRefs`). + * - Renames object shorthand `{ useMergeRefs }` (value/key are the same binding). + * + * Not handled: + * - Relative paths (e.g. `../hooks/useMergeRefs`). + * - Dynamic `import()` / `require()` for this module. + * + * Idempotency: second run leaves the file unchanged. + * + * @see packages/common/src/hooks/useMergeRefs.ts — re-exports from ../utils/mergeRefs + * + * NOTE: Transforms load in jscodeshift workers; keep imports limited to transform-utils. + */ + +import type { API, ASTPath, FileInfo, Identifier, Options } from 'jscodeshift'; + +import { escapeRegExp, getPackageScopeFromOptions } from '../../utils/package-scope'; +import { transformLogger } from '../../utils/transform-utils'; + +/** `hooks/useMergeRefs` or `utils/mergeRefs` under `@/cds-common`. */ +function buildCdsCommonMergeRefsModuleRe(packageScope: string | undefined): RegExp { + if (packageScope) { + return new RegExp( + `^${escapeRegExp(packageScope)}/cds-common/(hooks\\/useMergeRefs|utils\\/mergeRefs)$`, + ); + } + return /^@[^/]+\/cds-common\/(hooks\/useMergeRefs|utils\/mergeRefs)$/; +} + +function buildCdsCommonMergeRefsUtilsModuleRe(packageScope: string | undefined): RegExp { + if (packageScope) { + return new RegExp(`^${escapeRegExp(packageScope)}/cds-common/utils/mergeRefs$`); + } + return /^@[^/]+\/cds-common\/utils\/mergeRefs$/; +} + +/** Deprecated hooks entry → utils entry (preserves scope). */ +function migrateMergeRefsModulePath( + value: string, + packageScope: string | undefined, +): string | null { + if (packageScope) { + const m = value.match( + new RegExp(`^(${escapeRegExp(packageScope)}/cds-common)/hooks/useMergeRefs$`), + ); + if (m) { + return `${m[1]}/utils/mergeRefs`; + } + return null; + } + const m = value.match(/^(@[^/]+\/cds-common)\/hooks\/useMergeRefs$/); + if (m) { + return `${m[1]}/utils/mergeRefs`; + } + return null; +} + +function isCdsMergeRefsModuleSource( + j: API['jscodeshift'], + source: unknown, + moduleRe: RegExp, +): source is { value: string } { + return j.StringLiteral.check(source) && moduleRe.test(source.value); +} + +/** + * Unique `import … from '@/cds-common/utils/mergeRefs'` sources in the file (for consolidation). + */ +function collectCdsCommonMergeRefsUtilsModulePaths( + j: API['jscodeshift'], + root: ReturnType, + utilsModuleRe: RegExp, +): string[] { + const seen = new Set(); + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (j.StringLiteral.check(src) && utilsModuleRe.test(src.value)) { + seen.add(src.value); + } + }); + return [...seen]; +} + +/** + * `import { useMergeRefs }` / `as x` → `mergeRefs` / `as x` when from CDS mergeRefs modules. + */ +function renameUseMergeRefsInImportSpecifiers( + j: API['jscodeshift'], + root: ReturnType, + moduleRe: RegExp, +): boolean { + let changed = false; + root.find(j.ImportDeclaration).forEach((path) => { + const src = path.value.source; + if (!isCdsMergeRefsModuleSource(j, src, moduleRe)) { + return; + } + path.value.specifiers?.forEach((spec) => { + if (!j.ImportSpecifier.check(spec)) { + return; + } + if (j.Identifier.check(spec.imported) && spec.imported.name === 'useMergeRefs') { + spec.imported.name = 'mergeRefs'; + changed = true; + } + if (spec.local && j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + changed = true; + } + }); + }); + return changed; +} + +/** + * `export { useMergeRefs … } from '@coinbase/…/mergeRefs'` + */ +function renameUseMergeRefsInExportSpecifiersFromCds( + j: API['jscodeshift'], + root: ReturnType, + moduleRe: RegExp, +): boolean { + let changed = false; + root.find(j.ExportNamedDeclaration).forEach((path) => { + const src = path.value.source; + if (!src || !isCdsMergeRefsModuleSource(j, src, moduleRe)) { + return; + } + path.value.specifiers?.forEach((spec) => { + if (!j.ExportSpecifier.check(spec)) { + return; + } + if (j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + changed = true; + } + if (j.Identifier.check(spec.exported) && spec.exported.name === 'useMergeRefs') { + spec.exported.name = 'mergeRefs'; + changed = true; + } + }); + }); + return changed; +} + +function isObjectLiteralKey( + j: API['jscodeshift'], + parent: unknown, + idPath: ASTPath, +): boolean { + if (!parent || typeof parent !== 'object') { + return false; + } + + const node = parent as { type?: string; key?: unknown; computed?: boolean; shorthand?: boolean }; + if (node.type !== 'ObjectProperty' && node.type !== 'Property') { + return false; + } + if (node.key !== idPath.value || node.computed) { + return false; + } + if (node.shorthand) { + return false; + } + return true; +} + +/** + * Remaining `useMergeRefs` identifiers → `mergeRefs` (call sites, `export { useMergeRefs }` without from, etc.). + * Only runs when this file participated in a CDS mergeRefs module migration (avoids renaming unrelated bindings). + */ +function renameRemainingUseMergeRefsIdentifiers( + j: API['jscodeshift'], + root: ReturnType, + enabled: boolean, +): boolean { + if (!enabled) { + return false; + } + + let changed = false; + + root.find(j.Identifier, { name: 'useMergeRefs' }).forEach((path: ASTPath) => { + const parent = path.parent?.node; + + if (j.ImportSpecifier.check(parent)) { + return; + } + + if (j.ExportSpecifier.check(parent)) { + const spec = parent; + let touched = false; + if (j.Identifier.check(spec.local) && spec.local.name === 'useMergeRefs') { + spec.local.name = 'mergeRefs'; + touched = true; + } + if (j.Identifier.check(spec.exported) && spec.exported.name === 'useMergeRefs') { + spec.exported.name = 'mergeRefs'; + touched = true; + } + if (touched) { + changed = true; + } + return; + } + + if (isObjectLiteralKey(j, parent, path)) { + return; + } + + if (j.TSPropertySignature?.check(parent) && parent.key === path.value) { + return; + } + + if (j.MemberExpression.check(parent) && parent.property === path.value && !parent.computed) { + return; + } + + if (j.MethodDefinition.check(parent) && parent.key === path.value) { + return; + } + + path.value.name = 'mergeRefs'; + changed = true; + }); + + root.find(j.JSXIdentifier, { name: 'useMergeRefs' }).forEach((path) => { + path.value.name = 'mergeRefs'; + changed = true; + }); + + return changed; +} + +/** + * Merge multiple `import … from targetModule` into a single declaration; dedupe named imports. + */ +function consolidateImportsFromMergeRefsModule( + j: API['jscodeshift'], + root: ReturnType, + targetModule: string, +) { + const declarations = root + .find(j.ImportDeclaration, { source: { value: targetModule } }) + .filter((path) => (path.value.specifiers?.length ?? 0) > 0); + + if (declarations.length <= 1) { + return; + } + + const paths = declarations.paths(); + const first = paths[0]; + const mergedNamed = new Map(); + let defaultImport: string | null = null; + let namespaceImport: string | null = null; + + for (const path of paths) { + const specifiers = path.value.specifiers ?? []; + for (const spec of specifiers) { + if (j.ImportDefaultSpecifier.check(spec)) { + const local = spec.local?.name; + if (local) { + defaultImport = local; + } + } else if (j.ImportNamespaceSpecifier.check(spec)) { + const local = spec.local?.name; + if (local) { + namespaceImport = local; + } + } else if (j.ImportSpecifier.check(spec)) { + const imported = j.Identifier.check(spec.imported) + ? spec.imported.name + : String((spec.imported as { value?: string }).value); + const local = spec.local?.name ?? imported; + if (!mergedNamed.has(imported)) { + mergedNamed.set(imported, { imported, local }); + } + } + } + } + + const newSpecifiers: typeof first.value.specifiers = []; + + if (namespaceImport) { + newSpecifiers.push(j.importNamespaceSpecifier(j.identifier(namespaceImport))); + } else if (defaultImport) { + newSpecifiers.push(j.importDefaultSpecifier(j.identifier(defaultImport))); + } + + const sortedNamed = [...mergedNamed.values()].sort((a, b) => + a.imported.localeCompare(b.imported), + ); + for (const { imported, local } of sortedNamed) { + newSpecifiers.push( + j.importSpecifier(j.identifier(imported), local === imported ? null : j.identifier(local)), + ); + } + + first.value.specifiers = newSpecifiers; + + for (let i = 1; i < paths.length; i++) { + j(paths[i]).remove(); + } +} + +// eslint-disable-next-line no-restricted-exports -- jscodeshift requires default export +export default function transformer(file: FileInfo, api: API, options: Options) { + const j = api.jscodeshift; + const root = j(file.source); + + const packageScope = getPackageScopeFromOptions(options); + const mergeRefsModuleRe = buildCdsCommonMergeRefsModuleRe(packageScope); + const mergeRefsUtilsModuleRe = buildCdsCommonMergeRefsUtilsModuleRe(packageScope); + + let hasChanges = false; + let cdsMergeRefsMigration = false; + + root.find(j.ImportDeclaration).forEach((path) => { + if (path.value.source && j.StringLiteral.check(path.value.source)) { + const next = migrateMergeRefsModulePath(path.value.source.value, packageScope); + if (next) { + const prev = path.value.source.value; + path.value.source = j.stringLiteral(next); + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated import: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + } + }); + + root.find(j.ExportNamedDeclaration).forEach((path) => { + const src = path.value.source; + if (src && j.StringLiteral.check(src)) { + const next = migrateMergeRefsModulePath(src.value, packageScope); + if (next) { + const prev = src.value; + path.value.source = j.stringLiteral(next); + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated export from: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + } + }); + + root.find(j.StringLiteral).forEach((path) => { + const next = migrateMergeRefsModulePath(path.value.value, packageScope); + if (next) { + const prev = path.value.value; + path.value.value = next; + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success( + `Updated module path string: ${prev} → ${next}`, + file.path, + path.value.loc?.start.line, + ); + } + }); + + if (renameUseMergeRefsInImportSpecifiers(j, root, mergeRefsModuleRe)) { + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success(`Renamed import useMergeRefs → mergeRefs`, file.path); + } + + if (renameUseMergeRefsInExportSpecifiersFromCds(j, root, mergeRefsModuleRe)) { + hasChanges = true; + cdsMergeRefsMigration = true; + transformLogger.success(`Renamed re-export useMergeRefs → mergeRefs`, file.path); + } + + if (renameRemainingUseMergeRefsIdentifiers(j, root, cdsMergeRefsMigration)) { + hasChanges = true; + transformLogger.success(`Renamed remaining useMergeRefs → mergeRefs`, file.path); + } + + if (hasChanges) { + for (const targetModule of collectCdsCommonMergeRefsUtilsModulePaths( + j, + root, + mergeRefsUtilsModuleRe, + )) { + consolidateImportsFromMergeRefsModule(j, root, targetModule); + } + } + + if (!hasChanges) { + return null; + } + + return root.toSource(); +} diff --git a/packages/migrator/src/types.ts b/packages/migrator/src/types.ts new file mode 100644 index 0000000000..75cb79ede5 --- /dev/null +++ b/packages/migrator/src/types.ts @@ -0,0 +1,55 @@ +/** + * Types for CDS migration tools + */ + +export type Transform = { + /** + * Name of the transform + */ + name: string; + /** + * Description of what the transform does + */ + description: string; + /** + * Path to the transform file (relative to transforms directory) + */ + file: string; + /** + * File extensions to process (comma-separated) + * @default "tsx,ts,jsx,js" + */ + extensions?: string; +}; + +/** + * Preset manifest structure + */ +export type PresetManifest = { + /** + * Preset identifier (e.g., "v8-to-v9") + */ + preset: string; + /** + * Overall description of the migration + */ + description: string; + /** + * List of transforms in this preset + */ + transforms: Transform[]; +}; + +/** + * Selection for what to migrate + */ +export type MigrationSelection = { + /** + * If true, migrate everything + */ + all?: boolean; + /** + * Specific transforms to migrate (by name) + */ + transforms?: string[]; +}; diff --git a/packages/migrator/src/utils/config-loader.ts b/packages/migrator/src/utils/config-loader.ts new file mode 100644 index 0000000000..9510c627f4 --- /dev/null +++ b/packages/migrator/src/utils/config-loader.ts @@ -0,0 +1,74 @@ +/** + * Configuration loader utilities + */ + +import fs from 'fs'; +import path from 'path'; + +import type { MigrationSelection, PresetManifest, Transform } from '../types'; + +/** + * Load preset manifest from manifest.json + */ +export function loadMigrationManifest(presetDir: string): PresetManifest { + const manifestPath = path.join(presetDir, 'manifest.json'); + + if (!fs.existsSync(manifestPath)) { + throw new Error(`Preset manifest not found: ${manifestPath}`); + } + + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); + return JSON.parse(manifestContent) as PresetManifest; +} + +/** + * Get all transforms from manifest based on selection + */ +export function getSelectedTransforms( + manifest: PresetManifest, + selection: MigrationSelection, +): Transform[] { + // If migrate all, return all transforms + if (selection.all) { + return manifest.transforms; + } + + // Collect specific transforms by name + if (selection.transforms && selection.transforms.length > 0) { + const selectedTransforms: Transform[] = []; + for (const transformName of selection.transforms) { + const transform = manifest.transforms.find((t) => t.name === transformName); + if (transform) { + selectedTransforms.push(transform); + } + } + return selectedTransforms; + } + + return []; +} + +/** + * Build a summary of what will be migrated + */ +export function buildMigrationSummary( + manifest: PresetManifest, + selection: MigrationSelection, +): string { + const transforms = getSelectedTransforms(manifest, selection); + + let summary = '\nMigration Plan:\n'; + summary += '================\n\n'; + + if (transforms.length === 0) { + summary += 'No transforms selected.\n'; + } else { + for (const transform of transforms) { + summary += ` • ${transform.name} - ${transform.description}\n`; + } + } + + summary += `\nTotal transforms: ${transforms.length}\n`; + + return summary; +} diff --git a/packages/migrator/src/utils/constants.ts b/packages/migrator/src/utils/constants.ts new file mode 100644 index 0000000000..9a3ecd411e --- /dev/null +++ b/packages/migrator/src/utils/constants.ts @@ -0,0 +1,6 @@ +/** + * Constants used across migration utilities + */ + +export const TODO_PREFIX = 'TODO(cds-migration)'; +export const LOG_FILE_NAME = 'migration.log'; diff --git a/packages/migrator/src/utils/index.ts b/packages/migrator/src/utils/index.ts new file mode 100644 index 0000000000..ee8ca90714 --- /dev/null +++ b/packages/migrator/src/utils/index.ts @@ -0,0 +1,8 @@ +/** + * Shared utility functions for CDS migrations + */ + +export * from './config-loader'; +export * from './constants'; +export * from './logger'; +export * from './migration-history'; diff --git a/packages/migrator/src/utils/logger.ts b/packages/migrator/src/utils/logger.ts new file mode 100644 index 0000000000..15dc5d0746 --- /dev/null +++ b/packages/migrator/src/utils/logger.ts @@ -0,0 +1,201 @@ +/** + * Logging utilities for tracking migration progress and issues + */ + +import fs from 'fs'; +import path from 'path'; + +import { LOG_FILE_NAME } from './constants'; + +export const LogLevel = { + INFO: 'INFO', + WARN: 'WARN', + ERROR: 'ERROR', + SUCCESS: 'SUCCESS', + TODO: 'TODO', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +export type LogEntry = { + timestamp: string; + level: LogLevel; + file?: string; + line?: number; + message: string; + details?: string; +}; + +/** + * Migration logger for tracking changes and issues + */ +export class MigrationLogger { + private logPath: string; + private entries: LogEntry[] = []; + + constructor(outputDir: string = process.cwd()) { + this.logPath = path.join(outputDir, LOG_FILE_NAME); + this.initializeLog(); + } + + private initializeLog(): void { + const header = `CDS Migration Log +Generated: ${new Date().toISOString()} +========================================\n\n`; + + fs.writeFileSync(this.logPath, header, 'utf-8'); + } + + /** + * Add a log entry + */ + private addEntry(entry: LogEntry): void { + this.entries.push(entry); + this.writeEntry(entry); + } + + private writeEntry(entry: LogEntry): void { + const location = entry.file ? ` [${entry.file}${entry.line ? `:${entry.line}` : ''}]` : ''; + const details = entry.details ? `\n ${entry.details}` : ''; + + const line = `[${entry.timestamp}] ${entry.level}${location}: ${entry.message}${details}\n`; + fs.appendFileSync(this.logPath, line, 'utf-8'); + } + + /** + * Log an informational message + */ + info(message: string, file?: string, line?: number): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.INFO, + file, + line, + message, + }); + } + + /** + * Log a warning + */ + warn(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.WARN, + file, + line, + message, + details, + }); + } + + /** + * Log an error + */ + error(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.ERROR, + file, + line, + message, + details, + }); + } + + /** + * Log a successful transformation + */ + success(message: string, file?: string, line?: number): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.SUCCESS, + file, + line, + message, + }); + } + + /** + * Log a TODO item (manual migration required) + */ + todo(message: string, file?: string, line?: number, details?: string): void { + this.addEntry({ + timestamp: new Date().toISOString(), + level: LogLevel.TODO, + file, + line, + message, + details, + }); + } + + /** + * Generate a summary of the migration + */ + writeSummary(): void { + const summary = ` +======================================== +Migration Summary +======================================== + +Total Entries: ${this.entries.length} +- INFO: ${this.entries.filter((e) => e.level === LogLevel.INFO).length} +- SUCCESS: ${this.entries.filter((e) => e.level === LogLevel.SUCCESS).length} +- WARN: ${this.entries.filter((e) => e.level === LogLevel.WARN).length} +- ERROR: ${this.entries.filter((e) => e.level === LogLevel.ERROR).length} +- TODO: ${this.entries.filter((e) => e.level === LogLevel.TODO).length} + +`; + + fs.appendFileSync(this.logPath, summary, 'utf-8'); + + // List all TODO items + const todos = this.entries.filter((e) => e.level === LogLevel.TODO); + if (todos.length > 0) { + const todoList = ` +Manual Migration Required (${todos.length} items): +${todos.map((t) => ` - ${t.file || 'Unknown file'}: ${t.message}`).join('\n')} + +`; + fs.appendFileSync(this.logPath, todoList, 'utf-8'); + } + + console.log(`\n📝 Migration log written to: ${this.logPath}`); + } + + /** + * Get the log file path + */ + getLogPath(): string { + return this.logPath; + } + + /** + * Get all log entries + */ + getEntries(): LogEntry[] { + return [...this.entries]; + } + + /** + * Get entries by level + */ + getEntriesByLevel(level: LogLevel): LogEntry[] { + return this.entries.filter((e) => e.level === level); + } +} + +/** + * Create a global logger instance + */ +let globalLogger: MigrationLogger | null = null; + +export function createLogger(outputDir?: string): MigrationLogger { + globalLogger = new MigrationLogger(outputDir); + return globalLogger; +} + +export function getLogger(): MigrationLogger | null { + return globalLogger; +} diff --git a/packages/migrator/src/utils/migration-history.ts b/packages/migrator/src/utils/migration-history.ts new file mode 100644 index 0000000000..67c9c2be98 --- /dev/null +++ b/packages/migrator/src/utils/migration-history.ts @@ -0,0 +1,171 @@ +/** + * Migration history tracking + * + * Keeps track of which transforms have been run on a given path + */ + +import fs from 'fs'; +import path from 'path'; + +import type { Transform } from '../types'; + +const HISTORY_FILE_NAME = '.cds-migration-history.json'; + +export type MigrationHistoryEntry = { + /** + * Transform file path (e.g., "button-variant" or "components/button-variant") + */ + transform: string; + /** + * When the transform was run + */ + timestamp: string; +}; + +export type MigrationHistory = { + /** + * List of all transform runs + */ + entries: MigrationHistoryEntry[]; + /** + * Last update timestamp + */ + lastUpdated: string; +}; + +/** + * Get the history file path for a target directory + */ +function getHistoryFilePath(targetPath: string): string { + // If targetPath is a file, use its directory + const stats = fs.existsSync(targetPath) ? fs.statSync(targetPath) : null; + const dir = stats?.isDirectory() ? targetPath : path.dirname(targetPath); + + return path.join(dir, HISTORY_FILE_NAME); +} + +/** + * Load migration history for a target path + */ +export function loadMigrationHistory(targetPath: string): MigrationHistory | null { + const historyPath = getHistoryFilePath(targetPath); + + if (!fs.existsSync(historyPath)) { + return null; + } + + try { + const content = fs.readFileSync(historyPath, 'utf-8'); + return JSON.parse(content) as MigrationHistory; + } catch (error) { + console.warn(`Warning: Could not load migration history from ${historyPath}`); + return null; + } +} + +/** + * Save migration history for a target path + */ +export function saveMigrationHistory(targetPath: string, history: MigrationHistory): void { + const historyPath = getHistoryFilePath(targetPath); + + try { + fs.writeFileSync(historyPath, JSON.stringify(history, null, 2), 'utf-8'); + } catch (error) { + console.warn(`Warning: Could not save migration history to ${historyPath}`); + } +} + +/** + * Record a transform run in the history + */ +export function recordTransformRun( + targetPath: string, + transformPath: string, + dryRun: boolean, +): void { + // Don't record history for dry runs (they don't modify files) + if (dryRun) { + return; + } + + let history = loadMigrationHistory(targetPath); + + if (!history) { + history = { + entries: [], + lastUpdated: new Date().toISOString(), + }; + } + + // Add new entry + history.entries.push({ + transform: transformPath, + timestamp: new Date().toISOString(), + }); + + history.lastUpdated = new Date().toISOString(); + saveMigrationHistory(targetPath, history); +} + +/** + * Check if a transform has already been run + */ +export function hasTransformBeenRun(targetPath: string, transformPath: string): boolean { + const history = loadMigrationHistory(targetPath); + + if (!history) { + return false; + } + + return history.entries.some((entry) => entry.transform === transformPath); +} + +/** + * Get list of transforms that have already been run + */ +export function getAlreadyRunTransforms(targetPath: string, transformPaths: string[]): string[] { + const history = loadMigrationHistory(targetPath); + + if (!history) { + return []; + } + + const runTransforms = new Set(history.entries.map((entry) => entry.transform)); + + return transformPaths.filter((path) => runTransforms.has(path)); +} + +/** + * Build a summary of migration history + */ +export function buildHistorySummary(targetPath: string): string { + const history = loadMigrationHistory(targetPath); + + if (!history || history.entries.length === 0) { + return 'No migration history found for this path.'; + } + + let summary = '\n📜 Migration History\n'; + summary += '==================\n\n'; + + for (const entry of history.entries) { + const date = new Date(entry.timestamp).toLocaleDateString(); + summary += ` • ${entry.transform} (${date})\n`; + } + + summary += `\nLast updated: ${new Date(history.lastUpdated).toLocaleString()}\n`; + + return summary; +} + +/** + * Clear migration history for a target path + */ +export function clearMigrationHistory(targetPath: string): void { + const historyPath = getHistoryFilePath(targetPath); + + if (fs.existsSync(historyPath)) { + fs.unlinkSync(historyPath); + } +} diff --git a/packages/migrator/src/utils/package-scope.ts b/packages/migrator/src/utils/package-scope.ts new file mode 100644 index 0000000000..26d40df042 --- /dev/null +++ b/packages/migrator/src/utils/package-scope.ts @@ -0,0 +1,29 @@ +import type { Options } from 'jscodeshift'; + +export function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Normalize user input: `coinbase` or `@coinbase` → `@coinbase` + */ +export function normalizePackageScope(scope: string): string { + const t = scope.trim(); + if (!t) { + return ''; + } + return t.startsWith('@') ? t : `@${t}`; +} + +/** + * Reads `--packageScope` from jscodeshift options (forwarded by cds-migrate CLI). + * When set, scope-targeted transforms only rewrite that npm scope. + */ +export function getPackageScopeFromOptions(options: Options): string | undefined { + const raw = (options as Record).packageScope; + if (typeof raw !== 'string' || raw.trim() === '') { + return undefined; + } + const n = normalizePackageScope(raw); + return n || undefined; +} diff --git a/packages/migrator/src/utils/transform-utils.ts b/packages/migrator/src/utils/transform-utils.ts new file mode 100644 index 0000000000..cb0f07934c --- /dev/null +++ b/packages/migrator/src/utils/transform-utils.ts @@ -0,0 +1,54 @@ +/** + * Utilities for transforms (CommonJS-compatible) + * + * This module re-exports utilities in a way that jscodeshift transforms can use. + * It avoids complex dependencies and ES module issues. + */ + +// Re-export just the essential functions transforms need +export { TODO_PREFIX } from './constants'; + +/** + * Simple logger for transforms + * Note: This is a simplified version that just console.logs since + * the full logger isn't available in jscodeshift workers + */ +export const transformLogger = { + success: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.log(`✓ ${message}${location}`); + }, + warn: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.warn(`⚠ ${message}${location}`); + }, + info: (message: string, file?: string, line?: number) => { + const location = file ? ` [${file}${line ? `:${line}` : ''}]` : ''; + console.log(`ℹ ${message}${location}`); + }, +}; + +/** + * Add TODO comment to JSX attribute + */ +export function addTodoComment(j: any, path: any, message: string, context?: string): void { + const comment = j.commentLine(` TODO(cds-migration): ${message}`, true, false); + const comments = [comment]; + + if (context) { + const contextComment = j.commentLine(` ${context}`, true, false); + comments.push(contextComment); + } + + path.value.comments = [...comments, ...(path.value.comments || [])]; +} + +/** + * Check if node has migration TODO + */ +export function hasMigrationTodo(path: any): boolean { + const comments = path.value.comments || []; + return comments.some( + (comment: any) => comment.value && comment.value.includes('TODO(cds-migration)'), + ); +} diff --git a/packages/ui-mobile-playground/tsconfig.build.json b/packages/migrator/tsconfig.build.json similarity index 61% rename from packages/ui-mobile-playground/tsconfig.build.json rename to packages/migrator/tsconfig.build.json index 38a8f20ddd..a227a333b1 100644 --- a/packages/ui-mobile-playground/tsconfig.build.json +++ b/packages/migrator/tsconfig.build.json @@ -15,15 +15,5 @@ "**/*.test.*", "**/*.spec.*" ], - "references": [ - { - "path": "../../packages/common" - }, - { - "path": "../../packages/mobile" - }, - { - "path": "../../packages/mobile-visualization" - } - ] + "references": [] } diff --git a/packages/migrator/tsconfig.json b/packages/migrator/tsconfig.json new file mode 100644 index 0000000000..dcb63b06d2 --- /dev/null +++ b/packages/migrator/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.project.json", + "compilerOptions": { + "declarationDir": "dts", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/__testfixtures__/**" + ], + "references": [] +} diff --git a/packages/mobile-visreg/README.md b/packages/mobile-visreg/README.md index 1c26475990..f0af9635ef 100644 --- a/packages/mobile-visreg/README.md +++ b/packages/mobile-visreg/README.md @@ -42,12 +42,12 @@ packages/mobile-visreg/ All targets are run from the repo root via `yarn nx run mobile-visreg:`. -| Target | Command | Description | -| --------- | ----------------------------------- | ------------------------------------------------------ | -| `setup` | `yarn nx run mobile-visreg:setup` | Install Maestro CLI (one-time) | -| `ios` | `yarn nx run mobile-visreg:ios` | Capture screenshots from the CDS mobile app on iOS | -| `android` | `yarn nx run mobile-visreg:android` | Capture screenshots from the CDS mobile app on Android | -| `upload` | `yarn nx run mobile-visreg:upload` | Upload screenshots to BrowserStack App Percy | +| Target | Command | Description | +| --------- | ----------------------------------- | --------------------------------------------------------- | +| `setup` | `yarn nx run mobile-visreg:setup` | Install Maestro CLI (one-time) | +| `ios` | `yarn nx run mobile-visreg:ios` | Capture screenshots from the CDS test-expo app on iOS | +| `android` | `yarn nx run mobile-visreg:android` | Capture screenshots from the CDS test-expo app on Android | +| `upload` | `yarn nx run mobile-visreg:upload` | Upload screenshots to BrowserStack App Percy | ## Prerequisites @@ -83,8 +83,8 @@ yarn nx run mobile-visreg:setup > **Important**: Use the **release** build, not debug. Debug builds use the Expo Dev Client shell which intercepts deep links before React Navigation can handle them, preventing navigation to component routes. ```bash -yarn nx run mobile-app:build:ios-release -yarn nx run mobile-app:launch:ios-release +yarn nx run test-expo:patch-bundle-ios +xcrun simctl install booted apps/test-expo/prebuilds/ios-release/testexpo.app ``` ### 4. Capture screenshots diff --git a/packages/mobile-visreg/project.json b/packages/mobile-visreg/project.json index 5deab1d16b..c3699a11cb 100644 --- a/packages/mobile-visreg/project.json +++ b/packages/mobile-visreg/project.json @@ -11,13 +11,13 @@ } }, "ios": { - "command": "node ./src/run.mjs --appId com.ui-systems.ios-release-hermes --scheme cds --platform ios --platform-suffix _ios", + "command": "node ./src/run.mjs --appId com.anonymous.test-expo --scheme testexpo --platform ios --platform-suffix _ios", "options": { "cwd": "packages/mobile-visreg" } }, "android": { - "command": "node ./src/run.mjs --appId com.ui_systems.android_release_hermes --scheme cds --platform android --platform-suffix _android", + "command": "node ./src/run.mjs --appId com.anonymous.testexpo --scheme testexpo --platform android --platform-suffix _android", "options": { "cwd": "packages/mobile-visreg" } diff --git a/packages/mobile-visualization/babel.config.cjs b/packages/mobile-visualization/babel.config.cjs index 4d232b0f21..73280a634b 100644 --- a/packages/mobile-visualization/babel.config.cjs +++ b/packages/mobile-visualization/babel.config.cjs @@ -8,9 +8,12 @@ module.exports = { ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false, loose: true }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', - ...(isTestEnv || isDetoxEnv ? ['module:metro-react-native-babel-preset'] : []), + // Use babel-preset-expo for test/detox environments. This preset wraps @react-native/babel-preset + // which includes babel-plugin-syntax-hermes-parser for parsing Flow files with 'as' syntax. + // See: https://docs.expo.dev/versions/latest/config/babel/ + ...(isTestEnv || isDetoxEnv ? ['babel-preset-expo'] : []), ], - plugins: isTestEnv || isDetoxEnv ? ['react-native-reanimated/plugin'] : [], + plugins: isTestEnv || isDetoxEnv ? ['react-native-worklets/plugin'] : [], ignore: isTestEnv || isDetoxEnv ? [] diff --git a/packages/mobile-visualization/jest.config.js b/packages/mobile-visualization/jest.config.js index 3840f7f31f..f27d596dff 100644 --- a/packages/mobile-visualization/jest.config.js +++ b/packages/mobile-visualization/jest.config.js @@ -10,13 +10,19 @@ const native = [ const esModules = ['@coinbase', ...native, ...d3]; +/** @type {import('jest').Config} */ export default { coveragePathIgnorePatterns: ['/src/illustrations/images', '.stories.tsx', '__stories__'], coverageReporters: ['json', 'text-summary', 'text', 'json-summary'], displayName: 'mobile-visualization', + passWithNoTests: true, preset: '../../jest.preset-mobile.js', // https://docs.swmansion.com/react-native-gesture-handler/docs/guides/testing - setupFiles: ['/../../node_modules/react-native-gesture-handler/jestSetup.js'], + // https://docs.swmansion.com/react-native-worklets/docs/guides/testing/ + setupFiles: [ + '/../../node_modules/react-native-gesture-handler/jestSetup.js', + '/jest/setupWorkletsMock.js', + ], testMatch: ['**//**/*.test.(ts|tsx)'], setupFilesAfterEnv: ['/jest/setup.js'], // https://github.com/facebook/jest/blob/main/docs/Configuration.md#faketimers-object diff --git a/packages/mobile-visualization/jest/setup.js b/packages/mobile-visualization/jest/setup.js index e46ec43c3e..c3855fd8f2 100644 --- a/packages/mobile-visualization/jest/setup.js +++ b/packages/mobile-visualization/jest/setup.js @@ -1,6 +1,20 @@ -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock'); - Reanimated.makeMutable = Reanimated.useSharedValue; +// https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/ +const { + setUpTests, + configureReanimatedLogger, + ReanimatedLogLevel, +} = require('react-native-reanimated'); - return Reanimated; +/* + React Reanimated 4.x setup: +*/ + +// Disable strict mode to prevent warnings about writing to shared values during render +// This is needed because some components (e.g., TabsActiveIndicator) use patterns that +// trigger warnings in reanimated 4.x strict mode but still work correctly +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, }); + +setUpTests(); diff --git a/packages/mobile-visualization/jest/setupWorkletsMock.js b/packages/mobile-visualization/jest/setupWorkletsMock.js new file mode 100644 index 0000000000..681bc6223c --- /dev/null +++ b/packages/mobile-visualization/jest/setupWorkletsMock.js @@ -0,0 +1,3 @@ +// Mock react-native-worklets before any reanimated imports +// The built-in mock at lib/module/mock is not available until later versions: 0.7.X +jest.mock('react-native-worklets', () => require('./workletsMock')); diff --git a/packages/mobile-visualization/jest/workletsMock.js b/packages/mobile-visualization/jest/workletsMock.js new file mode 100644 index 0000000000..791d413330 --- /dev/null +++ b/packages/mobile-visualization/jest/workletsMock.js @@ -0,0 +1,111 @@ +/** + * Mock for react-native-worklets 0.5.2 + * The built-in mock at lib/module/mock is not available until later versions: 0.7.X, + * Following CMR's version recommendation on versions we are staying with 0.5.2 and reanimated 4.1.1 for now + * This mock is based on the official mock from: + * https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-worklets/src/mock.ts + */ + +'use strict'; + +const NOOP = () => {}; +const NOOP_FACTORY = () => NOOP; +const IDENTITY = (value) => value; +const IMMEDIATE_CALLBACK_INVOCATION = (callback) => callback(); + +const RuntimeKind = { + ReactNative: 'RN', + UI: 'UI', + Worklet: 'Worklet', +}; + +// Mocked requestAnimationFrame that uses setTimeout and passes timestamp +// This fixes Jest's React Native setup which doesn't pass timestamps to callbacks +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/jest/setup.js#L28 +const mockedRequestAnimationFrame = (callback) => { + return setTimeout(() => callback(performance.now()), 0); +}; + +// Set up global properties that reanimated expects from the native runtime +global._WORKLET = false; +global.__RUNTIME_KIND = RuntimeKind.ReactNative; +global._log = console.log; +global._getAnimationTimestamp = () => performance.now(); +global.__flushAnimationFrame = NOOP; +global.requestAnimationFrame = mockedRequestAnimationFrame; + +const WorkletAPI = { + isShareableRef: () => true, + makeShareable: IDENTITY, + makeShareableCloneOnUIRecursive: IDENTITY, + makeShareableCloneRecursive: IDENTITY, + shareableMappingCache: new Map(), + + getStaticFeatureFlag: () => false, + setDynamicFeatureFlag: NOOP, + + isSynchronizable: () => false, + + getRuntimeKind: () => RuntimeKind.ReactNative, + RuntimeKind, + + createWorkletRuntime: NOOP_FACTORY, + runOnRuntime: IDENTITY, + runOnRuntimeAsync(workletRuntime, worklet, ...args) { + return WorkletAPI.runOnUIAsync(worklet, ...args); + }, + scheduleOnRuntime: IMMEDIATE_CALLBACK_INVOCATION, + + createSerializable: IDENTITY, + isSerializableRef: IDENTITY, + serializableMappingCache: new Map(), + + createSynchronizable: IDENTITY, + + callMicrotasks: NOOP, + executeOnUIRuntimeSync: IDENTITY, + + runOnJS(fun) { + return (...args) => queueMicrotask(args.length ? () => fun(...args) : fun); + }, + + runOnUI(worklet) { + return (...args) => { + // In Jest environment we schedule work via mockedRequestAnimationFrame + // to ensure it runs when timers are advanced + mockedRequestAnimationFrame(() => { + worklet(...args); + }); + }; + }, + + runOnUIAsync(worklet, ...args) { + return new Promise((resolve) => { + mockedRequestAnimationFrame(() => { + const result = worklet(...args); + resolve(result); + }); + }); + }, + + runOnUISync: IMMEDIATE_CALLBACK_INVOCATION, + + scheduleOnRN(fun, ...args) { + WorkletAPI.runOnJS(fun)(...args); + }, + + scheduleOnUI(worklet, ...args) { + WorkletAPI.runOnUI(worklet)(...args); + }, + + unstable_eventLoopTask: NOOP_FACTORY, + + isWorkletFunction: () => false, + + WorkletsModule: {}, +}; + +module.exports = { + __esModule: true, + ...WorkletAPI, +}; diff --git a/packages/mobile-visualization/package.json b/packages/mobile-visualization/package.json index ba9c9c2c93..f56e8d0a61 100644 --- a/packages/mobile-visualization/package.json +++ b/packages/mobile-visualization/package.json @@ -2,6 +2,7 @@ "name": "@coinbase/cds-mobile-visualization", "version": "3.4.0", "description": "Coinbase Design System - Mobile Visualization Native", + "deprecated": "Use @coinbase/cds-mobile/visualizations/chart and @coinbase/cds-mobile/visualizations/sparkline instead.", "repository": { "type": "git", "url": "git@github.com:coinbase/cds.git", @@ -40,13 +41,14 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-utils": "workspace:^", - "@shopify/react-native-skia": "^1.12.4 || ^2.0.0", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-gesture-handler": "^2.16.2", - "react-native-reanimated": "^3.14.0", - "react-native-safe-area-context": "^4.10.5", - "react-native-svg": "^14.1.0" + "@shopify/react-native-skia": "2.2.12", + "react": "~19.1.2", + "react-native": "~0.81.5", + "react-native-gesture-handler": "2.28.0", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "dependencies": { "d3-interpolate-path": "^2.3.0", @@ -55,18 +57,22 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-common": "workspace:^", "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-utils": "workspace:^", - "@shopify/react-native-skia": "1.12.4", - "@types/react": "^18.3.12", - "react-native-gesture-handler": "2.16.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-svg": "14.1.0", - "react-test-renderer": "^18.3.1" + "@shopify/react-native-skia": "2.2.12", + "@testing-library/react-native": "^13.3.3", + "@types/react": "19.1.2", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-gesture-handler": "2.28.0", + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2", + "react-test-renderer": "19.1.2" } } diff --git a/packages/mobile-visualization/project.json b/packages/mobile-visualization/project.json index e2f9c11d8f..86a69bb526 100644 --- a/packages/mobile-visualization/project.json +++ b/packages/mobile-visualization/project.json @@ -23,9 +23,10 @@ "executor": "@nx/eslint:lint" }, "test": { - "executor": "@nx/jest:jest", + "executor": "nx:run-commands", "options": { - "jestConfig": "{projectRoot}/jest.config.js" + "command": "jest --runInBand", + "cwd": "{projectRoot}" } }, "typecheck": { diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 40620f86c0..4c38f98e44 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -1,17 +1 @@ -// codegen:start {preset: barrel, include: [./*.tsx, ./*/index.ts]} -export * from './area'; -export * from './axis'; -export * from './bar'; -export * from './CartesianChart'; -export * from './ChartContextBridge'; -export * from './ChartProvider'; -export * from './gradient'; -export * from './legend'; -export * from './line'; -export * from './Path'; -export * from './PeriodSelector'; -export * from './point'; -export * from './scrubber'; -export * from './text'; -export * from './utils'; -// codegen:end +export * from '@coinbase/cds-mobile/visualizations/chart'; diff --git a/packages/mobile-visualization/src/index.ts b/packages/mobile-visualization/src/index.ts index 21bfe928b2..c8fa7f668d 100644 --- a/packages/mobile-visualization/src/index.ts +++ b/packages/mobile-visualization/src/index.ts @@ -1,4 +1,2 @@ -// codegen:start {preset: barrel, include: ./*/index.ts} export * from './chart'; export * from './sparkline'; -// codegen:end diff --git a/packages/mobile-visualization/src/sparkline/index.ts b/packages/mobile-visualization/src/sparkline/index.ts index 5eab72d409..9f926803e3 100644 --- a/packages/mobile-visualization/src/sparkline/index.ts +++ b/packages/mobile-visualization/src/sparkline/index.ts @@ -1,5 +1 @@ -export * from './Sparkline'; -export * from './sparkline-interactive/SparklineInteractive'; -export * from './sparkline-interactive-header/SparklineInteractiveHeader'; -export * from './SparklineArea'; -export * from './SparklineGradient'; +export * from '@coinbase/cds-mobile/visualizations/sparkline'; diff --git a/packages/mobile/README.md b/packages/mobile/README.md index db91730b29..e71594d679 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -6,10 +6,9 @@ Components for React Native. Add the relative path to the CDS icon font to your react-native.config.js. If your project lives in the monorepo this lives in the root `react-native.config.js` file. There is an example for CDS playground in there. -In this monorepo, run the `mobile-app` targets from the repo root: +In this monorepo, run the `test-expo` targets from the repo root: -- `yarn nx run mobile-app:go` for Expo Go development -- `yarn nx run mobile-app:launch:ios-debug` or `yarn nx run mobile-app:launch:android-debug` for local debug launch +- `yarn nx run test-expo:launch:ios-debug` or `yarn nx run test-expo:launch:android-debug` for local debug launch ### Outside monorepo diff --git a/packages/mobile/babel.config.cjs b/packages/mobile/babel.config.cjs index 4d232b0f21..73280a634b 100644 --- a/packages/mobile/babel.config.cjs +++ b/packages/mobile/babel.config.cjs @@ -8,9 +8,12 @@ module.exports = { ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false, loose: true }], ['@babel/preset-react', { runtime: 'automatic' }], '@babel/preset-typescript', - ...(isTestEnv || isDetoxEnv ? ['module:metro-react-native-babel-preset'] : []), + // Use babel-preset-expo for test/detox environments. This preset wraps @react-native/babel-preset + // which includes babel-plugin-syntax-hermes-parser for parsing Flow files with 'as' syntax. + // See: https://docs.expo.dev/versions/latest/config/babel/ + ...(isTestEnv || isDetoxEnv ? ['babel-preset-expo'] : []), ], - plugins: isTestEnv || isDetoxEnv ? ['react-native-reanimated/plugin'] : [], + plugins: isTestEnv || isDetoxEnv ? ['react-native-worklets/plugin'] : [], ignore: isTestEnv || isDetoxEnv ? [] diff --git a/packages/mobile/jest.config.js b/packages/mobile/jest.config.js index 9218086799..713b8e70da 100644 --- a/packages/mobile/jest.config.js +++ b/packages/mobile/jest.config.js @@ -27,8 +27,10 @@ const config = { ], coverageReporters: ['json', 'text-summary', 'text', 'json-summary'], // https://docs.swmansion.com/react-native-gesture-handler/docs/guides/testing + // https://docs.swmansion.com/react-native-worklets/docs/guides/testing/ setupFiles: [ '/../../node_modules/react-native-gesture-handler/jestSetup.js', + '/jest/setupWorkletsMock.js', '/jest/jestThrowOnErrorAndWarning.js', ], setupFilesAfterEnv: ['/jest/setup.js'], diff --git a/packages/mobile/jest/accessibility/README.md b/packages/mobile/jest/accessibility/README.md new file mode 100644 index 0000000000..61b39a05c1 --- /dev/null +++ b/packages/mobile/jest/accessibility/README.md @@ -0,0 +1,102 @@ +# Custom Accessibility Engine + +This folder contains a custom accessibility testing engine for React Native components, replacing the unmaintained [`react-native-accessibility-engine`](https://github.com/aryella-lacerda/react-native-accessibility-engine) library. + +## Background + +The original `react-native-accessibility-engine` library provided a `toBeAccessible()` Jest matcher for testing React Native component accessibility. However, the library became incompatible with React 19 due to: + +1. **Deprecated dependency**: The library depends on `react-test-renderer`, which is deprecated in React 19 +2. **Initialization issue**: The library calls `react-test-renderer.create()` at module load time without wrapping it in `act()`, causing test failures in React 19's stricter environment +3. **Unmaintained**: The library has not been updated to address these compatibility issues + +Rather than waiting for an upstream fix, we implemented our own accessibility engine that: + +- Works directly with `@testing-library/react-native` test instances +- Derives types from RNTL exports instead of importing from `react-test-renderer` +- Maintains the same API (`toBeAccessible()` matcher) +- Implements all 10 original accessibility rules + +## Implementation + +The engine checks components against these accessibility rules: + +| Rule ID | Description | +| ------------------------------- | ------------------------------------------------------------ | +| `pressable-role-required` | Pressable components must have an accessibility role | +| `pressable-accessible-required` | Pressable components must not have `accessible={false}` | +| `pressable-label-required` | Pressable components must have a label or text content | +| `disabled-state-required` | Disableable components must expose disabled state | +| `checked-state-required` | Checkbox components must have a checked state | +| `adjustable-role-required` | Slider components must have `accessibilityRole="adjustable"` | +| `adjustable-value-required` | Slider components must have min/max/now values | +| `link-role-required` | Clickable text must have `accessibilityRole="link"` | +| `link-role-misused` | Non-clickable text should not have link role | +| `no-empty-text` | Text components must have content | + +## Intentional Difference from Original Library + +Our implementation includes one intentional improvement over the original library: + +**Extended allowed roles for pressable components** + +The original library's `pressable-role-required` rule only allowed these roles: + +``` +['button', 'link', 'imagebutton', 'radio', 'tab'] +``` + +Our implementation adds `checkbox` and `switch`: + +``` +['button', 'link', 'imagebutton', 'radio', 'tab', 'checkbox', 'switch'] +``` + +**Why this change?** + +The original library's exclusion of `checkbox` appears to be an oversight. The library includes a separate `checked-state-required` rule that specifically targets components with `accessibilityRole="checkbox"`, implying that checkbox is a valid role for pressables. Without `checkbox` in the allowed roles list, a properly implemented checkbox would fail the `pressable-role-required` rule before the `checked-state-required` rule could validate its checked state. + +Similarly, `switch` is a valid React Native accessibility role that semantically represents a toggle control and should be allowed on pressable components. + +## File Structure + +``` +accessibility/ +├── README.md # This file +├── types.ts # Type definitions derived from RNTL +├── helpers.ts # Component type checking utilities +├── rules.ts # Accessibility rule definitions +├── engine.ts # Core accessibility checking logic +├── matchers.ts # Jest matcher implementation +├── index.ts # Module exports and Jest setup +└── __tests__/ + └── rules.test.tsx # Rule validation tests +``` + +## Usage + +The matcher is automatically registered when Jest loads. Use it in tests like: + +```tsx +import { render, screen } from '@testing-library/react-native'; + +it('is accessible', () => { + render(); + expect(screen.getByTestId('test')).toBeAccessible(); +}); +``` + +### Options + +```tsx +// Check only specific rules +expect(element).toBeAccessible({ + rules: ['pressable-role-required', 'pressable-label-required'], +}); + +// Filter violations before assertion +expect(element).toBeAccessible({ + customViolationHandler: (violations) => + violations.filter((v) => !v.problem.includes('some expected issue')), +}); +``` diff --git a/packages/mobile/jest/accessibility/__tests__/rules.test.tsx b/packages/mobile/jest/accessibility/__tests__/rules.test.tsx new file mode 100644 index 0000000000..896aa33e73 --- /dev/null +++ b/packages/mobile/jest/accessibility/__tests__/rules.test.tsx @@ -0,0 +1,318 @@ +// we need to access the custom type definitions for the accessibility matcher +// eslint-disable-next-line @typescript-eslint/triple-slash-reference +/// +import React from 'react'; +import { Pressable, Text, TouchableOpacity, View } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +import { checkAccessibility } from '../engine'; + +// Note: When using checkAccessibility, we need to pass a component from the React tree, +// not just the host component. RNTL's screen.getByTestId returns the host component, +// so for violation checking we often need to use a container wrapper. + +describe('Accessibility Rules', () => { + describe('pressable-role-required', () => { + it('fails when pressable has no role', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.problem.includes("user hasn't been informed"))).toBe(true); + }); + + it('passes when pressable has button role', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes with link role', () => { + render( + + + Link + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('pressable-accessible-required', () => { + it('fails when pressable has accessible=false', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('not accessible'))).toBe(true); + }); + + it('passes when accessible is not set (defaults to true)', () => { + render( + + + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('pressable-label-required', () => { + it('fails when pressable has no label and no text', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('no text content'))).toBe(true); + }); + + it('passes when pressable has accessibilityLabel', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes when pressable has text content', () => { + render( + + + Submit + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('checked-state-required', () => { + it('fails when checkbox has no checked state', () => { + render( + + + Accept + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('checked state'))).toBe(true); + }); + + it('passes when checkbox has checked state', () => { + render( + + + Accept + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes with mixed checked state', () => { + render( + + + Select All + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('link-role-required', () => { + it('fails when clickable text has no link role', () => { + render( + + {}}>Click me + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes('clickable'))).toBe(true); + }); + + it('passes when clickable text has link role', () => { + render( + + {}}> + Click me + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + + it('passes when text is not clickable', () => { + render( + + Just text + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('link-role-misused', () => { + it('fails when non-clickable text has link role', () => { + render( + + Not a link + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes("'link' role"))).toBe(true); + }); + + it('passes when text with link role is clickable', () => { + render( + + {}}> + A link + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('no-empty-text', () => { + it('fails when text has no content', () => { + render( + + {''} + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.some((v) => v.problem.includes("doesn't contain text"))).toBe(true); + }); + + it('passes when text has content', () => { + render( + + Hello + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations).toHaveLength(0); + }); + }); + + describe('hidden components', () => { + it('skips hidden components with accessibilityElementsHidden', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + // The button has accessibility issues (no role, no label) but should be skipped because hidden + expect( + violations.filter((v) => v.problem.includes("user hasn't been informed")), + ).toHaveLength(0); + }); + + it('skips components with importantForAccessibility=no-hide-descendants', () => { + render( + + + + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect( + violations.filter((v) => v.problem.includes("user hasn't been informed")), + ).toHaveLength(0); + }); + }); + + describe('nested components', () => { + it('checks nested pressables', () => { + render( + + + Button 1 + + + Button 2 + + , + ); + const violations = checkAccessibility(screen.getByTestId('container')); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.problem.includes("user hasn't been informed"))).toBe(true); + }); + }); + + describe('toBeAccessible matcher', () => { + it('passes for accessible component', () => { + render( + + + Submit + + , + ); + expect(screen.getByTestId('container')).toBeAccessible(); + }); + + it('fails for inaccessible component', () => { + render( + + + + + , + ); + expect(() => { + expect(screen.getByTestId('container')).toBeAccessible(); + }).toThrow(); + }); + }); +}); diff --git a/packages/mobile/jest/accessibility/engine.ts b/packages/mobile/jest/accessibility/engine.ts new file mode 100644 index 0000000000..2043bc4b7b --- /dev/null +++ b/packages/mobile/jest/accessibility/engine.ts @@ -0,0 +1,72 @@ +/* eslint-disable no-restricted-syntax */ +/** + * Accessibility engine that checks React Native components for accessibility violations. + * Works directly with test instances from @testing-library/react-native. + */ +import { getPathToComponent, isHidden } from './helpers'; +import { type Rule, type RuleHelp, rules } from './rules'; +import type { TestInstance } from './types'; + +export interface Violation extends RuleHelp { + pathToComponent: string[]; +} + +export interface EngineOptions { + /** Specific rule IDs to check. If not provided, all rules are checked. */ + rules?: string[]; + /** Custom handler to filter or modify violations before the assertion. */ + customViolationHandler?: (violations: Violation[]) => Violation[]; +} + +/** + * Check a React test instance for accessibility violations. + * + * @param testInstance - The TestInstance to check (from RNTL's screen queries) + * @param options - Optional configuration for which rules to run + * @returns Array of violations found + */ +export function checkAccessibility( + testInstance: TestInstance, + options?: EngineOptions, +): Violation[] { + // Filter rules if specific rule IDs are provided + const rulesToCheck: Rule[] = options?.rules + ? rules.filter((rule) => options.rules?.includes(rule.id)) + : rules; + + const violations: Violation[] = []; + + // For every rule + for (const rule of rulesToCheck) { + // Traverse the component tree below the root to find components that should be tested + const matchedComponents = testInstance.findAll(rule.matcher, { deep: true }); + + // Check if the root of the tree should be tested as well + if (rule.matcher(testInstance)) { + matchedComponents.push(testInstance); + } + + // For all the components that were found + for (const component of matchedComponents) { + let didPassAssertion = false; + + if (isHidden(component)) { + // Skip checks on hidden components + didPassAssertion = true; + } else { + // Check if the component meets the rule's assertion + didPassAssertion = rule.assertion(component); + } + + // If not, add component to violation array + if (!didPassAssertion) { + violations.push({ + pathToComponent: getPathToComponent(component), + ...rule.help, + }); + } + } + } + + return violations; +} diff --git a/packages/mobile/jest/accessibility/helpers.ts b/packages/mobile/jest/accessibility/helpers.ts new file mode 100644 index 0000000000..235d41e4d4 --- /dev/null +++ b/packages/mobile/jest/accessibility/helpers.ts @@ -0,0 +1,198 @@ +/** + * Helper functions for accessibility rules. + * These functions check component types and properties to determine which accessibility rules apply. + */ +import { + Pressable, + Text, + TouchableHighlight, + TouchableNativeFeedback, + TouchableOpacity, + TouchableWithoutFeedback, +} from 'react-native'; + +import type { ComponentType, TestInstance } from './types'; + +// Components to exclude from component name extraction +const COMPONENT_NAME_BLACKLIST = ['String', 'Component', 'Object']; + +// Pressable component type names for string matching +const PRESSABLE_TYPE_NAMES = [ + 'TouchableHighlight', + 'TouchableOpacity', + 'TouchableNativeFeedback', + 'TouchableWithoutFeedback', + 'Pressable', +]; + +/** + * Get the type name from a component type. + * Handles both string types (host components) and function/class types (React components). + */ +function getTypeName(type: ComponentType): string { + if (typeof type === 'string') { + return type; + } + if (typeof type === 'function') { + return (type as { displayName?: string; name?: string }).displayName || type.name || ''; + } + if (typeof type === 'object' && type !== null) { + const objType = type as { displayName?: string; name?: string }; + return objType.displayName || objType.name || ''; + } + return ''; +} + +/** + * Check if a component type is a pressable element. + * Includes TouchableHighlight, TouchableOpacity, TouchableNativeFeedback, + * TouchableWithoutFeedback, and Pressable. + */ +export function isPressable(type: ComponentType): boolean { + // Direct reference comparison for React component instances + if ( + type === TouchableHighlight || + type === TouchableOpacity || + type === TouchableNativeFeedback || + type === TouchableWithoutFeedback || + type === Pressable + ) { + return true; + } + + // String name comparison for host components or named types + const typeName = getTypeName(type); + return PRESSABLE_TYPE_NAMES.some((name) => typeName.includes(name)); +} + +/** + * Check if a component type is a Text element. + */ +export function isText(type: ComponentType): boolean { + // Direct reference comparison + if (type === Text) { + return true; + } + + // String name comparison + const typeName = getTypeName(type); + return typeName === 'Text'; +} + +/** + * Check if a node is an adjustable component (Slider). + * Returns false for wrapper components that contain a Slider. + */ +export function isAdjustable(node: TestInstance): boolean { + const slidersInTree = node.findAll((n) => n.type.toString().includes('Slider')); + // If this node is a Slider BUT more than one slider is found in the tree + // that has this node as root, it means that this node must be a SliderWrapper + // for the actual Slider and should therefore be discarded. + return node.type.toString().includes('Slider') && slidersInTree.length === 1; +} + +/** + * Check if a node is a checkbox (pressable with role="checkbox"). + */ +export function isCheckbox(node: TestInstance): boolean { + return isPressable(node.type) && node.props.accessibilityRole === 'checkbox'; +} + +/** + * Check if a node is hidden from accessibility. + */ +export function isHidden(node: TestInstance): boolean { + return ( + node.props.accessibilityElementsHidden === true || + node.props.importantForAccessibility === 'no-hide-descendants' + ); +} + +/** + * Check if a node can be disabled. + * Returns false for wrapper components that contain disable-able components. + */ +export function canBeDisabled(node: TestInstance): boolean { + const inTree = node.findAll( + (n) => n.props.disabled !== undefined || n.props.enabled !== undefined, + ); + // If this node can be disabled BUT more than one disable-able component + // is found in the tree that has this node as root, it means that this node + // must be a Wrapper for the actual disable-able component and should be discarded. + return ( + (node.props.disabled !== undefined || node.props.enabled !== undefined) && inTree.length === 1 + ); +} + +/** + * Extract the component name from a node's type. + */ +function extractNameFromType(component: TestInstance): string | undefined { + const type = component.type as { displayName?: string; name?: string }; + + if (type.displayName && !COMPONENT_NAME_BLACKLIST.includes(type.displayName)) { + return type.displayName; + } + + if (type.name && !COMPONENT_NAME_BLACKLIST.includes(type.name)) { + return type.name; + } + + return undefined; +} + +/** + * Get the display name of a component. + * Handles wrapped components (Animated, Virtualized) by inspecting children. + */ +export function getComponentName(component: TestInstance): string { + let name = extractNameFromType(component); + + if (!name && component.children.length > 0 && typeof component.children[0] !== 'string') { + // Some components are wrapped in Animated or Virtualized nodes, + // and the main component is the child, not the wrapper, + // so we inspect the child component for name, not the parent. + name = extractNameFromType(component.children[0] as TestInstance); + } + + return name || 'Unknown'; +} + +/** + * Get the path from root to the given component as an array of component names. + */ +export function getPathToComponent(node: TestInstance): string[] { + const path: string[] = []; + let current: TestInstance | null = node; + + while (current) { + const type = current.type; + + // Skip string types and forward refs + const shouldSkip = + typeof type === 'string' || + (typeof type === 'object' && + type !== null && + (type as { $$typeof?: symbol }).$$typeof === Symbol.for('react.forward_ref')); + + if (!shouldSkip) { + path.push(getComponentName(current)); + } + + current = current.parent; + } + + return path.reverse(); +} + +/** + * Find a Text node within a component tree. + * Returns null if no Text node is found. + */ +export function findTextNode(node: TestInstance): TestInstance | null { + try { + return node.findByType(Text); + } catch { + return null; + } +} diff --git a/packages/mobile/jest/accessibility/index.ts b/packages/mobile/jest/accessibility/index.ts new file mode 100644 index 0000000000..ffcf7f7c75 --- /dev/null +++ b/packages/mobile/jest/accessibility/index.ts @@ -0,0 +1,25 @@ +/** + * Custom accessibility testing module for React Native. + * Replaces react-native-accessibility-engine with a React 19-compatible implementation. + * + * Usage: + * Import this module in your Jest setup file to add the toBeAccessible() matcher. + * + * @example + * // In jest/setup.js + * import './accessibility'; + * + * // In tests + * expect(screen.getByTestId('my-button')).toBeAccessible(); + */ +import { toBeAccessible } from './matchers'; + +// Extend Jest's expect with the toBeAccessible matcher +expect.extend({ toBeAccessible }); + +// Export for direct use if needed +export { toBeAccessible }; +export type { EngineOptions, Violation } from './engine'; +export { checkAccessibility } from './engine'; +export type { Rule, RuleHelp } from './rules'; +export { rules } from './rules'; diff --git a/packages/mobile/jest/accessibility/matchers.ts b/packages/mobile/jest/accessibility/matchers.ts new file mode 100644 index 0000000000..293dd900bc --- /dev/null +++ b/packages/mobile/jest/accessibility/matchers.ts @@ -0,0 +1,89 @@ +/** + * Custom Jest matchers for accessibility testing. + */ +import { getLabelPrinter, matcherHint, printExpected, printReceived } from 'jest-matcher-utils'; + +import { checkAccessibility, type EngineOptions, type Violation } from './engine'; +import type { TestInstance } from './types'; + +const LABEL_PROBLEM = 'Problem'; +const LABEL_SOLUTION = 'Solution'; + +/** + * Group violations by their path to component. + */ +function groupViolationsByPath(violations: Violation[]): Record { + const grouped: Record = {}; + for (const violation of violations) { + const key = violation.pathToComponent.join(','); + if (!grouped[key]) { + grouped[key] = []; + } + grouped[key].push(violation); + } + return grouped; +} + +/** + * Generate a formatted error message for accessibility violations. + */ +function generateErrorMessage(violations: Violation[], isNot: boolean): string { + let errorString = ''; + const matcherName = (isNot ? '.not' : '') + '.toBeAccessible'; + const hint = matcherHint(matcherName, 'component', '') + '\n\n'; + errorString += hint; + + const printLabel = getLabelPrinter(LABEL_PROBLEM, LABEL_SOLUTION); + const violationsGroupedByPath = groupViolationsByPath(violations); + + for (const path in violationsGroupedByPath) { + // Prettify path to component + errorString += path.split(',').join(' > ') + '\n\n'; + + for (const violation of violationsGroupedByPath[path]) { + const violationString = + printLabel(LABEL_PROBLEM) + + printReceived(violation.problem) + + '\n' + + printLabel(LABEL_SOLUTION) + + printExpected(violation.solution) + + '\n\n'; + errorString += violationString; + } + } + + return errorString; +} + +/** + * Jest matcher to check if a component is accessible. + * + * @example + * expect(screen.getByTestId('my-button')).toBeAccessible(); + */ +export function toBeAccessible( + this: jest.MatcherContext, + received: TestInstance, + options?: EngineOptions, +): jest.CustomMatcherResult { + let violations = checkAccessibility(received, options); + + // Apply custom violation handler if provided + if (options?.customViolationHandler) { + violations = options.customViolationHandler(violations); + } + + if (violations.length) { + const message = generateErrorMessage(violations, this.isNot); + return { + pass: false, + message: () => message, + }; + } + + return { + pass: true, + message: () => + 'Component is accessible.\nDoes it make sense to test a component for NOT being accessible?', + }; +} diff --git a/packages/mobile/jest/accessibility/rules.ts b/packages/mobile/jest/accessibility/rules.ts new file mode 100644 index 0000000000..bf5e372c89 --- /dev/null +++ b/packages/mobile/jest/accessibility/rules.ts @@ -0,0 +1,229 @@ +/** + * Accessibility rules for React Native components. + * Each rule has: + * - id: Unique identifier for the rule + * - matcher: Function that determines if a component should be checked + * - assertion: Function that returns true if the component passes the rule + * - help: Object with problem description and solution + */ +import { + canBeDisabled, + findTextNode, + isAdjustable, + isCheckbox, + isPressable, + isText, +} from './helpers'; +import type { TestInstance } from './types'; + +export type RuleHelp = { + problem: string; + solution: string; + link: string; +}; + +export type Rule = { + id: string; + matcher: (node: TestInstance) => boolean; + assertion: (node: TestInstance) => boolean; + help: RuleHelp; +}; + +const ALLOWED_PRESSABLE_ROLES = [ + 'button', + 'link', + 'imagebutton', + 'radio', + 'tab', + 'checkbox', + 'switch', +]; +const ALLOWED_PRESSABLE_ROLES_MESSAGE = ALLOWED_PRESSABLE_ROLES.join(' or '); + +const ALLOWED_CHECKED_VALUES = [true, false, 'mixed']; +const ALLOWED_CHECKED_VALUES_MESSAGE = ALLOWED_CHECKED_VALUES.join(' or '); + +/** + * Pressable components must have an accessibility role. + */ +const pressableRoleRequired: Rule = { + id: 'pressable-role-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => ALLOWED_PRESSABLE_ROLES.includes(node.props.accessibilityRole), + help: { + problem: + "This component is pressable but the user hasn't been informed that it behaves like a button/link/radio", + solution: `Set the 'accessibilityRole' prop to ${ALLOWED_PRESSABLE_ROLES_MESSAGE}`, + link: '', + }, +}; + +/** + * Pressable components must be accessible (not have accessible=false). + */ +const pressableAccessibleRequired: Rule = { + id: 'pressable-accessible-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => node.props.accessible !== false, + help: { + problem: 'This button is not accessible (selectable) to the user', + solution: + "Set the 'accessible' prop to 'true' or remove it (pressables are accessible by default)", + link: '', + }, +}; + +/** + * Pressable components must have a label (either from text content or accessibilityLabel). + */ +const pressableLabelRequired: Rule = { + id: 'pressable-label-required', + matcher: (node) => isPressable(node.type), + assertion: (node) => { + const textNode = findTextNode(node); + const textContent = textNode?.props?.children; + const accessibilityLabel = node.props.accessibilityLabel; + + if (!accessibilityLabel && !textContent) { + return false; + } + return true; + }, + help: { + problem: + "This pressable has no text content, so an accessibility label can't be automatically inferred", + solution: "Place a text component in the button or define an 'accessibilityLabel' prop", + link: '', + }, +}; + +/** + * Components with disabled/enabled props must expose disabled state. + */ +const disabledStateRequired: Rule = { + id: 'disabled-state-required', + matcher: (node) => canBeDisabled(node), + assertion: (node) => node.props.accessibilityState?.disabled !== undefined, + help: { + problem: "This component has a disabled state but it isn't exposed to the user", + solution: "Set the 'accessibilityState' prop to an object containing a boolean 'disabled' key", + link: '', + }, +}; + +/** + * Checkbox components must have a checked state. + */ +const checkedStateRequired: Rule = { + id: 'checked-state-required', + matcher: (node) => isCheckbox(node), + assertion: (node) => ALLOWED_CHECKED_VALUES.includes(node.props.accessibilityState?.checked), + help: { + problem: + "This component has an accessibility role of 'checkbox' but doesn't have a checked state", + solution: `Set the 'accessibilityState' prop to an object like this: { checked: ${ALLOWED_CHECKED_VALUES_MESSAGE} }`, + link: 'https://www.w3.org/WAI/ARIA/apg/example-index/checkbox/checkbox.html', + }, +}; + +/** + * Adjustable components (Slider) must have accessibilityRole="adjustable". + */ +const adjustableRoleRequired: Rule = { + id: 'adjustable-role-required', + matcher: (node) => isAdjustable(node), + assertion: (node) => node.props.accessibilityRole === 'adjustable', + help: { + problem: "This component has an adjustable value but the user wasn't informed of this", + solution: "Set the 'accessibilityRole' prop to 'adjustable'", + link: '', + }, +}; + +/** + * Adjustable components must have accessibilityValue with min, max, and now. + */ +const adjustableValueRequired: Rule = { + id: 'adjustable-value-required', + matcher: (node) => isAdjustable(node), + assertion: (node) => { + const value = node.props.accessibilityValue; + return value?.now !== undefined && value?.min !== undefined && value?.max !== undefined; + }, + help: { + problem: + "This component has an adjustable value but the user wasn't informed of its min, max, and current value", + solution: "Set the 'accessibilityValue' prop to an object: { min: ?, max: ?, now: ?}", + link: '', + }, +}; + +/** + * Clickable text must have accessibilityRole="link". + */ +const linkRoleRequired: Rule = { + id: 'link-role-required', + matcher: (node) => isText(node.type), + assertion: (node) => { + const { onPress, accessibilityRole } = node.props; + if (onPress) { + return accessibilityRole === 'link'; + } + return true; + }, + help: { + problem: "The text is clickable, but the user wasn't informed that it behaves like a link", + solution: "Set the 'accessibilityRole' prop to 'link' or remove the 'onPress' prop", + link: '', + }, +}; + +/** + * Non-clickable text should not have accessibilityRole="link". + */ +const linkRoleMisused: Rule = { + id: 'link-role-misused', + matcher: (node) => isText(node.type), + assertion: (node) => { + const { onPress, accessibilityRole } = node.props; + if (!onPress) { + return accessibilityRole !== 'link'; + } + return true; + }, + help: { + problem: "The 'link' role has been used but the text isn't clickable", + solution: "Set the 'accessibilityRole' prop to 'text' or add an 'onPress' prop", + link: '', + }, +}; + +/** + * Text components must have text content. + */ +const noEmptyText: Rule = { + id: 'no-empty-text', + matcher: (node) => isText(node.type), + assertion: (node) => !!node.props?.children, + help: { + problem: "This text node doesn't contain text and so no accessibility label can be inferred", + solution: 'Add text content or prevent this component from rendering if it has no content', + link: '', + }, +}; + +/** + * All accessibility rules in the order they should be applied. + */ +export const rules: Rule[] = [ + pressableRoleRequired, + pressableAccessibleRequired, + disabledStateRequired, + checkedStateRequired, + pressableLabelRequired, + adjustableRoleRequired, + adjustableValueRequired, + linkRoleRequired, + linkRoleMisused, + noEmptyText, +]; diff --git a/packages/mobile/jest/accessibility/types.ts b/packages/mobile/jest/accessibility/types.ts new file mode 100644 index 0000000000..b75690fceb --- /dev/null +++ b/packages/mobile/jest/accessibility/types.ts @@ -0,0 +1,18 @@ +/** + * Type definitions for the accessibility engine. + * + * These types are derived from @testing-library/react-native's exports + * to avoid importing from the deprecated react-test-renderer package. + */ +import type { RenderResult } from '@testing-library/react-native'; + +/** + * A node in the React test instance tree. + * Derived from RNTL's RenderResult['root'] type. + */ +export type TestInstance = RenderResult['root']; + +/** + * The type of a React component in the test tree. + */ +export type ComponentType = TestInstance['type']; diff --git a/packages/mobile/jest/setup.js b/packages/mobile/jest/setup.js index c6c68fdb74..c6719766f8 100644 --- a/packages/mobile/jest/setup.js +++ b/packages/mobile/jest/setup.js @@ -2,18 +2,28 @@ * NOTE: If you add imports here that extend Jest, such as extending `expect` with new * functions like `.toBeAccessible()`, you must also update `packages/mobile/src/jest.d.ts` */ -import 'react-native-gesture-handler/jestSetup'; -import 'react-native-accessibility-engine'; -import '@testing-library/jest-native/extend-expect'; +import './accessibility'; -import { setUpTests } from 'react-native-reanimated/src/jestUtils'; +// https://docs.swmansion.com/react-native-reanimated/docs/guides/testing/ +const { + setUpTests, + configureReanimatedLogger, + ReanimatedLogLevel, +} = require('react-native-reanimated'); -import { mockStatusBarHeight } from '../src/hooks/__tests__/constants'; - -jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter'); +// Must mock NativeEventEmitter at the internal module path not in main RN mock below +jest.mock('react-native/Libraries/EventEmitter/NativeEventEmitter', () => { + const MockNativeEventEmitter = class MockNativeEventEmitter { + addListener = jest.fn(() => ({ remove: jest.fn() })); + removeListener = jest.fn(); + removeAllListeners = jest.fn(); + }; + // Export as both default and the class itself for different import styles + MockNativeEventEmitter.default = MockNativeEventEmitter; + return MockNativeEventEmitter; +}); -// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing -jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper'); +jest.mock('react-native/src/private/animated/NativeAnimatedHelper'); jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); @@ -27,10 +37,6 @@ jest.mock('react-native', () => { RN.PixelRatio.getPixelSizeForLayoutSize = jest.fn((layoutSize) => Math.round(layoutSize * 1)); RN.PixelRatio.startDetecting = jest.fn(); - RN.NativeModules.StatusBarManager = { - getHeight: jest.fn((cb) => cb({ height: mockStatusBarHeight })), - }; - RN.Animated.loop = jest.fn(() => { return { start: jest.fn(), @@ -66,4 +72,16 @@ jest.mock('react-native', () => { return RN; }); +/* + React Reanimated 4.x setup: +*/ + +// Disable strict mode to prevent warnings about writing to shared values during render +// This is needed because some components (e.g., TabsActiveIndicator) use patterns that +// trigger warnings in reanimated 4.x strict mode but still work correctly +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, +}); + setUpTests(); diff --git a/packages/mobile/jest/setupWorkletsMock.js b/packages/mobile/jest/setupWorkletsMock.js new file mode 100644 index 0000000000..681bc6223c --- /dev/null +++ b/packages/mobile/jest/setupWorkletsMock.js @@ -0,0 +1,3 @@ +// Mock react-native-worklets before any reanimated imports +// The built-in mock at lib/module/mock is not available until later versions: 0.7.X +jest.mock('react-native-worklets', () => require('./workletsMock')); diff --git a/packages/mobile/jest/workletsMock.js b/packages/mobile/jest/workletsMock.js new file mode 100644 index 0000000000..791d413330 --- /dev/null +++ b/packages/mobile/jest/workletsMock.js @@ -0,0 +1,111 @@ +/** + * Mock for react-native-worklets 0.5.2 + * The built-in mock at lib/module/mock is not available until later versions: 0.7.X, + * Following CMR's version recommendation on versions we are staying with 0.5.2 and reanimated 4.1.1 for now + * This mock is based on the official mock from: + * https://github.com/software-mansion/react-native-reanimated/blob/main/packages/react-native-worklets/src/mock.ts + */ + +'use strict'; + +const NOOP = () => {}; +const NOOP_FACTORY = () => NOOP; +const IDENTITY = (value) => value; +const IMMEDIATE_CALLBACK_INVOCATION = (callback) => callback(); + +const RuntimeKind = { + ReactNative: 'RN', + UI: 'UI', + Worklet: 'Worklet', +}; + +// Mocked requestAnimationFrame that uses setTimeout and passes timestamp +// This fixes Jest's React Native setup which doesn't pass timestamps to callbacks +// See: https://github.com/facebook/react-native/blob/main/packages/react-native/jest/setup.js#L28 +const mockedRequestAnimationFrame = (callback) => { + return setTimeout(() => callback(performance.now()), 0); +}; + +// Set up global properties that reanimated expects from the native runtime +global._WORKLET = false; +global.__RUNTIME_KIND = RuntimeKind.ReactNative; +global._log = console.log; +global._getAnimationTimestamp = () => performance.now(); +global.__flushAnimationFrame = NOOP; +global.requestAnimationFrame = mockedRequestAnimationFrame; + +const WorkletAPI = { + isShareableRef: () => true, + makeShareable: IDENTITY, + makeShareableCloneOnUIRecursive: IDENTITY, + makeShareableCloneRecursive: IDENTITY, + shareableMappingCache: new Map(), + + getStaticFeatureFlag: () => false, + setDynamicFeatureFlag: NOOP, + + isSynchronizable: () => false, + + getRuntimeKind: () => RuntimeKind.ReactNative, + RuntimeKind, + + createWorkletRuntime: NOOP_FACTORY, + runOnRuntime: IDENTITY, + runOnRuntimeAsync(workletRuntime, worklet, ...args) { + return WorkletAPI.runOnUIAsync(worklet, ...args); + }, + scheduleOnRuntime: IMMEDIATE_CALLBACK_INVOCATION, + + createSerializable: IDENTITY, + isSerializableRef: IDENTITY, + serializableMappingCache: new Map(), + + createSynchronizable: IDENTITY, + + callMicrotasks: NOOP, + executeOnUIRuntimeSync: IDENTITY, + + runOnJS(fun) { + return (...args) => queueMicrotask(args.length ? () => fun(...args) : fun); + }, + + runOnUI(worklet) { + return (...args) => { + // In Jest environment we schedule work via mockedRequestAnimationFrame + // to ensure it runs when timers are advanced + mockedRequestAnimationFrame(() => { + worklet(...args); + }); + }; + }, + + runOnUIAsync(worklet, ...args) { + return new Promise((resolve) => { + mockedRequestAnimationFrame(() => { + const result = worklet(...args); + resolve(result); + }); + }); + }, + + runOnUISync: IMMEDIATE_CALLBACK_INVOCATION, + + scheduleOnRN(fun, ...args) { + WorkletAPI.runOnJS(fun)(...args); + }, + + scheduleOnUI(worklet, ...args) { + WorkletAPI.runOnUI(worklet)(...args); + }, + + unstable_eventLoopTask: NOOP_FACTORY, + + isWorkletFunction: () => false, + + WorkletsModule: {}, +}; + +module.exports = { + __esModule: true, + ...WorkletAPI, +}; diff --git a/packages/mobile/package.json b/packages/mobile/package.json index e4340a638e..23d44fc6b8 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -165,6 +165,14 @@ "types": "./dts/typography/index.d.ts", "default": "./esm/typography/index.js" }, + "./visualizations/chart": { + "types": "./dts/visualizations/chart/index.d.ts", + "default": "./esm/visualizations/chart/index.js" + }, + "./visualizations/sparkline": { + "types": "./dts/visualizations/sparkline/index.d.ts", + "default": "./esm/visualizations/sparkline/index.js" + }, "./visualizations": { "types": "./dts/visualizations/index.d.ts", "default": "./esm/visualizations/index.js" @@ -181,20 +189,17 @@ "CHANGELOG" ], "peerDependencies": { - "@react-navigation/native": "^6.1.6", - "@react-navigation/native-stack": "^6.9.26", - "@react-navigation/stack": "^6.3.16", - "lottie-react-native": "^6.7.0", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-gesture-handler": "^2.16.2", + "@shopify/react-native-skia": "2.2.12", + "lottie-react-native": "7.3.1", + "react": "~19.1.2", + "react-native": "~0.81.5", + "react-native-gesture-handler": "2.28.0", "react-native-inappbrowser-reborn": "^3.7.0", - "react-native-linear-gradient": "^2.8.3", "react-native-navigation-bar-color": "^2.0.2", - "react-native-reanimated": "^3.14.0", - "react-native-safe-area-context": "^4.10.5", - "react-native-screens": "^3.32.0", - "react-native-svg": "^14.1.0" + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "dependencies": { "@coinbase/cds-common": "workspace:^", @@ -203,7 +208,8 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@floating-ui/react-native": "^0.10.5", - "@react-spring/native": "^9.7.4", + "@react-spring/native": "^10.0.3", + "d3-interpolate-path": "^2.3.0", "fuse.js": "^7.1.0", "lodash": "^4.17.21", "type-fest": "^2.19.0", @@ -212,25 +218,23 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@react-native-community/netinfo": "^7.1.7", - "@react-navigation/native-stack": "^6.9.26", - "@testing-library/react-native": "^11.3.0", + "@shopify/react-native-skia": "2.2.12", + "@testing-library/react-native": "^13.3.3", "@types/d3-color": "^3.1.3", - "@types/react": "^18.3.12", - "@types/react-test-renderer": "^18.3.0", - "eslint-plugin-reanimated": "^2.0.1", - "lottie-react-native": "6.7.0", - "react-native-accessibility-engine": "^3.2.0", - "react-native-gesture-handler": "2.16.2", + "@types/react": "19.1.2", + "lottie-react-native": "7.3.1", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-gesture-handler": "2.28.0", "react-native-inappbrowser-reborn": "3.7.0", - "react-native-linear-gradient": "2.8.3", "react-native-navigation-bar-color": "2.0.2", - "react-native-reanimated": "3.14.0", - "react-native-safe-area-context": "4.10.5", - "react-native-screens": "3.32.0", - "react-native-svg": "14.1.0", - "react-test-renderer": "^18.3.1" + "react-native-reanimated": "4.1.1", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2", + "react-test-renderer": "19.1.2" } } diff --git a/packages/mobile/src/accordion/AccordionItem.tsx b/packages/mobile/src/accordion/AccordionItem.tsx index 482b0f0def..8af8b90df5 100644 --- a/packages/mobile/src/accordion/AccordionItem.tsx +++ b/packages/mobile/src/accordion/AccordionItem.tsx @@ -11,8 +11,8 @@ import { AccordionPanel, type AccordionPanelBaseProps } from './AccordionPanel'; export type AccordionItemBaseProps = Pick & Omit & Omit & { - headerRef?: React.RefObject; - panelRef?: React.RefObject; + headerRef?: React.RefObject; + panelRef?: React.RefObject; }; export type AccordionItemProps = AccordionItemBaseProps; diff --git a/packages/mobile/src/alpha/combobox/Combobox.tsx b/packages/mobile/src/alpha/combobox/Combobox.tsx index b7b301ddec..bed6a2b307 100644 --- a/packages/mobile/src/alpha/combobox/Combobox.tsx +++ b/packages/mobile/src/alpha/combobox/Combobox.tsx @@ -14,6 +14,7 @@ import Fuse from 'fuse.js'; import { Button } from '../../buttons/Button'; import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; import { Box } from '../../layout'; import { StickyFooter } from '../../sticky-footer/StickyFooter'; import { DefaultSelectControl } from '../select/DefaultSelectControl'; @@ -73,7 +74,7 @@ export type ComboboxControlProps< /** Search text change handler */ onSearch: (searchText: string) => void; /** Reference to the search input */ - searchInputRef: React.RefObject; + searchInputRef: React.RefObject; /** Reference to the combobox control for positioning */ controlRef: React.RefObject; /** Custom SelectControlComponent to wrap */ @@ -251,6 +252,7 @@ const ComboboxBase = memo( ); const searchInputRef = useRef(null); + const safeBottomPadding = useSafeBottomPadding(); const handleTrayVisibilityChange = useCallback((visibility: 'visible' | 'hidden') => { if (visibility === 'visible') { searchInputRef.current?.focus(); @@ -282,7 +284,7 @@ const ComboboxBase = memo( footer={({ handleClose }) => ( , { scenario }); }); diff --git a/packages/mobile/src/buttons/__tests__/Button.test.tsx b/packages/mobile/src/buttons/__tests__/Button.test.tsx index a45021136b..73bebbdceb 100644 --- a/packages/mobile/src/buttons/__tests__/Button.test.tsx +++ b/packages/mobile/src/buttons/__tests__/Button.test.tsx @@ -1,4 +1,4 @@ -import { Animated, Pressable } from 'react-native'; +import { Animated } from 'react-native'; import { useEventHandler } from '@coinbase/cds-common/hooks/useEventHandler'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -31,16 +31,6 @@ describe('Button', () => { expect(screen.UNSAFE_queryAllByType(Animated.View)).toHaveLength(1); }); - it('renders a pressable', () => { - render( - - - , - ); - - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - }); - it('renders children text', () => { render( diff --git a/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx b/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx index 455e4d31a9..da82defa6d 100644 --- a/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/SlideButton.test.tsx @@ -52,7 +52,7 @@ describe('SlideButton', () => { it('renders correctly', () => { render(); - expect(screen.getByText(uncheckedLabel)).toBeTruthy(); + expect(screen.getByText(uncheckedLabel, { includeHiddenElements: true })).toBeTruthy(); }); it('is accessible', () => { @@ -177,7 +177,7 @@ describe('SlideButton', () => { describe('compact variant', () => { it('renders correctly with compact prop', () => { render(); - expect(screen.getByText(uncheckedLabel)).toBeTruthy(); + expect(screen.getByText(uncheckedLabel, { includeHiddenElements: true })).toBeTruthy(); }); it('applies compact height of 40px', () => { diff --git a/packages/mobile/src/cards/Card.tsx b/packages/mobile/src/cards/Card.tsx index a3acd00297..10d7ba5d0a 100644 --- a/packages/mobile/src/cards/Card.tsx +++ b/packages/mobile/src/cards/Card.tsx @@ -24,6 +24,10 @@ export type CardBaseProps = Pick< pressableProps?: Omit; }; +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardProps = CardBaseProps & BoxProps; const getBorderRadiusPinStyle = (borderRadius: number) => ({ diff --git a/packages/mobile/src/cards/CardMedia.tsx b/packages/mobile/src/cards/CardMedia.tsx index ab726cec0a..a6c7f5b3f7 100644 --- a/packages/mobile/src/cards/CardMedia.tsx +++ b/packages/mobile/src/cards/CardMedia.tsx @@ -38,23 +38,9 @@ const imageProps: Record = { export const CardMedia = memo(function CardMedia({ placement = 'end', ...props }: CardMediaProps) { switch (props.type) { case 'spotSquare': - return ( - - ); + return ; case 'pictogram': - return ( - - ); + return ; case 'image': return ( { - const TextEndLabel = progressVariant === 'bar' ? TextLabel2 : TextBody; + const endLabelFont = progressVariant === 'bar' ? 'label2' : 'body'; return ( {!!startLabelProp && ( - {startLabelProp} + + {startLabelProp} + )} {!!endLabelProp && ( - + {endLabelProp} - + )} ); diff --git a/packages/mobile/src/cards/LikeButton.tsx b/packages/mobile/src/cards/LikeButton.tsx index 9f615ae460..e7cd70b72f 100644 --- a/packages/mobile/src/cards/LikeButton.tsx +++ b/packages/mobile/src/cards/LikeButton.tsx @@ -7,12 +7,12 @@ import { scaleInConfig, scaleOutConfig, } from '@coinbase/cds-common/animation/likeButton'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; import { convertMotionConfig } from '../animation/convertMotionConfig'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useTheme } from '../hooks/useTheme'; import { TextIcon } from '../icons/TextIcon'; import { HStack } from '../layout/HStack'; import type { PressableProps } from '../system/Pressable'; @@ -26,7 +26,7 @@ export type LikeButtonBaseProps = Pick< SharedProps & { liked?: boolean; count?: number; - /** Reduce the inner padding within the button itself. */ + /** Use the compact variant. */ compact?: boolean; /** Ensure the button aligns flush on the left or right. * This prop will translate the entire button left/right, @@ -45,6 +45,7 @@ export const LikeButton = memo(function LikeButton(_props: LikeButtonProps) { const { count = 0, compact = true, + padding = compact ? 1.5 : 2, // mirror IconButton's padding flush, liked = false, onPress, @@ -55,7 +56,7 @@ export const LikeButton = memo(function LikeButton(_props: LikeButtonProps) { } = mergedProps; const iconScale = useRef(new Animated.Value(1)); const iconSize = compact ? 's' : 'm'; - const size = interactableHeight[compact ? 'compact' : 'regular']; + const theme = useTheme(); const { marginStart, marginEnd } = getButtonSpacingProps({ compact, flush }); @@ -82,6 +83,12 @@ export const LikeButton = memo(function LikeButton(_props: LikeButtonProps) { [], ); + // override default line height to match the height of the sibling icon + const countTextStyle = useMemo( + () => ({ lineHeight: theme.iconSize[iconSize] }), + [theme.iconSize, iconSize], + ); + return ( {count > 0 ? ( - + {count} ) : null} diff --git a/packages/mobile/src/cards/NudgeCard.tsx b/packages/mobile/src/cards/NudgeCard.tsx index 41ef356313..1e0759aa15 100644 --- a/packages/mobile/src/cards/NudgeCard.tsx +++ b/packages/mobile/src/cards/NudgeCard.tsx @@ -13,6 +13,7 @@ import type { import { IconButton } from '../buttons'; import { Pictogram } from '../illustrations/Pictogram'; import { Box, HStack, VStack } from '../layout'; +import type { StyleProps } from '../styles/styleProps'; import { Pressable } from '../system/Pressable'; import { Text } from '../typography/Text'; @@ -104,7 +105,11 @@ export const NudgeCard = memo( background = 'bgAlternate', onPress, maxWidth, - ...props + maxHeight, + minHeight, + minWidth, + height, + aspectRatio, }: NudgeCardProps) => { const hasMedia = pictogram || media; const paddingBottom = action ? 1 : 2; @@ -135,11 +140,11 @@ export const NudgeCard = memo( background={background} borderColor="transparent" borderRadius={500} - maxWidth={maxWidth} + maxWidth={maxWidth as StyleProps['maxWidth']} paddingEnd={onDismissPress ? 3 : 0} position="relative" testID={testID} - width={width} + width={width as StyleProps['width']} > {onDismissPress ? ( // zIndex is required otherwise CardBody sits on top of it @@ -159,14 +164,24 @@ export const NudgeCard = memo( {/* ported over from CardBody */} {hasMedia && mediaPosition === 'left' ? renderMedia : null} - + {typeof title === 'string' ? ( & - Pick & { + Pick & + Pick & { /** Callback fired when the action button is pressed */ onActionPress?: PressableProps['onPress']; /** Callback fired when the dismiss button is pressed */ @@ -38,7 +39,8 @@ export type UpsellCardBaseProps = SharedProps & */ background?: ThemeVars.Color; /** - * @danger This is a migration escape hatch. It is not intended to be used normally. + * @deprecated Use `style` or `background` to customize card background. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ dangerouslySetBackground?: string; }; @@ -87,6 +89,7 @@ export const UpsellCard = memo( accessibilityLabel, width = upsellCardDefaultWidth, onPress, + style, }: UpsellCardProps) => { const content = ( ); diff --git a/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx b/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx index 25f80ec9ba..173b3bc6e9 100644 --- a/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/ContainedAssetCard.stories.tsx @@ -88,8 +88,8 @@ const ContainedAssetCardScreen = () => { description={ {subheadIconSignMap.upwardTrend}6.37% @@ -136,7 +136,7 @@ const ContainedAssetCardScreen = () => { /> - + ); diff --git a/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx b/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx index 49f0a25117..4aafac9450 100644 --- a/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/ContentCard.stories.tsx @@ -315,7 +315,7 @@ const ContentCardScreen = () => { - @@ -346,7 +346,7 @@ const ContentCardScreen = () => { - @@ -364,7 +364,7 @@ const ContentCardScreen = () => { - @@ -434,7 +434,7 @@ const ContentCardScreen = () => { - @@ -470,7 +470,7 @@ const ContentCardScreen = () => { - @@ -506,7 +506,7 @@ const ContentCardScreen = () => { - @@ -537,7 +537,7 @@ const ContentCardScreen = () => { - diff --git a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx index fbace0dd87..1977ac17f7 100644 --- a/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/MediaCard.stories.tsx @@ -7,7 +7,6 @@ import { Carousel } from '../../carousel/Carousel'; import { CarouselItem } from '../../carousel/CarouselItem'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { RemoteImage } from '../../media/RemoteImage'; -import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography'; import { Text } from '../../typography/Text'; import type { MediaCardProps } from '../MediaCard'; import { MediaCard } from '../MediaCard'; @@ -85,15 +84,19 @@ const MediaCardScreen = () => { + Custom description with bold text and{' '} italic text - + } media={exampleMedia} - subtitle={Custom Subtitle} + subtitle={ + + Custom Subtitle + + } thumbnail={exampleThumbnail} - title={Custom Title} + title={Custom Title} /> diff --git a/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx b/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx index 6e7e940285..142f1cb287 100644 --- a/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx +++ b/packages/mobile/src/cards/__stories__/UpsellCard.stories.tsx @@ -64,17 +64,17 @@ const UpsellCardScreen = () => { return ( - + - + @@ -86,14 +86,14 @@ const UpsellCardScreen = () => { Sign up } - dangerouslySetBackground={customTextNodeBackgroundColor} description={ - + Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: customTextNodeBackgroundColor }} title={ - + Coinbase One } @@ -102,21 +102,21 @@ const UpsellCardScreen = () => { + Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: customBackgroundColor }} title={ - + Coinbase One } /> - + Carousel @@ -126,19 +126,19 @@ const UpsellCardScreen = () => { , , , ]} diff --git a/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx b/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx index 998705abf4..b763481fec 100644 --- a/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx +++ b/packages/mobile/src/cards/__tests__/ContainedAssetCard.test.tsx @@ -80,7 +80,7 @@ describe('ContainedAssetCard', () => { } - maxWidth="none" + maxWidth={500} minWidth={120} subtitle="Subtitle" testID="card" @@ -89,6 +89,6 @@ describe('ContainedAssetCard', () => { , ); - expect(screen.getByTestId('card')).toHaveStyle({ maxWidth: 'none', minWidth: 120 }); + expect(screen.getByTestId('card')).toHaveStyle({ maxWidth: 500, minWidth: 120 }); }); }); diff --git a/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx b/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx index b90c33157d..a5e48084b9 100644 --- a/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx +++ b/packages/mobile/src/cards/__tests__/UpsellCard.test.tsx @@ -81,10 +81,10 @@ describe('UpsellCard', () => { expect(screen.getByTestId(`${exampleProps.testID}-dismiss-button`)).toBeDefined(); }); - it('renders dangerouslySetBackground', () => { + it('renders custom background via style prop', () => { render( - + , ); expect(screen.getByTestId(exampleProps.testID as string)).toHaveStyle({ diff --git a/packages/mobile/src/carousel/Carousel.tsx b/packages/mobile/src/carousel/Carousel.tsx index f54368683f..0575703a82 100644 --- a/packages/mobile/src/carousel/Carousel.tsx +++ b/packages/mobile/src/carousel/Carousel.tsx @@ -45,7 +45,7 @@ const wrap = (min: number, max: number, value: number): number => { return min + ((((value - min) % range) + range) % range); }; -export type CarouselItemRenderChildren = React.FC<{ isVisible: boolean }>; +export type CarouselItemRenderChildren = (args: { isVisible: boolean }) => React.ReactNode; export type CarouselItemBaseProps = Omit & SharedAccessibilityProps & { @@ -130,10 +130,10 @@ export type CarouselPaginationComponentBaseProps = { paginationAccessibilityLabel?: string | ((pageIndex: number) => string); /** * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead + * When omitted, the default pagination component renders the current dot-style design. + * @default 'dot' + * @deprecated Prefer the default dot pagination or provide a custom `PaginationComponent`. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ variant?: 'pill' | 'dot'; }; @@ -195,6 +195,11 @@ export type CarouselBaseProps = SharedProps & * Hides the pagination indicators (dots/bars showing current page). */ hidePagination?: boolean; + /** + * @deprecated Use the default dot pagination, or provide a custom `PaginationComponent` if you need custom visuals. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + paginationVariant?: CarouselPaginationComponentBaseProps['variant']; /** * Custom component to render navigation arrows. * @default DefaultCarouselNavigation @@ -264,14 +269,6 @@ export type CarouselBaseProps = SharedProps & * @default 3000 (3 seconds) */ autoplayInterval?: number; - /** - * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead - */ - paginationVariant?: CarouselPaginationComponentBaseProps['variant']; }; export type CarouselProps = CarouselBaseProps & { @@ -558,6 +555,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -575,7 +573,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props } = mergedProps; const carouselScrollX = useRef(0); @@ -1165,7 +1162,7 @@ export const Carousel = memo( {(title || !hideNavigation) && ( {typeof title === 'string' ? ( - + {title} ) : ( diff --git a/packages/mobile/src/carousel/DefaultCarouselPagination.tsx b/packages/mobile/src/carousel/DefaultCarouselPagination.tsx index e45e2f1e4e..edaa47f6b2 100644 --- a/packages/mobile/src/carousel/DefaultCarouselPagination.tsx +++ b/packages/mobile/src/carousel/DefaultCarouselPagination.tsx @@ -50,6 +50,7 @@ const PaginationPill = memo(function PaginationPill({ background={isActive ? 'bgPrimary' : 'bgLine'} borderColor="transparent" borderRadius={100} + borderWidth={0} height={INDICATOR_HEIGHT} onPress={onPress} style={style} @@ -173,7 +174,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination style, styles, paginationAccessibilityLabel = defaultPaginationAccessibilityLabel, - variant = 'pill', + variant = 'dot', }: DefaultCarouselPaginationProps) { const theme = useTheme(); const isDot = variant === 'dot'; @@ -190,7 +191,12 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination : paginationAccessibilityLabel; return ( - + {totalPages > 0 ? ( Array.from({ length: totalPages }, (_, index) => isDot ? ( diff --git a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx index 26597b66f8..e10a44c36d 100644 --- a/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/mobile/src/carousel/__figma__/Carousel.figma.tsx @@ -18,7 +18,7 @@ figma.connect( }), }, example: ({ title, hidePagination }) => ( - + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx index 35c54bd461..2ed4cb3a50 100644 --- a/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/mobile/src/carousel/__stories__/Carousel.stories.tsx @@ -134,8 +134,6 @@ const BasicExamples = () => { <> { @@ -173,7 +169,6 @@ const BasicExamples = () => { loop NavigationComponent={SeeAllComponent} drag="free" - paginationVariant="dot" snapMode="item" styles={{ root: { paddingHorizontal: horizontalPadding }, @@ -195,7 +190,6 @@ const BasicExamples = () => { { { disabled={!canGoPrevious} name="caretLeft" onPress={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -371,7 +364,6 @@ const AutoplayExample = () => { { { return ( { { autoplay loop drag="snap" - paginationVariant="dot" snapMode="item" styles={{ root: { paddingHorizontal: horizontalPadding }, diff --git a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx index a76580529c..d304996017 100644 --- a/packages/mobile/src/carousel/__tests__/Carousel.test.tsx +++ b/packages/mobile/src/carousel/__tests__/Carousel.test.tsx @@ -271,16 +271,47 @@ describe('Carousel', () => { render(); - expect(mockNavigation).toHaveBeenCalledWith( + expect(mockNavigation).toHaveBeenCalled(); + expect(mockNavigation.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ onGoNext: expect.any(Function), onGoPrevious: expect.any(Function), disableGoNext: expect.any(Boolean), disableGoPrevious: expect.any(Boolean), }), - {}, ); }); + + it('does not pass a pagination variant by default', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render(); + + await waitFor(() => { + expect( + mockPagination.mock.calls.some((call) => { + const props = call[0]; + return props !== undefined && props.variant === undefined; + }), + ).toBe(true); + }); + }); + + it('forwards deprecated paginationVariant to custom pagination components', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render( + , + ); + + await waitFor(() => { + expect(mockPagination.mock.calls.some((call) => call[0]?.variant === 'pill')).toBe(true); + }); + }); }); describe('Accessibility', () => { @@ -1181,7 +1212,7 @@ describe('Carousel', () => { fireEvent.press(screen.getByTestId('get-current-page')); - expect(screen.getByTestId('current-page-display')).toHaveTextContent('Page 1 of'); + expect(screen.getByTestId('current-page-display')).toHaveTextContent(/Page 1 of/); fireEvent.press(screen.getByTestId('go-to-page-2')); @@ -1194,7 +1225,7 @@ describe('Carousel', () => { fireEvent.press(screen.getByTestId('get-current-page')); - expect(screen.getByTestId('current-page-display')).toHaveTextContent('Page 1 of'); + expect(screen.getByTestId('current-page-display')).toHaveTextContent(/Page 1 of/); }); }); @@ -1215,9 +1246,13 @@ describe('Carousel', () => { , ); - expect(screen.getByTestId('render-props-content')).toBeOnTheScreen(); - expect(screen.getByTestId('visibility-indicator')).toBeOnTheScreen(); - expect(screen.getByText('Content')).toBeOnTheScreen(); + expect( + screen.getByTestId('render-props-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('visibility-indicator', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect(screen.getByText('Content', { includeHiddenElements: true })).toBeOnTheScreen(); }); it('supports both regular children and render props', () => { @@ -1241,11 +1276,21 @@ describe('Carousel', () => { , ); - expect(screen.getByTestId('regular-content')).toBeOnTheScreen(); - expect(screen.getByTestId('render-props-content')).toBeOnTheScreen(); - expect(screen.getByText('Regular Content')).toBeOnTheScreen(); - expect(screen.getByText('Render Props Content')).toBeOnTheScreen(); - expect(screen.getByTestId('visibility-status')).toBeOnTheScreen(); + expect( + screen.getByTestId('regular-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('render-props-content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByText('Regular Content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByText('Render Props Content', { includeHiddenElements: true }), + ).toBeOnTheScreen(); + expect( + screen.getByTestId('visibility-status', { includeHiddenElements: true }), + ).toBeOnTheScreen(); }); }); diff --git a/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx b/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx index aa03b553d4..372ae3f35b 100644 --- a/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx +++ b/packages/mobile/src/carousel/__tests__/DefaultCarouselPagination.test.tsx @@ -47,6 +47,20 @@ const renderPagination = (props: Partial { + describe('variant', () => { + it('defaults to the dot variant', () => { + renderPagination({ totalPages: 3 }); + + expect(screen.getByTestId('carousel-pagination-dot')).toBeOnTheScreen(); + }); + + it('uses the pill variant when requested', () => { + renderPagination({ totalPages: 3, variant: 'pill' }); + + expect(screen.getByTestId('carousel-pagination-pill')).toBeOnTheScreen(); + }); + }); + describe('paginationAccessibilityLabel', () => { it('uses default function that includes page number when not provided', () => { renderPagination({ totalPages: 3 }); diff --git a/packages/mobile/src/cells/Cell.tsx b/packages/mobile/src/cells/Cell.tsx index 813aa8378b..f96380a00d 100644 --- a/packages/mobile/src/cells/Cell.tsx +++ b/packages/mobile/src/cells/Cell.tsx @@ -1,5 +1,11 @@ import React, { memo, useMemo } from 'react'; -import { type StyleProp, StyleSheet, type ViewProps, type ViewStyle } from 'react-native'; +import { + type DimensionValue, + type StyleProp, + StyleSheet, + type ViewProps, + type ViewStyle, +} from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { CellPriority, SharedProps } from '@coinbase/cds-common/types'; import { hasCellPriority } from '@coinbase/cds-common/utils/cell'; @@ -36,6 +42,7 @@ export type CellSpacing = Pick< export type CellBaseProps = SharedProps & LinkableProps & Pick & { + /** Accessory element rendered at the end of the cell (e.g., chevron). */ accessory?: React.ReactElement; /** Custom accessory node rendered at the end of the cell. Takes precedence over `accessory`. */ accessoryNode?: React.ReactNode; @@ -60,7 +67,7 @@ export type CellBaseProps = SharedProps & * @deprecated Use `styles.end` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v9 */ - detailWidth?: number | string; + detailWidth?: DimensionValue; /** Is the cell disabled? Will apply opacity and disable interaction. */ disabled?: boolean; /** Which piece of content has the highest priority in regards to text truncation, growing, and shrinking. */ @@ -108,7 +115,24 @@ export const Cell = memo(function Cell(_props: CellProps) { accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, styles, end, @@ -147,9 +171,55 @@ export const Cell = memo(function Cell(_props: CellProps) { const { marginX: innerSpacingMarginX, ...innerSpacingWithoutMarginX } = innerSpacing; + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); + const content = useMemo(() => { const contentContainerProps = { - borderRadius, + ...borderProps, testID, renderToHardwareTextureAndroid: disabled, ...(selected ? { background } : {}), @@ -237,7 +307,7 @@ export const Cell = memo(function Cell(_props: CellProps) { ); }, [ - borderRadius, + borderProps, testID, disabled, selected, @@ -285,7 +355,7 @@ export const Cell = memo(function Cell(_props: CellProps) { accessibilityState={{ disabled, ...accessibilityState }} background="bg" blendStyles={blendStyles} - borderRadius={borderRadius} + {...borderProps} contentStyle={pressStyles} disabled={disabled} onPress={onPress} @@ -308,7 +378,7 @@ export const Cell = memo(function Cell(_props: CellProps) { styles?.pressable, accessibilityState, blendStyles, - borderRadius, + borderProps, ]); return ( diff --git a/packages/mobile/src/cells/CellAccessory.tsx b/packages/mobile/src/cells/CellAccessory.tsx index 121498feab..07e3fe7cf3 100644 --- a/packages/mobile/src/cells/CellAccessory.tsx +++ b/packages/mobile/src/cells/CellAccessory.tsx @@ -40,7 +40,7 @@ export const CellAccessory = memo(function CellAccessory({ type, ...props }: Cel } return ( - + {icon} ); diff --git a/packages/mobile/src/cells/ContentCellFallback.tsx b/packages/mobile/src/cells/ContentCellFallback.tsx index b3ebece0f2..74402448f2 100644 --- a/packages/mobile/src/cells/ContentCellFallback.tsx +++ b/packages/mobile/src/cells/ContentCellFallback.tsx @@ -39,6 +39,10 @@ export type ContentCellFallbackProps = FallbackRectWidthProps & title?: boolean; }; +/** + * @deprecated Please use the new ListCellFallback component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const ContentCellFallback = memo(function ContentCellFallback({ accessory, accessoryNode, diff --git a/packages/mobile/src/cells/ListCell.tsx b/packages/mobile/src/cells/ListCell.tsx index 611d0a3861..631b4dc10e 100644 --- a/packages/mobile/src/cells/ListCell.tsx +++ b/packages/mobile/src/cells/ListCell.tsx @@ -4,7 +4,7 @@ import { compactListHeight, listHeight } from '@coinbase/cds-common/tokens/cell' import { useComponentConfig } from '../hooks/useComponentConfig'; import { VStack } from '../layout/VStack'; -import { Text, type TextProps } from '../typography/Text'; +import { Text } from '../typography/Text'; import { Cell, type CellBaseProps, type CellProps, type CellSpacing } from './Cell'; import { CellAccessory, type CellAccessoryType } from './CellAccessory'; @@ -179,16 +179,20 @@ export const ListCell = memo(function ListCell(_props: ListCellProps) { variant, onPress, spacingVariant = compact ? 'compact' : 'normal', + minHeight: minHeightProp, style, styles, ...props } = mergedProps; + // we need to maintain fixed min-heights for the different cell style variants until they are dropped in a breaking change + // see CDS-1620 const minHeight = - spacingVariant === 'compact' + minHeightProp ?? + (spacingVariant === 'compact' ? compactListHeight : spacingVariant === 'normal' ? listHeight - : undefined; + : undefined); const accessoryType = selected && !disableSelectionAccessory ? 'selected' : accessory; const hasDetails = Boolean(detail || subdetail || detailNode || subdetailNode); diff --git a/packages/mobile/src/cells/MediaFallback.tsx b/packages/mobile/src/cells/MediaFallback.tsx index abc8075c16..fb5803d58b 100644 --- a/packages/mobile/src/cells/MediaFallback.tsx +++ b/packages/mobile/src/cells/MediaFallback.tsx @@ -14,8 +14,8 @@ export const MediaFallback = memo(function MediaFallback({ ...fallbackProps }: MediaFallbackProps) { if (type === 'image') { - return ; + return ; } - return ; + return ; }); diff --git a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx index cb9d29d7f9..21f886d845 100644 --- a/packages/mobile/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/mobile/src/cells/__stories__/ListCell.stories.tsx @@ -794,6 +794,77 @@ const WithHelperText = () => ( ); +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; + + return ( + + setIsCondensed(Boolean(nextChecked))} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + ); +}; + const CustomSpacing = () => ( <> ( const CondensedListCell = () => { const theme = useTheme(); return ( - + { }, []); return ( - + { + + + diff --git a/packages/mobile/src/cells/__tests__/CellMedia.test.tsx b/packages/mobile/src/cells/__tests__/CellMedia.test.tsx index 4eaeea1bda..e514d62ac6 100644 --- a/packages/mobile/src/cells/__tests__/CellMedia.test.tsx +++ b/packages/mobile/src/cells/__tests__/CellMedia.test.tsx @@ -192,42 +192,6 @@ describe('CellMedia', () => { expect(screen.getByText(glyphMap['arrowUp-24-inactive'])).toBeTruthy(); }); - it('renders an asset', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 100000 }); - }); - - it('renders an avatar', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 100000 }); - }); - - it('renders an image', () => { - render( - - - , - ); - const image = screen.getByRole('image'); - - expect(image).toHaveProp('source', { cache: undefined, uri: 'some/image/path' }); - expect(image).toHaveStyle({ borderRadius: 8 }); - }); - it('renders a pictogram', () => { render( @@ -239,46 +203,6 @@ describe('CellMedia', () => { }); describe('at normal scale', () => { - it('sets icon size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets asset size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets avatar size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 32, height: 32 }); - }); - - it('sets image size', () => { - render( - - - , - ); - - expect(screen.getByRole('image')).toHaveStyle({ width: 48, height: 48 }); - }); - it('sets pictogram size', () => { render( diff --git a/packages/mobile/src/cells/__tests__/ContentCell.test.tsx b/packages/mobile/src/cells/__tests__/ContentCell.test.tsx index 234b9c9a60..1f0a8d80eb 100644 --- a/packages/mobile/src/cells/__tests__/ContentCell.test.tsx +++ b/packages/mobile/src/cells/__tests__/ContentCell.test.tsx @@ -1,8 +1,6 @@ import { Text, View } from 'react-native'; import { render, screen } from '@testing-library/react-native'; -import { VStack } from '../../layout'; -import { Text as TypographyText } from '../../typography/Text'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Cell } from '../Cell'; import { CellMedia } from '../CellMedia'; @@ -231,7 +229,7 @@ describe('ContentCell', () => { , ); - expect(screen.container).not.toBeNull(); + expect(screen.root).not.toBeNull(); }); it('renders override nodes when provided', () => { @@ -290,9 +288,9 @@ describe('ContentCell', () => { , ); - const titleInstance = screen.getByText('Title').parent; - const subtitleInstance = screen.getByText('Subtitle').parent; - const descriptionInstance = screen.getByText('Description').parent; + const titleInstance = screen.getByText('Title').parent.parent; + const subtitleInstance = screen.getByText('Subtitle').parent.parent; + const descriptionInstance = screen.getByText('Description').parent.parent; expect(titleInstance?.props.numberOfLines).toBe(2); expect(subtitleInstance?.props.font).toBe('label1'); @@ -321,6 +319,6 @@ describe('ContentCell', () => { ); const metaInstance = screen.getByText('Meta').parent; - expect(metaInstance?.props.style).toBe(metaStyle); + expect(metaInstance?.props.style).toContainEqual(metaStyle); }); }); diff --git a/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx b/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx index af6fa9ce62..4e50fd85b9 100644 --- a/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx +++ b/packages/mobile/src/cells/__tests__/ContentCellFallback.test.tsx @@ -29,7 +29,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('MediaFallback image')).toBeDefined(); + expect(screen.getByText('MediaFallback image', { includeHiddenElements: true })).toBeDefined(); }); it('should render description fallback', () => { @@ -38,7 +38,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -47,7 +47,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 3), width: 110, }), - {}, + undefined, ); }); @@ -74,7 +74,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -82,7 +82,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 1), width: 90, }), - {}, + undefined, ); }); @@ -92,7 +92,7 @@ describe('ContentCellFallback', () => { , ); - expect(screen.getByText('Fallback')).toBeDefined(); + expect(screen.getByText('Fallback', { includeHiddenElements: true })).toBeDefined(); expect(Fallback).toHaveBeenCalledWith( expect.objectContaining({ disableRandomRectWidth: true, @@ -100,7 +100,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 2), width: 90, }), - {}, + undefined, ); }); diff --git a/packages/mobile/src/cells/__tests__/ListCell.test.tsx b/packages/mobile/src/cells/__tests__/ListCell.test.tsx index b6222283c4..b3aa9a7bc4 100644 --- a/packages/mobile/src/cells/__tests__/ListCell.test.tsx +++ b/packages/mobile/src/cells/__tests__/ListCell.test.tsx @@ -3,41 +3,11 @@ import { noop } from '@coinbase/cds-utils'; import { render, screen } from '@testing-library/react-native'; import { Button } from '../../buttons'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, treeHasStyleProp } from '../../utils/testHelpers'; import { CellHelperText } from '../CellHelperText'; import { CellMedia } from '../CellMedia'; import { ListCell } from '../ListCell'; -function flattenStyle(style: unknown): Array> { - if (!style) return []; - if (Array.isArray(style)) return style.flatMap(flattenStyle); - if (typeof style === 'object') return [style as Record]; - return []; -} - -function treeHasStyleProp( - tree: unknown, - predicate: (style: Record) => boolean, -): boolean { - if (!tree) return false; - - if (Array.isArray(tree)) { - return tree.some((node) => treeHasStyleProp(node, predicate)); - } - - if (typeof tree !== 'object') return false; - - const node = tree as { - props?: { style?: unknown }; - children?: unknown[]; - }; - - const styles = flattenStyle(node.props?.style); - if (styles.some(predicate)) return true; - - return (node.children ?? []).some((child) => treeHasStyleProp(child, predicate)); -} - describe('ListCell', () => { it('renders a Text component title', () => { render( @@ -288,7 +258,7 @@ describe('ListCell', () => { , ); - expect(screen.getByText('Helper Text')).toBeTruthy(); + expect(screen.getByText(/Helper Text/, { includeHiddenElements: true })).toBeTruthy(); }); it('renders empty strings without crashing', () => { @@ -298,7 +268,7 @@ describe('ListCell', () => { , ); - expect(screen.container).not.toBeNull(); + expect(screen.root).not.toBeNull(); }); it('can set an accessibilityLabel and accessibilityHint when a pressable', () => { diff --git a/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx b/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx index 7aa24522cb..21935b0439 100644 --- a/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx +++ b/packages/mobile/src/cells/__tests__/ListCellFallback.test.tsx @@ -20,7 +20,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-description')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-description', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if detail is passed', () => { @@ -29,7 +31,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-detail')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-detail', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if subdetail is passed', () => { @@ -38,7 +42,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-subdetail')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-subdetail', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if title is passed', () => { @@ -47,7 +53,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-title')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-title', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a MediaFallback component if media is passed', () => { @@ -56,7 +64,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-media')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-media', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders a Fallback component if helperText is passed', () => { @@ -65,7 +75,9 @@ describe('ListCellFallback', () => { , ); - expect(screen.getByTestId('list-cell-fallback-helper-text')).toBeTruthy(); + expect( + screen.getByTestId('list-cell-fallback-helper-text', { includeHiddenElements: true }), + ).toBeTruthy(); }); it('renders ListCellFallback component with innerSpacing and outerSpacing', () => { diff --git a/packages/mobile/src/chips/Chip.tsx b/packages/mobile/src/chips/Chip.tsx index 1fffff312d..46d6164443 100644 --- a/packages/mobile/src/chips/Chip.tsx +++ b/packages/mobile/src/chips/Chip.tsx @@ -71,6 +71,7 @@ export const Chip = memo( paddingX={paddingX} paddingY={paddingY} style={[contentStyle, styles?.content]} + testID={testID ? `${testID}-content` : undefined} > {start} {typeof children === 'string' ? ( diff --git a/packages/mobile/src/chips/ChipProps.ts b/packages/mobile/src/chips/ChipProps.ts index bb71eb559d..1238c0db25 100644 --- a/packages/mobile/src/chips/ChipProps.ts +++ b/packages/mobile/src/chips/ChipProps.ts @@ -1,9 +1,5 @@ -import { type StyleProp, type ViewStyle } from 'react-native'; -import { - type DimensionValue, - type SharedAccessibilityProps, - type SharedProps, -} from '@coinbase/cds-common/types'; +import { type DimensionValue, type StyleProp, type ViewStyle } from 'react-native'; +import { type SharedAccessibilityProps, type SharedProps } from '@coinbase/cds-common/types'; import type { PressableProps } from '../system'; diff --git a/packages/mobile/src/chips/__tests__/Chip.test.tsx b/packages/mobile/src/chips/__tests__/Chip.test.tsx index 5f7fde7fc5..32307cd4ae 100644 --- a/packages/mobile/src/chips/__tests__/Chip.test.tsx +++ b/packages/mobile/src/chips/__tests__/Chip.test.tsx @@ -61,7 +61,7 @@ describe('Chip', () => { it('renders correctly when passing custom styles to contentStyle prop', () => { render(); - expect(screen.getByTestId(chipTestID).children[0]).toHaveStyle(customContentStyle); + expect(screen.getByTestId(`${chipTestID}-content`)).toHaveStyle(customContentStyle); }); it('applies custom styles to root and content', () => { @@ -72,8 +72,7 @@ describe('Chip', () => { render(); - const chip = screen.getByTestId(chipTestID); - expect(chip).toHaveStyle({ borderWidth: 2 }); - expect(chip.children[0]).toHaveStyle({ paddingVertical: 10 }); + expect(screen.getByTestId(chipTestID)).toHaveStyle({ borderWidth: 2 }); + expect(screen.getByTestId(`${chipTestID}-content`)).toHaveStyle({ paddingVertical: 10 }); }); }); diff --git a/packages/mobile/src/coachmark/Coachmark.tsx b/packages/mobile/src/coachmark/Coachmark.tsx index b8dee35f7e..e937c2f5f1 100644 --- a/packages/mobile/src/coachmark/Coachmark.tsx +++ b/packages/mobile/src/coachmark/Coachmark.tsx @@ -1,7 +1,7 @@ import React, { forwardRef, memo } from 'react'; import { useWindowDimensions } from 'react-native'; -import type { View } from 'react-native'; -import { type DimensionValue, type SharedProps } from '@coinbase/cds-common'; +import type { DimensionValue, View } from 'react-native'; +import { type SharedProps } from '@coinbase/cds-common'; import { IconButton } from '../buttons'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -70,13 +70,13 @@ export const Coachmark = memo( return ( {media} diff --git a/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx b/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx index 0e318f1d4a..29f2c08c5f 100644 --- a/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx +++ b/packages/mobile/src/coachmark/__tests__/Coachmark.test.tsx @@ -83,7 +83,7 @@ describe('Coachmark', () => { , ); - expect(screen.getByTestId('remoteimage')).toBeTruthy(); + expect(screen.getByTestId('remoteimage', { includeHiddenElements: true })).toBeTruthy(); }); it('renders with custom width', () => { diff --git a/packages/mobile/src/controls/HelperText.tsx b/packages/mobile/src/controls/HelperText.tsx index 205a86b8e7..275d0413ab 100644 --- a/packages/mobile/src/controls/HelperText.tsx +++ b/packages/mobile/src/controls/HelperText.tsx @@ -17,6 +17,13 @@ export type HelperTextProps = { errorIconAccessibilityLabel?: string; /** Test ID for the error icon */ errorIconTestID?: string; + /** Custom styles for individual elements of the HelperText component */ + styles?: { + /** Root text element */ + root?: TextProps['style']; + /** Error icon element */ + icon?: TextProps['style']; + }; } & TextProps; export const HelperText = memo(function HelperText({ @@ -26,6 +33,8 @@ export const HelperText = memo(function HelperText({ children, align, dangerouslySetColor, + style, + styles, ...props }: HelperTextProps) { const theme = useTheme(); @@ -36,14 +45,22 @@ export const HelperText = memo(function HelperText({ const glyph = glyphMap[glyphKey]; const iconStyle = useMemo( - () => ({ - fontFamily: 'CoinbaseIcons', - fontSize: iconSize, - height: iconSize, - width: iconSize, - letterSpacing: 4, - }), - [iconSize], + () => [ + { + fontFamily: 'CoinbaseIcons', + fontSize: iconSize, + height: iconSize, + width: iconSize, + letterSpacing: 4, + }, + // TODO: when we actually remove dangerouslySetColor: + // when migrating from dangerouslySetColor to style.color, + // root style/className color will not automatically style the error icon like dangerouslySetColor. + // Consumers must set both styles.root and styles.icon (or classNames equivalents). + // We need to have a migrator handle this or document in future migration guide. + styles?.icon, + ], + [iconSize, styles?.icon], ); return ( @@ -52,6 +69,7 @@ export const HelperText = memo(function HelperText({ color={color} dangerouslySetColor={dangerouslySetColor} font="label2" + style={[style, styles?.root]} {...props} > {color === 'fgNegative' && ( diff --git a/packages/mobile/src/controls/InputIconButton.tsx b/packages/mobile/src/controls/InputIconButton.tsx index 26cd0c8440..95d93ac754 100644 --- a/packages/mobile/src/controls/InputIconButton.tsx +++ b/packages/mobile/src/controls/InputIconButton.tsx @@ -12,7 +12,7 @@ export const variantTransformMap: Record = { negative: 'primary', foreground: 'primary', primary: 'primary', - foregroundMuted: 'foregroundMuted', + foregroundMuted: 'secondary', secondary: 'secondary', }; diff --git a/packages/mobile/src/controls/InputStack.tsx b/packages/mobile/src/controls/InputStack.tsx index a0cd2e0a50..b47e783d3d 100644 --- a/packages/mobile/src/controls/InputStack.tsx +++ b/packages/mobile/src/controls/InputStack.tsx @@ -2,6 +2,7 @@ import React, { memo, useMemo } from 'react'; import { Animated, StyleSheet, View } from 'react-native'; import type { StyleProp, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { inputStackGap } from '@coinbase/cds-common/tokens/input'; import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; @@ -88,7 +89,16 @@ export type InputStackBaseProps = SharedProps & { }; export type InputStackProps = Omit & - InputStackBaseProps; + InputStackBaseProps & { + styles?: { + /** Root container element */ + root?: StyleProp; + /** Input horizontal container element */ + inputContainer?: StyleProp; + /** Input area element */ + input?: StyleProp; + }; + }; const variantColorMap: Record = { primary: 'fgPrimary', @@ -123,6 +133,8 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { inputBackground = 'bg', borderWidth = 100, focusedBorderWidth = borderWidth, + styles, + style, ...props } = mergedProps; const theme = useTheme(); @@ -225,10 +237,23 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { inputAreaSize.height, ]); + const rootStyles = useMemo(() => [style, styles?.root], [style, styles?.root]); + + const inputContainerStyles = useMemo( + () => [staticStyles.inputAreaContainerStyle, styles?.inputContainer], + [styles?.inputContainer], + ); + + const combinedInputAreaStyles = useMemo( + () => [inputAreaStyles, styles?.input], + [inputAreaStyles, styles?.input], + ); + return ( {labelNode}} {!!prependNode && <>{prependNode}} - + {focused && } {focused && enableColorSurge && ( @@ -248,7 +273,7 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { )} {!!startNode && <>{startNode}} {!!labelNode && labelVariant === 'inside' ? ( - + {labelNode} {inputNode} @@ -269,9 +294,9 @@ export const InputStack = memo(function InputStack(_props: InputStackProps) { // When `overflow: auto` is set the thickened border when focused is not accounted for // hence you see a cutoff. // Padding must accommodate the focus border extension (default 2px for focusedBorderWidth: 200) -const styles = StyleSheet.create({ +const staticStyles = StyleSheet.create({ inputAreaContainerStyle: { padding: 2, - flexGrow: 1, + flexGrow: 2, }, }); diff --git a/packages/mobile/src/controls/NativeInput.tsx b/packages/mobile/src/controls/NativeInput.tsx index 16473d7a97..48836788da 100644 --- a/packages/mobile/src/controls/NativeInput.tsx +++ b/packages/mobile/src/controls/NativeInput.tsx @@ -86,6 +86,7 @@ export const NativeInput = memo( const containerStyle: ViewStyle = useMemo(() => { return { flex: 2, + minWidth: 0, padding: theme.space[compact ? 1 : 2], ...containerSpacing, ...(!disabled && diff --git a/packages/mobile/src/controls/Radio.tsx b/packages/mobile/src/controls/Radio.tsx index 6313ddae3c..4846edc110 100644 --- a/packages/mobile/src/controls/Radio.tsx +++ b/packages/mobile/src/controls/Radio.tsx @@ -47,11 +47,11 @@ export type RadioProps = RadioBaseProps; const DotSvg = ({ color = 'black', - width = 20, + width, dotSize = (2 * width) / 3, }: { color?: ColorValue; - width?: number; + width: number; dotSize?: number; }) => { return ( @@ -121,13 +121,13 @@ const RadioWithRef = forwardRef(function Radio( : accessibilityLabel; return ( - {...props} ref={ref} accessibilityHint={accessibilityHint} accessibilityLabel={accessibilityLabelValue} accessibilityRole="radio" hitSlop={5} label={children} + {...props} > {RadioIcon} diff --git a/packages/mobile/src/controls/SearchInput.tsx b/packages/mobile/src/controls/SearchInput.tsx index 331f3a0b97..83963a0c09 100644 --- a/packages/mobile/src/controls/SearchInput.tsx +++ b/packages/mobile/src/controls/SearchInput.tsx @@ -1,10 +1,10 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; import type { ForwardedRef } from 'react'; import type { + BlurEvent, + FocusEvent, GestureResponderEvent, - NativeSyntheticEvent, TextInput as RNTextInput, - TextInputFocusEventData, } from 'react-native'; import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import type { IconName } from '@coinbase/cds-common/types'; @@ -116,7 +116,7 @@ export const SearchInput = memo( const refs = useMergeRefs(ref, internalRef); const handleOnFocus = useCallback( - (e: NativeSyntheticEvent) => { + (e: FocusEvent) => { onFocus?.(e); if (!disableBackArrow && startIcon === undefined) { @@ -127,7 +127,7 @@ export const SearchInput = memo( ); const handleOnBlur = useCallback( - (e: NativeSyntheticEvent) => { + (e: BlurEvent) => { onBlur?.(e); if (startIcon === undefined) { diff --git a/packages/mobile/src/controls/SelectContext.tsx b/packages/mobile/src/controls/SelectContext.tsx index 91c48c0bb6..2328417609 100644 --- a/packages/mobile/src/controls/SelectContext.tsx +++ b/packages/mobile/src/controls/SelectContext.tsx @@ -12,9 +12,21 @@ const defaultContext = { const errorMessage = 'SelectContext is undefined. SelectProvider was not found higher up the tree. '; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectContext = createContext(defaultContext); +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectProvider = SelectContext.Provider; +/** + * @deprecated Please use the new Select alpha component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useSelectContext = () => { const context = React.useContext(SelectContext); // TODO: check for something required diff --git a/packages/mobile/src/controls/SelectOption.tsx b/packages/mobile/src/controls/SelectOption.tsx index eef277c9ed..2ba17018a8 100644 --- a/packages/mobile/src/controls/SelectOption.tsx +++ b/packages/mobile/src/controls/SelectOption.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from 'react'; +import { memo, useCallback } from 'react'; import type { GestureResponderEvent } from 'react-native'; import { selectCellMobileSpacingConfig } from '@coinbase/cds-common/tokens/select'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; @@ -26,6 +26,10 @@ export type SelectOptionBaseProps = Omit export type SelectOptionProps = SelectOptionBaseProps; +/** + * @deprecated Please use the new Select alpha component instead. If you are using this component outside of Select, we recommend replacing it with ListCell. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectOption = memo((_props: SelectOptionProps) => { const mergedProps = useComponentConfig('SelectOption', _props); const { diff --git a/packages/mobile/src/controls/Switch.tsx b/packages/mobile/src/controls/Switch.tsx index 5ede932981..5b8d0904ab 100644 --- a/packages/mobile/src/controls/Switch.tsx +++ b/packages/mobile/src/controls/Switch.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, memo, useMemo } from 'react'; -import { StyleSheet, type View } from 'react-native'; +import { type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; @@ -10,10 +10,30 @@ import { Control, type ControlBaseProps, type ControlIconProps } from './Control export type SwitchBaseProps = Omit< ControlBaseProps, - 'style' | 'controlSize' | 'dotSize' + 'controlSize' | 'dotSize' >; -export type SwitchProps = SwitchBaseProps; +export type SwitchProps = SwitchBaseProps & { + /** + * Label content rendered next to the switch control. + * + * @example + * ```tsx + * Dark mode + * ``` + */ + children?: React.ReactNode; + /** Slot-level styles for Switch. */ + styles?: { + /** Persistent outer wrapper across all variants. */ + root?: StyleProp; + /** + * Control wrapper style. + * Applied to the underlying `Control` element (same element that receives `style`). + */ + control?: StyleProp; + }; +}; const SwitchIcon = ({ pressed, @@ -100,29 +120,37 @@ const SwitchWithRef = forwardRef(function SwitchWithRef, ) { const mergedProps = useComponentConfig('Switch', _props); - const { children, ...props } = mergedProps; + const { children, style, styles, ...props } = mergedProps; const theme = useTheme(); const { switchHeight } = theme.controlSize; + const controlStyles = useMemo( + () => StyleSheet.flatten([style, styles?.control]), + [style, styles?.control], + ); const switchNode = ( {SwitchIcon} ); - return children ? ( - + return ( + {switchNode} - ) : ( - switchNode ); }); diff --git a/packages/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index 472e2fcb43..7ee5a3dbd1 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -11,9 +11,8 @@ import React, { import { Pressable } from 'react-native'; import type { ForwardedRef } from 'react'; import type { - NativeSyntheticEvent, + DimensionValue, TextInput as RNTextInput, - TextInputFocusEventData, TextInputProps as RNTextInputProps, ViewStyle, } from 'react-native'; @@ -26,7 +25,6 @@ import type { SharedProps, TextAlignProps, } from '@coinbase/cds-common/types'; -import type { DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { InputVariant } from '@coinbase/cds-common/types/InputBaseProps'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -175,13 +173,13 @@ export const TextInput = memo( focusedBorderWidth, ); - const editableInputAddonProps = { + const editableInputAddonProps: TextInputProps = { ...editableInputProps, - onFocus: (e: NativeSyntheticEvent) => { + onFocus: (e) => { editableInputProps?.onFocus?.(e); setFocused(true); }, - onBlur: (e: NativeSyntheticEvent) => { + onBlur: (e) => { editableInputProps?.onBlur?.(e); setFocused(false); }, @@ -202,7 +200,7 @@ export const TextInput = memo( ...(labelVariant === 'inside' && hasLabel && !compact && { - paddingBottom: 0, + paddingBottom: theme.space[1], paddingTop: 0, }), }), @@ -222,7 +220,8 @@ export const TextInput = memo( const inaccessibleStart = useMemo(() => { if (isValidElement(start) && start.type === InputIconButton) { return cloneElement(start, { - ...start.props, + // ReactElement default props is unknown, so we need to cast to the correct type + ...(start.props as InputIconButtonProps), accessibilityLabel: undefined, accessibilityHint: undefined, accessibilityElementsHidden: true, @@ -315,6 +314,7 @@ export const TextInput = memo( { key={`${variant}-input-iconbutton`} editable={__DEV__} label={variant} - start={ - - } + start={} variant={variant} /> ))} @@ -75,7 +73,7 @@ const AddCustomColor = () => { disableInheritFocusStyle accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } /> @@ -93,7 +91,7 @@ const AddCustomColorEnd = () => { transparent accessibilityLabel="Add" name="add" - variant="foregroundMuted" + variant="secondary" /> } label="Label" @@ -130,7 +128,7 @@ const InputIconButtonScreen = () => { - + diff --git a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx index ecd13f3a29..623c1be205 100644 --- a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx +++ b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx @@ -1,6 +1,5 @@ -import { Pressable } from 'react-native'; import { glyphMap } from '@coinbase/cds-icons/glyphMap'; -import { fireEvent, render, screen, within } from '@testing-library/react-native'; +import { fireEvent, render, screen } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/testHelpers'; @@ -16,15 +15,19 @@ describe('Checkbox', () => { expect(screen.getByTestId('mock-checkbox')).toBeAccessible(); }); - it('renders a Pressable', () => { + it('renders and responds to press', () => { + const onChange = jest.fn(); render( - Checkbox + Checkbox , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - expect(screen.getByText('Checkbox')).toBeTruthy(); + const checkboxText = screen.getByText('Checkbox'); + expect(checkboxText).toBeTruthy(); + + fireEvent.press(checkboxText); + expect(onChange).toHaveBeenCalled(); }); it('renders a check icon when checked', () => { @@ -113,7 +116,7 @@ describe('Checkbox', () => { , ); - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeChecked(); }); it('has accessibility state disabled when disabled', () => { @@ -123,7 +126,7 @@ describe('Checkbox', () => { , ); - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeDisabled(); }); it('disabled checkbox passes a11y', () => { @@ -227,9 +230,8 @@ describe('Checkbox', () => { , ); - const iconBox = screen.getByTestId('checkbox-icon'); - // The icon glyph is inside the Box, find the Text element by role - const iconText = within(iconBox).getByRole('image'); + // Find the check icon glyph text + const iconText = screen.getByText(glyphMap['checkmark-24-inactive']); expect(iconText).toHaveStyle({ color: defaultTheme.lightColor.bgPositive, }); @@ -244,9 +246,8 @@ describe('Checkbox', () => { , ); - const iconBox = screen.getByTestId('checkbox-icon'); - // The icon glyph is inside the Box, find the Text element by role - const iconText = within(iconBox).getByRole('image'); + // Find the minus icon glyph text + const iconText = screen.getByText(glyphMap['minus-24-inactive']); expect(iconText).toHaveStyle({ color: defaultTheme.lightColor.bgWarning, }); diff --git a/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx b/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx index bd762757ab..73fed3ce37 100644 --- a/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx +++ b/packages/mobile/src/controls/__tests__/CheckboxCell.test.tsx @@ -70,7 +70,7 @@ describe('CheckboxCell', () => { ); // CheckboxCell has proper accessibility state (only the main cell should have checked state) - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('checkbox')).toBeChecked(); }); it('shows unchecked state correctly', () => { @@ -86,7 +86,7 @@ describe('CheckboxCell', () => { ); // CheckboxCell has proper accessibility state (only the main cell should have checked state) - expect(screen.queryAllByA11yState({ checked: false })).toHaveLength(1); + expect(screen.getByRole('checkbox')).not.toBeChecked(); }); it('triggers onChange when pressed with correct parameters', () => { @@ -161,8 +161,9 @@ describe('CheckboxCell', () => { , ); - // CheckboxCell has proper accessibility state (both main cell and internal control have disabled state) - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(2); + // CheckboxCell should have disabled accessibility state + const disabledCheckboxes = screen.queryAllByRole('checkbox', { disabled: true }); + expect(disabledCheckboxes.length).toBeGreaterThanOrEqual(1); }); it('attaches testID', () => { @@ -305,9 +306,7 @@ describe('CheckboxCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('checkbox')).toHaveLength(1); - expect(screen.getByTestId('checked-accessible-checkbox')).toHaveAccessibilityState({ - checked: true, - }); + expect(screen.getByTestId('checked-accessible-checkbox')).toBeChecked(); }); it('renders with proper accessibility when disabled', () => { @@ -326,9 +325,7 @@ describe('CheckboxCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('checkbox')).toHaveLength(1); - expect(screen.getByTestId('disabled-accessible-checkbox')).toHaveAccessibilityState({ - disabled: true, - }); + expect(screen.getByTestId('disabled-accessible-checkbox')).toBeDisabled(); }); it('works without description', () => { diff --git a/packages/mobile/src/controls/__tests__/HelperText.test.tsx b/packages/mobile/src/controls/__tests__/HelperText.test.tsx index 0b15288a7a..76f674c2df 100644 --- a/packages/mobile/src/controls/__tests__/HelperText.test.tsx +++ b/packages/mobile/src/controls/__tests__/HelperText.test.tsx @@ -14,17 +14,23 @@ describe('HelperText.test', () => { expect(screen.getByText('Test text')).toBeTruthy(); }); - it('renders custom color', () => { + it('renders custom color and icon styles via style slots', () => { render( - + Test text , ); - expect(screen.getByText('Test text')).toHaveStyle({ color: 'yellow' }); - expect(screen.getByRole('image')).toHaveStyle({ color: 'yellow' }); + expect(screen.getByText(/Test text/)).toHaveStyle({ color: 'yellow' }); + expect(screen.getByText(/Test text/)).toHaveStyle({ marginTop: 8 }); + expect(screen.getByTestId('error-icon')).toHaveStyle({ color: 'yellow' }); }); it('renders custom spacing', () => { diff --git a/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx b/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx index b1353c55d3..bd2b5f1dce 100644 --- a/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx +++ b/packages/mobile/src/controls/__tests__/InputIconButton.test.tsx @@ -9,12 +9,7 @@ describe('InputIconButton', () => { it('passes a11y', () => { render( - + , ); expect(screen.getByTestId(INPUTICONBUTTON_TEST_ID)).toBeAccessible(); @@ -23,12 +18,7 @@ describe('InputIconButton', () => { it('renders an InputIconButton', () => { render( - + , ); expect(screen.getByTestId(INPUTICONBUTTON_TEST_ID)).toBeTruthy(); diff --git a/packages/mobile/src/controls/__tests__/InputStack.test.tsx b/packages/mobile/src/controls/__tests__/InputStack.test.tsx index 40ed602c3c..b9e9de8631 100644 --- a/packages/mobile/src/controls/__tests__/InputStack.test.tsx +++ b/packages/mobile/src/controls/__tests__/InputStack.test.tsx @@ -1,49 +1,11 @@ -import { TextInput as RNTextInput } from 'react-native'; -import TestRenderer from 'react-test-renderer'; import { render, screen } from '@testing-library/react-native'; import { DefaultThemeProvider, theme } from '../../utils/testHelpers'; -import type { InputStackProps } from '../InputStack'; import { InputStack } from '../InputStack'; import { NativeInput } from '../NativeInput'; const TEST_ID = 'input'; -function expectAttribute< - K extends keyof Pick, ->(prop: K, values: readonly NonNullable[]) { - const input = ; - - values.forEach((value) => { - it(`will set "${value}" for \`${prop}\` prop`, async () => { - const inputRenderer = TestRenderer.create( - - - , - ); - - const inputStackInstance = await inputRenderer.root.findByProps({ testID: TEST_ID }); - expect(inputStackInstance.props[prop]).toEqual(value); - }); - }); -} - -describe('width', () => { - expectAttribute('width', ['10%', '50%', '100%']); -}); - -describe('height', () => { - expectAttribute('height', ['10%', '50%', '100%']); -}); - -describe('disabled', () => { - expectAttribute('disabled', [false, true]); -}); - -describe('variant', () => { - expectAttribute('variant', ['foreground', 'foregroundMuted', 'negative', 'positive', 'primary']); -}); - describe('styles', () => { it('renders a custom borderStyle', async () => { const borderStyle = { diff --git a/packages/mobile/src/controls/__tests__/RadioCell.test.tsx b/packages/mobile/src/controls/__tests__/RadioCell.test.tsx index 06a2216e96..ce9145a884 100644 --- a/packages/mobile/src/controls/__tests__/RadioCell.test.tsx +++ b/packages/mobile/src/controls/__tests__/RadioCell.test.tsx @@ -69,8 +69,7 @@ describe('RadioCell', () => { , ); - // The RadioCell should have selected accessibility state - expect(screen.queryAllByA11yState({ selected: true })).toHaveLength(1); // Only the cell + expect(screen.getByRole('radio')).toBeSelected(); }); it('shows unselected state correctly', () => { @@ -85,8 +84,7 @@ describe('RadioCell', () => { , ); - // The RadioCell should have unselected accessibility state - expect(screen.queryAllByA11yState({ selected: false })).toHaveLength(1); // Only the cell + expect(screen.getByRole('radio')).not.toBeSelected(); }); it('triggers onChange when pressed', () => { @@ -140,8 +138,9 @@ describe('RadioCell', () => { , ); - // The RadioCell should have disabled accessibility state (both main cell and internal control have disabled state) - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(2); + // The RadioCell should have disabled accessibility state + const disabledRadios = screen.queryAllByRole('radio', { disabled: true }); + expect(disabledRadios.length).toBeGreaterThanOrEqual(1); }); it('attaches testID', () => { @@ -324,9 +323,7 @@ describe('RadioCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('radio')).toHaveLength(1); - expect(screen.getByTestId('selected-accessible-radio')).toHaveAccessibilityState({ - selected: true, - }); + expect(screen.getByTestId('selected-accessible-radio')).toBeSelected(); }); it('renders with proper accessibility when disabled', () => { @@ -345,9 +342,7 @@ describe('RadioCell', () => { // Should have proper accessibility role and state expect(screen.queryAllByRole('radio')).toHaveLength(1); - expect(screen.getByTestId('disabled-accessible-radio')).toHaveAccessibilityState({ - disabled: true, - }); + expect(screen.getByTestId('disabled-accessible-radio')).toBeDisabled(); }); it('works without description', () => { diff --git a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx index 08644fd326..594f6a293a 100644 --- a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx +++ b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx @@ -15,15 +15,20 @@ describe('Radio', () => { ); expect(screen.getByTestId('mock-radio')).toBeAccessible(); }); - it('renders a Pressable', () => { + + it('renders and responds to press', () => { + const onChange = jest.fn(); render( - Radio + Radio , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); - expect(screen.getByText('Radio')).toBeTruthy(); + const radioText = screen.getByText('Radio'); + expect(radioText).toBeTruthy(); + + fireEvent.press(radioText); + expect(onChange).toHaveBeenCalled(); }); it('renders a dot icon when checked', () => { @@ -65,7 +70,7 @@ describe('Radio', () => { , ); - expect(screen.queryAllByA11yState({ checked: true })).toHaveLength(1); + expect(screen.getByRole('radio')).toBeChecked(); }); it('has accessibility state disabled when disabled', () => { @@ -75,7 +80,7 @@ describe('Radio', () => { , ); - expect(screen.queryAllByA11yState({ disabled: true })).toHaveLength(1); + expect(screen.getByRole('radio')).toBeDisabled(); }); it('Can set custom accessibility label and hints', () => { diff --git a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx index 52a21d2094..9298d4ab17 100644 --- a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx @@ -213,7 +213,7 @@ describe('Search', () => { render(SearchComponent); // This will throw if we find duplicates - expect(screen.getByLabelText(`search`)).toBeAccessible(); + expect(screen.getByLabelText('search', { includeHiddenElements: true })).toBeAccessible(); }); it('announces the Back arrow icon button', () => { @@ -238,13 +238,17 @@ describe('Search', () => { it('renders a close icon button at the end node', () => { render(SearchComponent); - expect(screen.getByTestId(`${TEST_ID}-close-iconbtn`)).toBeDefined(); + expect( + screen.getByTestId(`${TEST_ID}-close-iconbtn`, { includeHiddenElements: true }), + ).toBeDefined(); }); it('fires `onSearch` when search btn is pressed', () => { render(SearchComponent); - fireEvent.press(screen.getByTestId(`${TEST_ID}-searchinput-iconbtn`)); + fireEvent.press( + screen.getByTestId(`${TEST_ID}-searchinput-iconbtn`, { includeHiddenElements: true }), + ); expect(onSearchSpy).toHaveBeenCalled(); }); @@ -252,7 +256,9 @@ describe('Search', () => { it('fires `onClear` when clear btn is pressed', () => { render(SearchComponent); - fireEvent.press(screen.getByTestId(`${TEST_ID}-close-iconbtn`)); + fireEvent.press( + screen.getByTestId(`${TEST_ID}-close-iconbtn`, { includeHiddenElements: true }), + ); expect(onClearSpy).toHaveBeenCalled(); }); diff --git a/packages/mobile/src/controls/__tests__/Switch.test.tsx b/packages/mobile/src/controls/__tests__/Switch.test.tsx index 88a9c9950b..06e85a21f4 100644 --- a/packages/mobile/src/controls/__tests__/Switch.test.tsx +++ b/packages/mobile/src/controls/__tests__/Switch.test.tsx @@ -3,7 +3,7 @@ import { Text, View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, treeHasStyleProp } from '../../utils/testHelpers'; import { Switch } from '../Switch'; describe('Switch.test', () => { @@ -28,11 +28,11 @@ describe('Switch.test', () => { ); expect(screen.getByText('checked is false')).toBeTruthy(); - expect(screen.getByRole('switch')).toHaveAccessibilityState({ checked: false }); + expect(screen.getByRole('switch')).not.toBeChecked(); fireEvent.press(screen.getByRole('switch')); expect(screen.getByText('checked is true')).toBeTruthy(); - expect(screen.getByRole('switch')).toHaveAccessibilityState({ checked: true }); + expect(screen.getByRole('switch')).toBeChecked(); }); it('passes accessibility', () => { @@ -129,6 +129,30 @@ describe('Switch.test', () => { expect(screen.getByTestId('test-test-id')).toBeTruthy(); }); + it('keeps a stable root wrapper regardless of label presence', () => { + const { toJSON, rerender } = render( + + + , + ); + + const treeWithoutLabel = toJSON(); + expect(treeWithoutLabel).toBeTruthy(); + expect(Array.isArray(treeWithoutLabel)).toBe(false); + expect(treeWithoutLabel).toHaveProperty('type', 'View'); + + rerender( + + with label + , + ); + + const treeWithLabel = toJSON(); + expect(treeWithLabel).toBeTruthy(); + expect(Array.isArray(treeWithLabel)).toBe(false); + expect(treeWithLabel).toHaveProperty('type', 'View'); + }); + it('has default palette', () => { render( @@ -177,4 +201,40 @@ describe('Switch.test', () => { backgroundColor: defaultTheme.lightColor.bgTertiary, }); }); + + it('applies styles.root', () => { + const { toJSON } = render( + + + label + + , + ); + + const tree = toJSON(); + expect(treeHasStyleProp(tree, (s) => s.borderTopWidth === 1)).toBe(true); + }); + + it('applies styles.control and preserves style prop behavior', () => { + const { toJSON } = render( + + + , + ); + + const tree = toJSON(); + expect(treeHasStyleProp(tree, (s) => s.borderLeftWidth === 4)).toBe(true); + expect(treeHasStyleProp(tree, (s) => s.borderRightWidth === 5)).toBe(true); + }); }); diff --git a/packages/mobile/src/controls/__tests__/TextInput.test.tsx b/packages/mobile/src/controls/__tests__/TextInput.test.tsx index 28d582204a..97d6450872 100644 --- a/packages/mobile/src/controls/__tests__/TextInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/TextInput.test.tsx @@ -390,11 +390,11 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(startNode).toHaveTextContent('Compact Label'); - expect(screen.getByText('Compact Label')).toBeTruthy(); + expect(screen.getByText('Compact Label', { includeHiddenElements: true })).toBeTruthy(); }); it('renders labelNode without compact', () => { @@ -452,8 +452,8 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); - const customLabel = screen.getByTestId(labelTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(customLabel).toBeTruthy(); expect(customLabel).toHaveTextContent('Custom Label Node'); @@ -515,8 +515,8 @@ describe('TextInput', () => { , ); - const customLabel = screen.getByTestId(labelTestID); - const startContent = screen.getByTestId(startTestID); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); + const startContent = screen.getByTestId(startTestID, { includeHiddenElements: true }); expect(customLabel).toBeTruthy(); expect(startContent).toBeTruthy(); }); @@ -539,12 +539,12 @@ describe('TextInput', () => { , ); - const startNode = screen.getByTestId(startTestID); - const customLabel = screen.getByTestId(labelTestID); + const startNode = screen.getByTestId(startTestID, { includeHiddenElements: true }); + const customLabel = screen.getByTestId(labelTestID, { includeHiddenElements: true }); expect(startNode).toBeTruthy(); expect(customLabel).toBeTruthy(); expect(customLabel).toHaveTextContent('Custom Label Node'); - expect(screen.queryByText('Regular Label')).toBeFalsy(); + expect(screen.queryByText('Regular Label', { includeHiddenElements: true })).toBeFalsy(); }); it('positions label correctly with inside variant and start content', () => { @@ -561,8 +561,8 @@ describe('TextInput', () => { , ); - const label = screen.getByTestId('label-test'); - const startContent = screen.getByTestId('start-content'); + const label = screen.getByTestId('label-test', { includeHiddenElements: true }); + const startContent = screen.getByTestId('start-content', { includeHiddenElements: true }); expect(label).toBeTruthy(); expect(startContent).toBeTruthy(); diff --git a/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx b/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx index f09eb96cac..82f0e08e94 100644 --- a/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx +++ b/packages/mobile/src/controls/__tests__/useControlMotionProps.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useControlMotionProps } from '../useControlMotionProps'; diff --git a/packages/mobile/src/dates/DateInput.tsx b/packages/mobile/src/dates/DateInput.tsx index 59a9ed9a78..e736909458 100644 --- a/packages/mobile/src/dates/DateInput.tsx +++ b/packages/mobile/src/dates/DateInput.tsx @@ -1,11 +1,11 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; import { + type BlurEvent, type NativeSyntheticEvent, type StyleProp, type TextInput as NativeTextInput, type TextInputChangeEventData, type TextInputEndEditingEventData, - type TextInputFocusEventData, type ViewStyle, } from 'react-native'; import { IntlDateFormat } from '@coinbase/cds-common/dates/IntlDateFormat'; @@ -89,7 +89,7 @@ export const DateInput = memo( */ const handleBlur = useCallback( - (event: NativeSyntheticEvent) => { + (event: BlurEvent) => { onBlur?.(event); if (!required || !hasTyped.current) return; const error = validateDateInput(inputValue); @@ -121,7 +121,6 @@ export const DateInput = memo( ); diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index ac073f2919..5d5732bd23 100644 --- a/packages/mobile/src/dates/DatePicker.tsx +++ b/packages/mobile/src/dates/DatePicker.tsx @@ -232,7 +232,6 @@ export const DatePicker = memo( {showPicker && ( { openCalendarAccessibilityLabel="Birthdate calendar" /> - + diff --git a/packages/mobile/src/dates/__tests__/Calendar.test.tsx b/packages/mobile/src/dates/__tests__/Calendar.test.tsx index f0ee9fb76b..239f2306ab 100644 --- a/packages/mobile/src/dates/__tests__/Calendar.test.tsx +++ b/packages/mobile/src/dates/__tests__/Calendar.test.tsx @@ -86,13 +86,14 @@ describe('Calendar', () => { it('renders days of the week', () => { render(); - // Check for first letter of each day - const sLetters = screen.getAllByText('S'); + // Weekday header row uses aria-hidden; include hidden elements to assert visual labels exist. + const hidden = { includeHiddenElements: true } as const; + const sLetters = screen.getAllByText('S', hidden); expect(sLetters.length).toBeGreaterThanOrEqual(2); // Sunday and Saturday (plus potentially dates) - expect(screen.getByText('M')).toBeTruthy(); - expect(screen.getAllByText('T').length).toBeGreaterThanOrEqual(1); // Tuesday and Thursday - expect(screen.getByText('W')).toBeTruthy(); - expect(screen.getByText('F')).toBeTruthy(); + expect(screen.getByText('M', hidden)).toBeTruthy(); + expect(screen.getAllByText('T', hidden).length).toBeGreaterThanOrEqual(1); // Tuesday and Thursday + expect(screen.getByText('W', hidden)).toBeTruthy(); + expect(screen.getByText('F', hidden)).toBeTruthy(); }); it('handles disabled state correctly', () => { @@ -249,14 +250,15 @@ describe('Calendar', () => { it('days of week header is not accessible to screen readers', () => { render(); - // The days of week header HStack should have accessible={false} - // This is tested indirectly by checking the structure const calendar = screen.getByTestId(testID); expect(calendar).toBeTruthy(); - // Days of week letters should still be present in the DOM - expect(screen.getAllByText('S').length).toBeGreaterThan(0); - expect(screen.getAllByText('M').length).toBeGreaterThan(0); + // Header row is aria-hidden: excluded from default queries (a11y tree) but still rendered. + expect(screen.queryAllByText('S')).toHaveLength(0); + expect(screen.queryAllByText('M')).toHaveLength(0); + const hidden = { includeHiddenElements: true } as const; + expect(screen.getAllByText('S', hidden).length).toBeGreaterThan(0); + expect(screen.getAllByText('M', hidden).length).toBeGreaterThan(0); }); it('respects minDate and disables dates before it', () => { diff --git a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx index 40a311fac6..4b06d9b95c 100644 --- a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx +++ b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx @@ -75,10 +75,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - // Calendar should be visible - await waitFor(() => { - expect(screen.getByText('Confirm')).toBeTruthy(); - }); + screen.getByText('Confirm'); }); it('closes calendar when handle bar is pressed', async () => { @@ -90,9 +87,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('Confirm')).toBeTruthy(); - }); + screen.getByText('Confirm'); // Close calendar via handle bar using testID const handleBar = screen.getByTestId('handleBar'); @@ -119,9 +114,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByLabelText('Custom close label')).toBeTruthy(); - }); + expect(screen.getByLabelText('Custom close label')).toBeTruthy(); }); it('displays confirm button with custom text', async () => { @@ -131,9 +124,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('Done')).toBeTruthy(); - }); + expect(screen.getByText('Done')).toBeTruthy(); }); it('confirm button is disabled when no date is selected', async () => { @@ -143,10 +134,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - const confirmButton = screen.getByRole('button', { name: 'Confirm' }); - expect(confirmButton).toBeDisabled(); - }); + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); }); it('confirm button has custom accessibility hint', async () => { @@ -161,10 +150,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - const confirmButton = screen.getByRole('button', { name: 'Confirm' }); - expect(confirmButton).toHaveProp('accessibilityHint', 'Custom confirm button hint'); - }); + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toHaveProp('accessibilityHint', 'Custom confirm button hint'); }); it('confirm button is enabled after selecting a date from calendar', async () => { @@ -175,9 +162,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -212,9 +197,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -268,9 +251,7 @@ describe('DatePicker', () => { expect(mockOnOpen).toHaveBeenCalledTimes(1); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); - }); + screen.getByRole('button', { name: 'Confirm' }); // Close calendar using testID const handleBar = screen.getByTestId('handleBar'); @@ -301,10 +282,8 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - // Should show June 2024 (the month of the current date) - expect(screen.getByText('June 2024')).toBeTruthy(); - }); + // Should show June 2024 (the month of the current date) + expect(screen.getByText('June 2024')).toBeTruthy(); }); it('passes disabled state to DateInput and Calendar', () => { @@ -324,9 +303,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Previous month arrow should be disabled since minDate is in current month const prevArrow = screen.getByLabelText('Go to previous month'); @@ -342,9 +319,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Next month arrow should be disabled since maxDate is in current month const nextArrow = screen.getByLabelText('Go to next month'); @@ -360,9 +335,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Check that the calendar is rendered (specific dates being disabled is tested in Calendar.test.tsx) const allButtons = screen.getAllByRole('button'); @@ -447,9 +420,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -483,9 +454,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -512,9 +481,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + expect(screen.getByText('July 2024')).toBeTruthy(); }); it('passes navigation accessibility labels to Calendar', async () => { @@ -530,9 +497,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByLabelText('Next month custom')).toBeTruthy(); - }); + expect(screen.getByLabelText('Next month custom')).toBeTruthy(); expect(screen.getByLabelText('Previous month custom')).toBeTruthy(); }); @@ -552,9 +517,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByText('July 2024')).toBeTruthy(); - }); + screen.getByText('July 2024'); // Select a date const july15Button = screen.getByLabelText(/15.*July.*2024/); @@ -589,9 +552,7 @@ describe('DatePicker', () => { const calendarButton = screen.getByLabelText('Open calendar'); fireEvent.press(calendarButton); - await waitFor(() => { - expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); - }); + screen.getByRole('button', { name: 'Confirm' }); // Try to press disabled confirm button const confirmButton = screen.getByRole('button', { name: 'Confirm' }); diff --git a/packages/mobile/src/dots/DotCount.tsx b/packages/mobile/src/dots/DotCount.tsx index 85f739082c..8ee702decc 100644 --- a/packages/mobile/src/dots/DotCount.tsx +++ b/packages/mobile/src/dots/DotCount.tsx @@ -56,8 +56,6 @@ const [opacityEnter, opacityExit, scaleEnter, scaleExit] = convertMotionConfigs( dotScaleExitConfig, ]); -const dotTextPaddingHorizontal = 6; - const variantColorMap: Record = { negative: 'bgNegative', }; @@ -87,6 +85,18 @@ export type DotCountBaseProps = SharedProps & children?: React.ReactNode; /** Indicates what shape Dot is overlapping */ overlap?: DotOverlap; + /** + * An optional fixed height of the DotCount component. + * Width grows based on content length. + * @default 24 + * */ + height?: number; + /** + * An optional fixed width of the DotCount component. + * By default, width grows based on content length. + * @default auto + * */ + width?: number; }; export type DotCountProps = DotCountBaseProps & { @@ -110,6 +120,8 @@ export const DotCount = memo((_props: DotCountProps) => { variant = 'negative', count, max, + height = dotCountSize, + width, overlap, style, styles, @@ -119,7 +131,7 @@ export const DotCount = memo((_props: DotCountProps) => { const [childrenSize, onChildrenLayout] = useDotsLayout(); const transforms = useDotPinStyles( childrenSize, - { width: dotCountSize + dotTextPaddingHorizontal, height: dotCountSize } as LayoutRectangle, + { width: width ?? height, height } as LayoutRectangle, overlap, ); @@ -145,11 +157,17 @@ export const DotCount = memo((_props: DotCountProps) => { return [ styleSheet.container, { + height, + minWidth: height, + width, + paddingHorizontal: theme.space[0.75], + borderWidth: theme.borderWidth[100], + borderRadius: theme.borderRadius[400], borderColor: theme.color.bgSecondary, backgroundColor: theme.color[variantColorMap[variant]], }, ]; - }, [theme.color, variant]); + }, [height, width, theme.space, theme.borderWidth, theme.borderRadius, theme.color, variant]); // avoid displaying 0 during animations and preserve exit animation useEffect(() => { @@ -191,11 +209,6 @@ export const DotCount = memo((_props: DotCountProps) => { [containerStyles, animatedStyles, styles?.container], ); - const textStyles = useMemo( - () => [{ paddingHorizontal: dotTextPaddingHorizontal }, styles?.text], - [styles?.text], - ); - const rootStyles = useMemo(() => [style, styles?.root], [styles?.root, style]); // only check childrenSize when children is defined @@ -209,7 +222,7 @@ export const DotCount = memo((_props: DotCountProps) => { {!shouldUnmount && shouldShow && ( - + {parseDotCountMaxOverflow(countInternal, max)} @@ -224,9 +237,5 @@ const styleSheet = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', display: 'flex', - borderWidth: 1, - minWidth: dotCountSize, - height: dotCountSize, - borderRadius: 16, }, }); diff --git a/packages/mobile/src/dots/DotSymbol.tsx b/packages/mobile/src/dots/DotSymbol.tsx index 58681fea03..ca7dc17254 100644 --- a/packages/mobile/src/dots/DotSymbol.tsx +++ b/packages/mobile/src/dots/DotSymbol.tsx @@ -123,7 +123,7 @@ export const DotSymbol = memo((_props: DotSymbolProps) => { const shouldShow = children !== undefined ? childrenSize !== null : true; return ( - + {children} diff --git a/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx b/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx index 7c6461ed55..0b03dbdf16 100644 --- a/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx +++ b/packages/mobile/src/dots/__tests__/DotSymbol.test.tsx @@ -40,7 +40,9 @@ describe('DotSymbol', () => { nativeEvent: { layout: { height: 12, width: 12 } }, }); - expect(screen.getByTestId('dotsymbol-remote-image').props.source).toEqual({ uri: src }); + expect( + screen.getByTestId('dotsymbol-remote-image', { includeHiddenElements: true }).props.source, + ).toEqual({ uri: src }); }); it('renders an image when source is a string', () => { @@ -55,7 +57,9 @@ describe('DotSymbol', () => { nativeEvent: { layout: { height: 12, width: 12 } }, }); - expect(screen.getByTestId('dotsymbol-remote-image').props.source).toEqual({ uri: src }); + expect( + screen.getByTestId('dotsymbol-remote-image', { includeHiddenElements: true }).props.source, + ).toEqual({ uri: src }); }); it('passes a11y for DotSymbol that have a children', () => { diff --git a/packages/mobile/src/examples/ExampleScreen.tsx b/packages/mobile/src/examples/ExampleScreen.tsx index 08482319aa..1abca8d9e6 100644 --- a/packages/mobile/src/examples/ExampleScreen.tsx +++ b/packages/mobile/src/examples/ExampleScreen.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react'; +import React, { createContext, type JSX, useCallback, useContext, useMemo, useRef } from 'react'; import { ScrollView } from 'react-native'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; import type { PaddingProps } from '@coinbase/cds-common/types'; @@ -34,7 +34,7 @@ export const Example = ({ const { registerExample } = useContext(ExampleContext); // Register exactly once during first render - const exampleNumberRef = useRef(); + const exampleNumberRef = useRef(undefined); if (exampleNumberRef.current === undefined) { exampleNumberRef.current = registerExample(); } diff --git a/packages/mobile/src/gradients/LinearGradient.tsx b/packages/mobile/src/gradients/LinearGradient.tsx index fcc80a32b5..01c262699f 100644 --- a/packages/mobile/src/gradients/LinearGradient.tsx +++ b/packages/mobile/src/gradients/LinearGradient.tsx @@ -46,9 +46,10 @@ type LinearGradientProps = { */ colors: NonNullable[]; /** - * @deprecated Please use the elevated prop instead. This will be removed in a future major release. - * @deprecationExpectedRemoval v6 * Sets layout position between SVG and children. Set it to false when gradient should overlay children content. + * + * @deprecated Use the `elevated` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v6 * @default true */ isBelowChildren?: boolean; diff --git a/packages/mobile/src/hooks/__tests__/constants.ts b/packages/mobile/src/hooks/__tests__/constants.ts deleted file mode 100644 index 5f6db193d9..0000000000 --- a/packages/mobile/src/hooks/__tests__/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const mockStatusBarHeight = 20; diff --git a/packages/mobile/src/hooks/__tests__/useA11y.test.ts b/packages/mobile/src/hooks/__tests__/useA11y.test.ts index ade93a0a97..3629ffa283 100644 --- a/packages/mobile/src/hooks/__tests__/useA11y.test.ts +++ b/packages/mobile/src/hooks/__tests__/useA11y.test.ts @@ -1,5 +1,5 @@ import { AccessibilityInfo } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useA11y } from '../useA11y'; diff --git a/packages/mobile/src/hooks/__tests__/useAppState.test.ts b/packages/mobile/src/hooks/__tests__/useAppState.test.ts index 2f21f2ebfb..cec7c72d8d 100644 --- a/packages/mobile/src/hooks/__tests__/useAppState.test.ts +++ b/packages/mobile/src/hooks/__tests__/useAppState.test.ts @@ -1,46 +1,59 @@ -import type { AppStateStatus } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { AppState, type AppStateStatus } from 'react-native'; +import { renderHook } from '@testing-library/react-native'; import { useAppState } from '../useAppState'; describe('useAppState', () => { - const removeListenerSpy = jest.fn(); - const addListenerSpy = jest.fn(() => { - return { - remove: removeListenerSpy, - }; + const mockRemoveListener = jest.fn(); + let addEventListenerSpy: jest.SpyInstance; + const originalCurrentState = AppState.currentState; + + beforeEach(() => { + jest.clearAllMocks(); + addEventListenerSpy = jest.spyOn(AppState, 'addEventListener').mockReturnValue({ + remove: mockRemoveListener, + }); + }); + + afterEach(() => { + addEventListenerSpy.mockRestore(); + Object.defineProperty(AppState, 'currentState', { + value: originalCurrentState, + writable: true, + configurable: true, + }); }); - const mockCurrentAppState = (state: AppStateStatus) => { - jest.resetModules(); - jest.doMock('react-native/Libraries/AppState/AppState', () => ({ - currentState: state, - addEventListener: addListenerSpy, - })); + const mockCurrentState = (state: AppStateStatus) => { + Object.defineProperty(AppState, 'currentState', { + value: state, + writable: true, + configurable: true, + }); }; it('returns AppState.currentState - active', () => { - mockCurrentAppState('active'); + mockCurrentState('active'); const { result } = renderHook(() => useAppState()); expect(result.current).toBe('active'); }); it('returns AppState.currentState - inactive', () => { - mockCurrentAppState('inactive'); + mockCurrentState('inactive'); const { result } = renderHook(() => useAppState()); expect(result.current).toBe('inactive'); }); it('adds an event listener for state changes', () => { - mockCurrentAppState('active'); + mockCurrentState('active'); renderHook(() => useAppState()); - expect(addListenerSpy).toHaveBeenCalled(); + expect(addEventListenerSpy).toHaveBeenCalled(); }); it('removes event listener on unmount', () => { - mockCurrentAppState('inactive'); + mockCurrentState('inactive'); const { unmount } = renderHook(() => useAppState()); unmount(); - expect(removeListenerSpy).toHaveBeenCalled(); + expect(mockRemoveListener).toHaveBeenCalled(); }); }); diff --git a/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts b/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts index b1c711fe34..38fb2f878a 100644 --- a/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts +++ b/packages/mobile/src/hooks/__tests__/useCellSpacing.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { innerDefaults, outerDefaults, useCellSpacing } from '../useCellSpacing'; diff --git a/packages/mobile/src/hooks/__tests__/useDimension.test.ts b/packages/mobile/src/hooks/__tests__/useDimension.test.ts index 9ebaf6a1ad..29f18283c8 100644 --- a/packages/mobile/src/hooks/__tests__/useDimension.test.ts +++ b/packages/mobile/src/hooks/__tests__/useDimension.test.ts @@ -1,10 +1,20 @@ -import { renderHook } from '@testing-library/react-hooks'; +import React from 'react'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { renderHook } from '@testing-library/react-native'; import { useDimensions } from '../useDimensions'; +const safeAreaInitialMetrics = { + frame: { x: 0, y: 0, width: 0, height: 0 }, + insets: { top: 20, left: 0, right: 0, bottom: 0 }, +}; + describe('useDimensions.test', () => { it('returns screen dimensions', () => { - const { result } = renderHook(() => useDimensions()); + const { result } = renderHook(() => useDimensions(), { + wrapper: ({ children }) => + React.createElement(SafeAreaProvider, { initialMetrics: safeAreaInitialMetrics }, children), + }); expect(result.current.screenHeight).toBe(1334); expect(result.current.screenWidth).toBe(750); diff --git a/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts b/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts index 8fced6da5a..d13b989be9 100644 --- a/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts +++ b/packages/mobile/src/hooks/__tests__/useHorizontalScrollToTarget.test.ts @@ -5,25 +5,26 @@ import type { ScrollView, View, } from 'react-native'; -import { act, renderHook } from '@testing-library/react-hooks'; +import { act, renderHook } from '@testing-library/react-native'; import throttle from 'lodash/throttle'; import { useHorizontalScrollToTarget } from '../useHorizontalScrollToTarget'; jest.mock('lodash/throttle'); +type ThrottledMock = jest.Mock & { cancel: jest.Mock }; + describe('useHorizontalScrollToTarget', () => { let mockScrollView: ScrollView; let mockActiveTarget: View; - let throttledFn: jest.Mock; + let throttledFn: ThrottledMock; beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); // Mock throttle to return the function immediately - throttledFn = jest.fn(); - // @ts-expect-error - Testing internal ref assignment + throttledFn = jest.fn() as ThrottledMock; throttledFn.cancel = jest.fn(); (throttle as jest.Mock).mockImplementation((fn) => { throttledFn.mockImplementation(fn); @@ -78,7 +79,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -93,7 +93,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(400); result.current.handleScrollContainerLayout({ @@ -108,7 +107,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -125,7 +123,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -142,7 +139,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -159,7 +155,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -176,7 +171,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 10 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -207,7 +201,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -228,7 +221,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -243,7 +235,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -260,7 +251,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContainerLayout({ nativeEvent: { layout: { width: 500 } }, @@ -275,7 +265,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContainerLayout({ nativeEvent: { layout: { width: 500 } }, @@ -289,13 +278,14 @@ describe('useHorizontalScrollToTarget', () => { describe('active target scrolling', () => { it('should scroll to active target when offscreen left', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -307,7 +297,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -319,13 +308,14 @@ describe('useHorizontalScrollToTarget', () => { }); it('should scroll to active target when offscreen right', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -337,7 +327,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -349,13 +338,14 @@ describe('useHorizontalScrollToTarget', () => { }); it('should not scroll when target is visible', () => { - const { result, rerender } = renderHook( - ({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), - { initialProps: { activeTarget: null } }, - ); + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null } + >(({ activeTarget }) => useHorizontalScrollToTarget({ activeTarget }), { + initialProps: { activeTarget: null }, + }); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -367,7 +357,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget }); expect(mockActiveTarget.measureLayout).toHaveBeenCalled(); @@ -375,14 +364,16 @@ describe('useHorizontalScrollToTarget', () => { }); it('should use autoScrollOffset when scrolling', () => { - const { result, rerender } = renderHook( + const { result, rerender } = renderHook< + ReturnType, + { activeTarget: View | null; autoScrollOffset: number } + >( ({ activeTarget, autoScrollOffset }) => useHorizontalScrollToTarget({ activeTarget, autoScrollOffset }), { initialProps: { activeTarget: null, autoScrollOffset: 0 } }, ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ @@ -394,7 +385,6 @@ describe('useHorizontalScrollToTarget', () => { }); }); - // @ts-expect-error - Type inference issue with renderHook rerender({ activeTarget: mockActiveTarget, autoScrollOffset: 20 }); expect(mockScrollView.scrollTo).toHaveBeenCalledWith({ @@ -408,7 +398,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ activeTarget: null })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; }); @@ -421,7 +410,6 @@ describe('useHorizontalScrollToTarget', () => { ); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = null; }); @@ -435,7 +423,6 @@ describe('useHorizontalScrollToTarget', () => { unmount(); - // @ts-expect-error - Testing internal ref assignment expect(throttledFn.cancel).toHaveBeenCalled(); }); }); @@ -445,7 +432,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(500); result.current.handleScrollContainerLayout({ @@ -463,7 +449,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget()); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(300); result.current.handleScrollContainerLayout({ @@ -481,7 +466,6 @@ describe('useHorizontalScrollToTarget', () => { const { result } = renderHook(() => useHorizontalScrollToTarget({ overflowThreshold: 1 })); act(() => { - // @ts-expect-error - Testing internal ref assignment result.current.scrollRef.current = mockScrollView; result.current.handleScrollContentSizeChange(1000); result.current.handleScrollContainerLayout({ diff --git a/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts b/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts index 07d35ef10b..00ead4cc79 100644 --- a/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts +++ b/packages/mobile/src/hooks/__tests__/useInputBorderStyle.test.ts @@ -1,5 +1,5 @@ import { focusedInputBorderWidth, inputBorderWidth } from '@coinbase/cds-common/tokens/input'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { useInputBorderAnimation } from '../useInputBorderAnimation'; diff --git a/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts b/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts index 24602fbab5..7a76bdc6d2 100644 --- a/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts +++ b/packages/mobile/src/hooks/__tests__/usePressAnimation.test.ts @@ -1,6 +1,6 @@ import { act } from 'react'; import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { usePressAnimation } from '../usePressAnimation'; diff --git a/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts b/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts index 533dfe17c1..1e7d045c44 100644 --- a/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts +++ b/packages/mobile/src/hooks/__tests__/useScreenReaderStatus.test.ts @@ -1,5 +1,5 @@ import { AccessibilityInfo } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, waitFor } from '@testing-library/react-native'; import { useScreenReaderStatus } from '../useScreenReaderStatus'; @@ -15,9 +15,10 @@ describe('useScreenReaderStatus', () => { it('should return true when screen reader is enabled', async () => { (AccessibilityInfo.isScreenReaderEnabled as jest.Mock).mockResolvedValueOnce(true); - const { result, waitForNextUpdate } = renderHook(() => useScreenReaderStatus()); - await waitForNextUpdate(); - expect(result.current).toBe(true); + const { result } = renderHook(() => useScreenReaderStatus()); + await waitFor(() => { + expect(result.current).toBe(true); + }); }); it('should return false when screen reader is disabled', () => { diff --git a/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts b/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts index 238403be11..1d1564d95a 100644 --- a/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts +++ b/packages/mobile/src/hooks/__tests__/useScrollOffset.test.ts @@ -1,5 +1,5 @@ import { act } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useScrollOffset } from '../useScrollOffset'; diff --git a/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx b/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx index 08e037b741..9aa3ac2471 100644 --- a/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx +++ b/packages/mobile/src/hooks/__tests__/useScrollTo.test.tsx @@ -1,7 +1,6 @@ import { useCallback } from 'react'; import { ScrollView } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react-native'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../buttons'; import { Box } from '../../layout'; diff --git a/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts b/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts deleted file mode 100644 index b7a17e5eeb..0000000000 --- a/packages/mobile/src/hooks/__tests__/useStatusBarHeight.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { StatusBar } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; - -import { useStatusBarHeight } from '../useStatusBarHeight'; - -import { mockStatusBarHeight } from './constants'; - -describe('useStatusBarHeight.test', () => { - beforeEach(() => { - jest.resetModules(); - }); - - it('returns status bar height', () => { - const { result } = renderHook(() => useStatusBarHeight()); - - expect(result.current).toBe(mockStatusBarHeight); - }); - - it('returns default status bar height on android', () => { - jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ OS: 'android' })); - - const { result } = renderHook(() => useStatusBarHeight()); - - expect(result.current).toBe(StatusBar.currentHeight); - }); -}); diff --git a/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx b/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx index e53b5ed759..f57725c8ba 100644 --- a/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx +++ b/packages/mobile/src/hooks/__tests__/useWebBrowserOpener.test.tsx @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import * as openWebBrowser from '../../utils/openWebBrowser'; diff --git a/packages/mobile/src/hooks/useA11y.ts b/packages/mobile/src/hooks/useA11y.ts index e10497ba90..e6c9d6f7fa 100644 --- a/packages/mobile/src/hooks/useA11y.ts +++ b/packages/mobile/src/hooks/useA11y.ts @@ -17,7 +17,7 @@ import { AccessibilityInfo, findNodeHandle } from 'react-native'; * */ export const useA11y = () => { - const setA11yFocus = useCallback((ref: React.RefObject) => { + const setA11yFocus = useCallback((ref: React.RefObject) => { // TODO: Migrate this to fabric supported API const reactTag = findNodeHandle(ref.current as React.Component); if (reactTag) { diff --git a/packages/mobile/src/hooks/useAppState.ts b/packages/mobile/src/hooks/useAppState.ts index a398a333e2..50f2cb7935 100644 --- a/packages/mobile/src/hooks/useAppState.ts +++ b/packages/mobile/src/hooks/useAppState.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from 'react'; -import { AppState } from 'react-native'; -import type { AppStateStatus } from 'react-native'; +import { AppState, type AppStateStatus } from 'react-native'; export const useAppState = () => { const [appState, setAppState] = useState(AppState.currentState); diff --git a/packages/mobile/src/hooks/useDimensions.ts b/packages/mobile/src/hooks/useDimensions.ts index 82fa91b25b..51ed8fc44c 100644 --- a/packages/mobile/src/hooks/useDimensions.ts +++ b/packages/mobile/src/hooks/useDimensions.ts @@ -1,6 +1,5 @@ import { useWindowDimensions } from 'react-native'; - -import { useStatusBarHeight } from './useStatusBarHeight'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; // The bottom Navigation bar height needs the be accounted for but could not find a lib to help with this. export const IOS_BOTTOM_NAV_BAR_HEIGHT = 50; @@ -8,7 +7,7 @@ export const IOS_BOTTOM_NAV_BAR_HEIGHT = 50; // This is the beginning of our new dimensions hook. It will build on the old retail `useDimensions` hook. export function useDimensions() { const { height: screenHeight, width: screenWidth } = useWindowDimensions(); - const statusBarHeight = useStatusBarHeight(); + const { top: statusBarHeight } = useSafeAreaInsets(); return { screenHeight, screenWidth, diff --git a/packages/mobile/src/hooks/useDotPinStyles.ts b/packages/mobile/src/hooks/useDotPinStyles.ts index 6ec4f30719..f5ac07027b 100644 --- a/packages/mobile/src/hooks/useDotPinStyles.ts +++ b/packages/mobile/src/hooks/useDotPinStyles.ts @@ -1,8 +1,32 @@ import type { LayoutRectangle } from 'react-native'; import type { DotOverlap } from '@coinbase/cds-common'; +/** Valid keys for accessing pin position offsets. */ export type DotPinStylesKey = 'end' | 'start' | 'bottom' | 'top'; +/** + * Calculates positioning offsets for pinning a dot badge to the edges of its parent element. + * + * Returns transform offsets that position the dot so it overlaps the edge by half its size. + * Used by DotCount, DotSymbol, and DotStatusColor to position badges at corners like "top-end". + * + * @param childrenSize - Measured dimensions of the parent/host element + * @param dotSize - Dimensions of the dot badge (number for uniform size, or LayoutRectangle) + * @param overlap - When 'circular', pulls offsets inward to better align with circular parents (e.g., avatars) + * @returns Object with edge offsets { end, start, bottom, top }, or null if dimensions unavailable + * + * @example + * ```tsx + * const transforms = useDotPinStyles(childrenSize, dotSize, overlap); + * // For pin="top-end", use: + * const style = { + * transform: [ + * { translateX: transforms.end }, + * { translateY: transforms.top } + * ] + * }; + * ``` + */ export const useDotPinStyles = ( childrenSize: LayoutRectangle | null = null, dotSize: LayoutRectangle | number | null = null, diff --git a/packages/mobile/src/hooks/useHasNotch.ts b/packages/mobile/src/hooks/useHasNotch.ts index 1cc3065ffa..3b6073288b 100644 --- a/packages/mobile/src/hooks/useHasNotch.ts +++ b/packages/mobile/src/hooks/useHasNotch.ts @@ -1,8 +1,11 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; +/** + * @deprecated This logic is seriously outdated. The last iPhone version to have a 20px status bar was iPhone 8. Most modern iOS devices no longer have a "notch". This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useHasNotch = () => { const { top } = useSafeAreaInsets(); - // we choose to hide the statusbar on iOS for devices with a notch, which - // has a top inset of more than 20. + // older iphones without a notch (or island for current phones) have a status bar of 20px return top > 20; }; diff --git a/packages/mobile/src/hooks/useScrollTo.ts b/packages/mobile/src/hooks/useScrollTo.ts index 3c536f9ea3..bf68a558b7 100644 --- a/packages/mobile/src/hooks/useScrollTo.ts +++ b/packages/mobile/src/hooks/useScrollTo.ts @@ -18,7 +18,7 @@ export type ScrollToFns = { }; export const useScrollTo = (ref?: AnyRef): [ScrollRef, ScrollToFns] => { - const internalRef = useRef(); + const internalRef = useRef(undefined); const scrollRef = useMergeRefs(ref, internalRef); const scrollTo = useCallback(({ x = 0, y = 0, animated = true }: ScrollToParams) => { internalRef.current?.scrollTo({ x, y, animated }); diff --git a/packages/mobile/src/hooks/useStatusBarHeight.ts b/packages/mobile/src/hooks/useStatusBarHeight.ts index c125b82c52..5404619f1f 100644 --- a/packages/mobile/src/hooks/useStatusBarHeight.ts +++ b/packages/mobile/src/hooks/useStatusBarHeight.ts @@ -1,16 +1,9 @@ -import { useEffect, useState } from 'react'; -import { NativeEventEmitter, NativeModules, Platform, StatusBar } from 'react-native'; -import type { NativeModule } from 'react-native'; - -const { StatusBarManager } = NativeModules; - -type StatusBarNativeModule = { - getHeight: (arg1: ({ height }: { height: number }) => void) => void; -} & NativeModule; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; /** * @deprecated Use `useSafeAreaInsets().top` from `react-native-safe-area-context` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v9 + * * This approach is recommended by Expo and provides more reliable values across platforms. * @see https://docs.expo.dev/versions/latest/sdk/safe-area-context/ * @@ -24,27 +17,6 @@ type StatusBarNativeModule = { * const statusBarHeight = insets.top; */ export const useStatusBarHeight = () => { - const [statusBarHeight, setStatusBarHeight] = useState(); - - useEffect(() => { - if (Platform.OS === 'ios' && StatusBarManager !== undefined) { - const statusBarManager = StatusBarManager as StatusBarNativeModule; - const emitter = new NativeEventEmitter(statusBarManager); - - statusBarManager.getHeight(({ height }: { height: number }) => setStatusBarHeight(height)); - - const subscription = emitter.addListener( - 'statusBarFrameWillChange', - ({ frame: { height } }: { frame: { height: number } }) => { - setStatusBarHeight(height); - }, - ); - - return () => subscription.remove(); - } - setStatusBarHeight(StatusBar.currentHeight); - return () => {}; - }, []); - - return statusBarHeight; + const { top } = useSafeAreaInsets(); + return top; }; diff --git a/packages/mobile/src/icons/Icon.tsx b/packages/mobile/src/icons/Icon.tsx index 6a0e9517fa..3dd6e7d42d 100644 --- a/packages/mobile/src/icons/Icon.tsx +++ b/packages/mobile/src/icons/Icon.tsx @@ -47,7 +47,10 @@ export type IconBaseProps = SharedProps & * @default primary */ color?: ThemeVars.Color; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style`, `styles.icon`, or the `color` prop to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string | Animated.AnimatedInterpolation; animated?: boolean; style?: Animated.WithAnimatedValue>; @@ -161,7 +164,8 @@ export const Icon = memo((_props: IconProps) => { accessibilityRole="image" accessible={!!accessibilityLabel} allowFontScaling={false} - style={iconStyle} + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + style={iconStyle as StyleProp} > {glyph} diff --git a/packages/mobile/src/icons/TextIcon.tsx b/packages/mobile/src/icons/TextIcon.tsx index 7e372b243b..684ce895db 100644 --- a/packages/mobile/src/icons/TextIcon.tsx +++ b/packages/mobile/src/icons/TextIcon.tsx @@ -53,7 +53,8 @@ export const TextIcon = memo(function TextIcon({ color: iconColor, }, style, - ] as TextStyle, + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + ] as StyleProp, [style, iconColor, iconSize], ); diff --git a/packages/mobile/src/jest.d.ts b/packages/mobile/src/jest.d.ts index 4ffd9e6b26..034d98878c 100644 --- a/packages/mobile/src/jest.d.ts +++ b/packages/mobile/src/jest.d.ts @@ -1,3 +1,50 @@ /// -/// -/// + +/** + * Custom accessibility matcher type declaration. + * Replaces the react-native-accessibility-engine types. + */ + +type AccessibilityViolation = { + pathToComponent: string[]; + problem: string; + solution: string; + link: string; +}; + +type AccessibilityOptions = { + /** Specific rule IDs to check. If not provided, all rules are checked. */ + rules?: string[]; + /** Custom handler to filter or modify violations before the assertion. */ + customViolationHandler?: (violations: AccessibilityViolation[]) => AccessibilityViolation[]; +}; + +type AccessibilityMatchers = { + /** + * Check if a component is accessible according to React Native accessibility rules. + * + * @param options - Optional configuration for accessibility checks + * @example + * expect(screen.getByTestId('my-button')).toBeAccessible(); + * expect(screen.getByTestId('my-button')).toBeAccessible({ + * customViolationHandler: (violations) => violations.filter(v => v.problem !== 'some problem') + * }); + */ + toBeAccessible(options?: AccessibilityOptions): R; +}; + +// Implicit Jest global `expect`. +declare global { + namespace jest { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface Matchers extends AccessibilityMatchers {} + } +} + +// Explicit `@jest/globals` `expect` matchers. +declare module '@jest/expect' { + // eslint-disable-next-line no-restricted-syntax, @typescript-eslint/no-empty-object-type + interface Matchers> extends AccessibilityMatchers {} +} + +export {}; diff --git a/packages/mobile/src/layout/Box.tsx b/packages/mobile/src/layout/Box.tsx index cc5a35facc..0a70083eb7 100644 --- a/packages/mobile/src/layout/Box.tsx +++ b/packages/mobile/src/layout/Box.tsx @@ -1,6 +1,6 @@ import React, { forwardRef, memo, useMemo } from 'react'; import { Animated, type StyleProp, View, type ViewProps, type ViewStyle } from 'react-native'; -import type { PinningDirection } from '@coinbase/cds-common'; +import type { PinningDirection, SharedProps } from '@coinbase/cds-common'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { ElevationLevels } from '@coinbase/cds-common/types/ElevationLevels'; @@ -9,34 +9,36 @@ import { useTheme } from '../hooks/useTheme'; import { pinStyles } from '../styles/pinStyles'; import { getStyles, type StyleProps } from '../styles/styleProps'; -export type BoxBaseProps = StyleProps & { - children?: React.ReactNode; - style?: Animated.WithAnimatedValue>; - animated?: boolean; - /** Determines box shadow styles. Parent should have overflow set to visible to ensure styles are not clipped. */ - elevation?: ElevationLevels; - font?: ThemeVars.FontFamily | 'inherit'; - /** Direction in which to absolutely pin the box. */ - pin?: PinningDirection; - /** Add a border around all sides of the box. */ - bordered?: boolean; - /** Add a border to the top side of the box. */ - borderedTop?: boolean; - /** Add a border to the bottom side of the box. */ - borderedBottom?: boolean; - /** Add a border to the leading side of the box. */ - borderedStart?: boolean; - /** Add a border to the trailing side of the box. */ - borderedEnd?: boolean; - /** Add a border to the leading and trailing sides of the box. */ - borderedHorizontal?: boolean; - /** Add a border to the top and bottom sides of the box. */ - borderedVertical?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ - dangerouslySetBackground?: string; - /** Used to locate this element in unit and end-to-end tests. */ - testID?: string; -}; +export type BoxBaseProps = SharedProps & + StyleProps & { + children?: React.ReactNode; + style?: Animated.WithAnimatedValue>; + animated?: boolean; + /** Determines box shadow styles. Parent should have overflow set to visible to ensure styles are not clipped. */ + elevation?: ElevationLevels; + font?: ThemeVars.FontFamily | 'inherit'; + /** Direction in which to absolutely pin the box. */ + pin?: PinningDirection; + /** Add a border around all sides of the box. */ + bordered?: boolean; + /** Add a border to the top side of the box. */ + borderedTop?: boolean; + /** Add a border to the bottom side of the box. */ + borderedBottom?: boolean; + /** Add a border to the leading side of the box. */ + borderedStart?: boolean; + /** Add a border to the trailing side of the box. */ + borderedEnd?: boolean; + /** Add a border to the leading and trailing sides of the box. */ + borderedHorizontal?: boolean; + /** Add a border to the top and bottom sides of the box. */ + borderedVertical?: boolean; + /** + * @deprecated Use `style` or the `background` style prop to set custom background colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + dangerouslySetBackground?: string; + }; export type BoxProps = BoxBaseProps & Omit; @@ -396,7 +398,8 @@ export const Box = memo( ); return ( - + // TODO https://linear.app/coinbase/issue/CDS-1518/audit-potentially-harmful-reactnative-animated-pattern + } testID={testID} {...props}> {children} ); diff --git a/packages/mobile/src/layout/Fallback.tsx b/packages/mobile/src/layout/Fallback.tsx index 2dca933473..ce3547abeb 100644 --- a/packages/mobile/src/layout/Fallback.tsx +++ b/packages/mobile/src/layout/Fallback.tsx @@ -20,7 +20,7 @@ export type FallbackBaseProps = { * @default rectangle */ shape?: Shape; - width: number | string; + width: DimensionValue; /** Disables randomization of rectangle shape width. */ disableRandomRectWidth?: boolean; /** @@ -51,7 +51,11 @@ export const Fallback = memo((_props: FallbackProps) => { [disableRandomRectWidth, rectWidthVariant], ); - const { width, borderRadius } = useFallbackShape(shape, baseWidth, fallbackShapeOptions); + const { width, borderRadius } = useFallbackShape( + shape, + baseWidth, + fallbackShapeOptions, + ); const { activeColorScheme } = useTheme(); const shimmerColor = fallbackShimmer[activeColorScheme]; diff --git a/packages/mobile/src/layout/Group.tsx b/packages/mobile/src/layout/Group.tsx index 3fc7beddcb..e44a9b390f 100644 --- a/packages/mobile/src/layout/Group.tsx +++ b/packages/mobile/src/layout/Group.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { forwardRef, memo, type ReactElement, useMemo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -33,11 +33,11 @@ export type GroupBaseProps = BoxProps & { */ renderItem?: (info: { Wrapper: React.ComponentType>; - item: React.ReactChild; + item: ReactElement | string | number; index: number; isFirst: boolean; isLast: boolean; - }) => React.ReactChild; + }) => ReactElement | string | number; }; export type RenderGroupItem = GroupBaseProps['renderItem']; diff --git a/packages/mobile/src/layout/Spacer.tsx b/packages/mobile/src/layout/Spacer.tsx index b8fe22a8be..4954336872 100644 --- a/packages/mobile/src/layout/Spacer.tsx +++ b/packages/mobile/src/layout/Spacer.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { Animated, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; @@ -42,16 +42,14 @@ export const Spacer = memo(function Spacer({ minHorizontal, minVertical, animated, + style, ...viewProps }: SpacerProps) { const theme = useTheme(); const Component = animated ? Animated.View : View; - - return ( - + [ getSpacerStyle({ flexGrow, flexShrink, @@ -63,8 +61,23 @@ export const Spacer = memo(function Spacer({ minHorizontal, minVertical, spacingScaleValues: theme.space, - }) as ViewStyle - } - /> + }) as ViewStyle, + style, + ] as StyleProp, + [ + flexGrow, + flexShrink, + flexBasis, + horizontal, + vertical, + maxHorizontal, + maxVertical, + minHorizontal, + minVertical, + theme.space, + style, + ], ); + + return ; }); diff --git a/packages/mobile/src/layout/__tests__/Box.test.tsx b/packages/mobile/src/layout/__tests__/Box.test.tsx index 0623231ac2..2bb907643e 100644 --- a/packages/mobile/src/layout/__tests__/Box.test.tsx +++ b/packages/mobile/src/layout/__tests__/Box.test.tsx @@ -140,7 +140,7 @@ describe('Box', () => { it('renders width styles', async () => { render( - + Child , ); @@ -150,7 +150,7 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - width: '321px', + width: 321, maxWidth: 789, minWidth: '66%', }); @@ -158,7 +158,7 @@ describe('Box', () => { it('renders height styles', async () => { render( - + Child , ); @@ -168,7 +168,7 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - height: '321px', + height: 321, maxHeight: 789, minHeight: '66%', }); @@ -177,12 +177,12 @@ describe('Box', () => { it('renders position styles', async () => { render( Child @@ -194,11 +194,11 @@ describe('Box', () => { expect(screen.getByTestId('parent')).toBeAccessible(); expect(screen.getByTestId('parent')).toHaveStyle({ - bottom: '8rem', + bottom: 8, left: '1000%', position: 'absolute', - right: '30px', - top: '25%', + right: 30, + top: 25, zIndex: 7, }); }); diff --git a/packages/mobile/src/media/Avatar.tsx b/packages/mobile/src/media/Avatar.tsx index a24a6e492f..d2eb37e4a4 100644 --- a/packages/mobile/src/media/Avatar.tsx +++ b/packages/mobile/src/media/Avatar.tsx @@ -1,6 +1,8 @@ import React, { memo, useMemo } from 'react'; import { StyleSheet } from 'react-native'; +import { ClipPath, Defs, Path, Rect, Svg } from 'react-native-svg'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { hexagonShapePath } from '@coinbase/cds-common/svg/shape'; import { colorSchemeMap } from '@coinbase/cds-common/tokens/avatar'; import type { AvatarFallbackColor, @@ -22,6 +24,7 @@ import { shapeStyles } from './RemoteImageGroup'; const smallAvatarSize = 44; export const coloredFallbackTestID = 'cds-avatar-colored-fallback'; +const avatarHexagonClipPathId = 'cds-avatar-hexagon-fallback-clip-path'; export const fallbackImageSrc = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD//gA7Q1JFQVRPUjogZ2QtanBlZyB2MS4wICh1c2luZyBJSkcgSlBFRyB2NjIpLCBxdWFsaXR5ID0gOTAK/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcGCAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwEDBAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgAOAA4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+t80Zo4o4oAM0ZrsPBvw7m8Sxi7uZDa2GcKwHzyeu30HvXdf8Kp0Dytnlz7v+ennHP8Ah+lAHiuaM12XjL4dTeG4jd2sjXViDhiR88f1x1HvXG8UAGaKOKKADI9Kkt4vtFxFEOC7Bc/U4qPJ9KVXZGDLwwOQaAPpS0tY7G1ht4VCRRKEVcdABipc1meG9eh8RaRBeRMNzACRB1R+4/z2rU/OgCK5t47y3lgmUPFKpR1I6gjBr5uu4fst1NCefLdkz64OK+g/EWuQ+HtJnvJmHyjCIerv2Ar55klaWRnblmJJPqaAG5HpRRk+lFABzVnTtOutWvY7W0iMs8hwqj+Z9BVbB9a9o+GXhlNI0VL2VR9rvFD5PVY/4R+PX8vSgCfwX4EXwsDNJdSTXTrh1RisQ/Dv9T+QrrP89aT8qPyoA5Txr4FHilRNHdSQ3Ua4RHYtEfw7fUfrXjOo6dc6Tey2t3EYp4zgqf5j1FfSP5VxvxN8Mpq+jPfRKPtlmpfI6tH/ABD8Ov5+tAHi/NFGDRQBY060+3aja22f9dKsf5kD+tfSKIsaKigKqjAAHAFFFAC/j+lL+P6UUUAH4/pTXRZEZGG5WGCCOooooA+btRtPsOoXVtn/AFMrR/kSKKKKAP/Z'; @@ -107,8 +110,8 @@ export const Avatar = memo((_props: AvatarProps) => { return ( {placeholderLetter} @@ -119,8 +122,8 @@ export const Avatar = memo((_props: AvatarProps) => { return ( {placeholderLetter} @@ -131,8 +134,8 @@ export const Avatar = memo((_props: AvatarProps) => { return ( {placeholderLetter} @@ -151,10 +154,9 @@ export const Avatar = memo((_props: AvatarProps) => { () => ( @@ -164,11 +166,40 @@ export const Avatar = memo((_props: AvatarProps) => { [avatarText, shapeStyle, colorSchemeRgb], ); + const hexagonColoredFallback = useMemo( + () => ( + + + + + + + + + + {avatarText} + + ), + [avatarText, colorSchemeRgb], + ); + return ( { testID={`${testID ?? ''}-image`} width={computedSize} /> + ) : shape === 'hexagon' ? ( + hexagonColoredFallback ) : ( coloredFallback )} @@ -211,4 +244,13 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + hexagonFallbackLabel: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + }, }); diff --git a/packages/mobile/src/media/Carousel/Carousel.tsx b/packages/mobile/src/media/Carousel/Carousel.tsx index 63020b8098..ab0b2b3ef8 100644 --- a/packages/mobile/src/media/Carousel/Carousel.tsx +++ b/packages/mobile/src/media/Carousel/Carousel.tsx @@ -155,7 +155,6 @@ export const Carousel = memo( return ( {content} diff --git a/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx b/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx index f5360bb595..a63979ef29 100644 --- a/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx +++ b/packages/mobile/src/media/Carousel/__tests__/useCarouselItem.test.tsx @@ -1,6 +1,5 @@ import { useCallback } from 'react'; -import { renderHook } from '@testing-library/react-hooks'; -import { cleanup, fireEvent, render, screen } from '@testing-library/react-native'; +import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../../buttons'; import { Box } from '../../../layout'; diff --git a/packages/mobile/src/media/RemoteImage.tsx b/packages/mobile/src/media/RemoteImage.tsx index 922520956b..e514a40bf6 100644 --- a/packages/mobile/src/media/RemoteImage.tsx +++ b/packages/mobile/src/media/RemoteImage.tsx @@ -13,6 +13,7 @@ import type { import { ClipPath, Defs, Image as SvgImage, Path, Svg, SvgXml } from 'react-native-svg'; import { SvgCssUri } from 'react-native-svg/css'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { hexagonShapePath } from '@coinbase/cds-common/svg/shape'; import type { AspectRatio, AvatarSize, FixedValue, Shape } from '@coinbase/cds-common/types'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -44,7 +45,7 @@ type BaseRemoteImageProps = Omit { return ( - + - + {image} @@ -230,11 +231,11 @@ export const RemoteImage = memo(function RemoteImage(_props: RemoteImageProps) { ); } @@ -288,8 +289,8 @@ export const RemoteImage = memo(function RemoteImage(_props: RemoteImageProps) { onError={onError} onLoad={onLoad} source={transformedSource as ImageSourcePropType} - {...props} style={stylesWithDimensions} + {...props} /> ); }); diff --git a/packages/mobile/src/media/RemoteImageGroup.tsx b/packages/mobile/src/media/RemoteImageGroup.tsx index 3e126c6005..2e27001e01 100644 --- a/packages/mobile/src/media/RemoteImageGroup.tsx +++ b/packages/mobile/src/media/RemoteImageGroup.tsx @@ -90,15 +90,22 @@ export const RemoteImageGroup = (_props: RemoteImageGroupProps) => { if (!isValidElement(child)) { return null; } - const childShape: RemoteImageProps['shape'] = child.props.shape; + + const childShape: RemoteImageProps['shape'] = ( + child as React.ReactElement + ).props.shape; // dynamically apply uniform sizing and shape to all RemoteImage children elements - const clonedChild = React.cloneElement(child as React.ReactElement, { - testID: `${testID ? `${testID}-` : ''}image-${index}`, - width: sizeAsNumber, - height: sizeAsNumber, - ...(childShape ? undefined : { shape }), - }); + const clonedChild = React.cloneElement( + // the type of child (after isValidElement check) is not inferred so it must be typecast here + child as React.ReactElement, + { + testID: `${testID ? `${testID}-` : ''}image-${index}`, + width: sizeAsNumber, + height: sizeAsNumber, + ...(childShape ? undefined : { shape }), + }, + ); // zIndex is progressively lower so that each child is stacked below the previous one const zIndex = -index; diff --git a/packages/mobile/src/media/__stories__/Avatar.stories.tsx b/packages/mobile/src/media/__stories__/Avatar.stories.tsx index d11a525b85..c9fd9af00c 100644 --- a/packages/mobile/src/media/__stories__/Avatar.stories.tsx +++ b/packages/mobile/src/media/__stories__/Avatar.stories.tsx @@ -77,6 +77,7 @@ const AvatarScreen = () => { accessibilityLabel="" alt="" borderColor="bgPositive" + borderWidth={0} size={size} src={image} /> diff --git a/packages/mobile/src/media/__tests__/Avatar.test.tsx b/packages/mobile/src/media/__tests__/Avatar.test.tsx index ef7b6096fa..913042fb57 100644 --- a/packages/mobile/src/media/__tests__/Avatar.test.tsx +++ b/packages/mobile/src/media/__tests__/Avatar.test.tsx @@ -16,12 +16,10 @@ describe('Avatar', () => { , ); - const image = screen.getByTestId('avatar-image'); + const image = screen.getByTestId('avatar-image', { includeHiddenElements: true }); expect(image).toBeTruthy(); expect(image?.props.source).toEqual({ uri: src }); - expect(image).toBeAccessible(); - expect(screen.queryByText('T')).toBeFalsy(); }); @@ -148,6 +146,25 @@ describe('Avatar', () => { expect(screen.getByText('T')).toBeTruthy(); }); + it('renders a hexagon fallback when shape is hexagon and name is provided without src', async () => { + render( + + + , + ); + + await screen.findByTestId(coloredFallbackTestID); + + expect(screen.getByTestId(coloredFallbackTestID)).toBeAccessible(); + expect(screen.getByText('T')).toBeTruthy(); + }); + it('applies provider config defaults', () => { const config: ComponentConfig = { Avatar: { diff --git a/packages/mobile/src/media/__tests__/RemoteImage.test.tsx b/packages/mobile/src/media/__tests__/RemoteImage.test.tsx index e158b8ec78..7cf54d2779 100644 --- a/packages/mobile/src/media/__tests__/RemoteImage.test.tsx +++ b/packages/mobile/src/media/__tests__/RemoteImage.test.tsx @@ -12,7 +12,7 @@ const mockSvgFetch = async () => ); describe('RemoteImage', () => { - it('shouldApplyDarkModeEnhacements border styles takes precedence over custom borderColor and passes a11y', () => { + it('shouldApplyDarkModeEnhacements border styles takes precedence over custom borderColor', () => { render( { /> , ); - const image = screen.queryByTestId('remoteimage'); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toBeTruthy(); - expect(image).toBeAccessible(); - expect(image).toHaveStyle({ borderWidth: 1, }); }); - it('darkModeEnhancementsApplied border styles takes precedence over custom borderColor and passes a11y', () => { + it('darkModeEnhancementsApplied border styles takes precedence over custom borderColor', () => { render( { /> , ); - const image = screen.queryByTestId('remoteimage'); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toBeTruthy(); - expect(image).toBeAccessible(); - expect(image).toHaveStyle({ borderWidth: 1, }); }); - it('has a default shape of square and passes a11y', () => { + it('has a default shape of square', () => { render( , ); - const image = screen.queryByTestId('remoteimage'); - - expect(image).toBeAccessible(); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toHaveStyle({ borderRadius: defaultTheme.borderRadius[100], }); }); - it('if width/height/size is not set, it will default to size = m. Passes a11y', () => { + it('if width/height/size is not set, it will default to size = m', () => { render( , ); - const image = screen.queryByTestId('remoteimage'); - - expect(image).toBeAccessible(); + const image = screen.getByTestId('remoteimage', { includeHiddenElements: true }); expect(image).toHaveStyle({ width: theme.avatarSize.m, @@ -134,9 +126,9 @@ describe('RemoteImage', () => { , ); - expect(screen.getByRole('image')).toHaveProp('accessibilityElementsHidden', false); - expect(screen.getByRole('image')).toHaveProp('importantForAccessibility', 'auto'); - expect(screen.getByLabelText('A label')).toBeTruthy(); + const image = screen.getByLabelText('A label'); + expect(image).toHaveProp('accessibilityElementsHidden', false); + expect(image).toHaveProp('importantForAccessibility', 'auto'); expect(screen.getByHintText('A hint')).toBeTruthy(); }); diff --git a/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx b/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx index 5e85c6784b..08ee974b54 100644 --- a/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx +++ b/packages/mobile/src/media/__tests__/RemoteImageGroup.test.tsx @@ -69,7 +69,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 24, @@ -84,7 +86,9 @@ describe('RemoteImageGroup', () => { await screen.findByTestId(TEST_ID); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ borderRadius: defaultTheme.borderRadius[1000], @@ -104,7 +108,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 30, @@ -117,7 +123,9 @@ describe('RemoteImageGroup', () => { render(); remoteImageIndices.forEach((index) => { - const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`); + const remoteImage = screen.getByTestId(`${TEST_ID}-image-${index}`, { + includeHiddenElements: true, + }); expect(remoteImage).toHaveStyle({ width: 32, diff --git a/packages/mobile/src/motion/__tests__/Pulse.test.tsx b/packages/mobile/src/motion/__tests__/Pulse.test.tsx index d7ff7a4023..d3cd727139 100644 --- a/packages/mobile/src/motion/__tests__/Pulse.test.tsx +++ b/packages/mobile/src/motion/__tests__/Pulse.test.tsx @@ -63,7 +63,7 @@ describe('Pulse', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; stop: () => Promise; - }>; + } | null>; render( Children diff --git a/packages/mobile/src/motion/__tests__/Shake.test.tsx b/packages/mobile/src/motion/__tests__/Shake.test.tsx index 69cb40adb4..3812700e11 100644 --- a/packages/mobile/src/motion/__tests__/Shake.test.tsx +++ b/packages/mobile/src/motion/__tests__/Shake.test.tsx @@ -62,7 +62,7 @@ describe('Shake', () => { it('exposes imperative handlers that start the animation', () => { const ref = { current: null } as React.RefObject<{ play: () => Promise; - }>; + } | null>; render( Children diff --git a/packages/mobile/src/navigation/BrowserBarSearchInput.tsx b/packages/mobile/src/navigation/BrowserBarSearchInput.tsx index 489cd85020..7c14acee76 100644 --- a/packages/mobile/src/navigation/BrowserBarSearchInput.tsx +++ b/packages/mobile/src/navigation/BrowserBarSearchInput.tsx @@ -1,5 +1,5 @@ import { memo, useCallback } from 'react'; -import type { NativeSyntheticEvent, TextInputFocusEventData } from 'react-native'; +import type { BlurEvent, FocusEvent } from 'react-native'; import { SearchInput, type SearchInputProps } from '../controls/SearchInput'; @@ -29,7 +29,7 @@ export const BrowserBarSearchInput = memo( const { setHideStart, setHideEnd } = useBrowserBarContext(); const handleFocus = useCallback( - (e: NativeSyntheticEvent) => { + (e: FocusEvent) => { if (expandOnFocus) { setHideStart(true); setHideEnd(true); @@ -40,7 +40,7 @@ export const BrowserBarSearchInput = memo( ); const handleBlur = useCallback( - (e: NativeSyntheticEvent) => { + (e: BlurEvent) => { setHideEnd(false); setHideStart(false); onBlur?.(e); diff --git a/packages/mobile/src/navigation/NavigationTitleSelect.tsx b/packages/mobile/src/navigation/NavigationTitleSelect.tsx index 23dec815f4..0acc943b69 100644 --- a/packages/mobile/src/navigation/NavigationTitleSelect.tsx +++ b/packages/mobile/src/navigation/NavigationTitleSelect.tsx @@ -1,8 +1,8 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; +import { selectCellMobileSpacingConfig } from '@coinbase/cds-common/tokens/select'; -import { SelectProvider } from '../controls/SelectContext'; -import { SelectOption } from '../controls/SelectOption'; -import { useSelect } from '../controls/useSelect'; +import { Cell } from '../cells/Cell'; +import { CellAccessory } from '../cells/CellAccessory'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons'; import { HStack } from '../layout/HStack'; @@ -40,16 +40,18 @@ export const NavigationTitleSelect = memo((_props: NavigationTitleSelectProps) = setVisible(true); }, []); - const handleOptionPress = useCallback(() => { - trayRef.current?.handleClose(); - }, []); + const handleOptionPress = useCallback( + (id: string) => { + trayRef.current?.handleClose(); + onChange(id); + }, + [onChange], + ); const label = useMemo(() => { return options.find((option) => option.id === value)?.label; }, [options, value]); - const selectContextValue = useSelect({ onChange, value }); - return ( <> @@ -66,11 +68,26 @@ export const NavigationTitleSelect = memo((_props: NavigationTitleSelectProps) = {visible && ( - - {options.map(({ id, label }) => ( - - ))} - + {options.map(({ id, label }) => { + const selected = id === value; + return ( + : undefined} + borderRadius={0} + onPress={() => handleOptionPress(id)} + selected={id === value} + {...selectCellMobileSpacingConfig} + > + {!!label && ( + + {label} + + )} + + ); + })} )} diff --git a/packages/mobile/src/navigation/TopNavBar.tsx b/packages/mobile/src/navigation/TopNavBar.tsx index 90bacfba17..946fc6840c 100644 --- a/packages/mobile/src/navigation/TopNavBar.tsx +++ b/packages/mobile/src/navigation/TopNavBar.tsx @@ -166,7 +166,7 @@ export const TopNavBar = memo((_props: NavigationBarProps) => { paddingBottom={paddingBottom} paddingTop={paddingTop} paddingX={paddingX} - position="sticky" + position="absolute" right={0} top={0} width="100%" diff --git a/packages/mobile/src/overlays/Alert.tsx b/packages/mobile/src/overlays/Alert.tsx index 70723e80c0..4e1a3cdb2f 100644 --- a/packages/mobile/src/overlays/Alert.tsx +++ b/packages/mobile/src/overlays/Alert.tsx @@ -1,9 +1,8 @@ import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo } from 'react'; -import { Modal as RNModal } from 'react-native'; +import { Modal as RNModal, type ViewStyle } from 'react-native'; import type { ButtonVariant, IllustrationPictogramNames, - PositionStyles, SharedProps, } from '@coinbase/cds-common/types'; @@ -18,7 +17,6 @@ import { Overlay } from './overlay/Overlay'; import { useAlertAnimation } from './useAlertAnimation'; export type AlertBaseProps = SharedProps & - Pick & Pick & { /** * Alert title @@ -58,6 +56,7 @@ export type AlertBaseProps = SharedProps & * @default horizontal */ actionLayout?: 'horizontal' | 'vertical'; + zIndex?: ViewStyle['zIndex']; }; export type AlertProps = AlertBaseProps; @@ -190,7 +189,7 @@ export const Alert = memo( { if (!nodes.length) return null; @@ -46,6 +51,16 @@ export const PortalHost = memo(({ nodes }: PortalHostProps) => { return <>{elements}; }); +/** + * Required root-level provider that enables CDS overlay components (Modal, Toast, Alert, + * Tooltip, Tray). Manages the registry of active overlays and provides the context for + * overlay state management and toast queuing. + * + * Unlike the PortalProvider in cds-web, cds-mobile does not use DOM portals. Overlay components render + * above other content using React Native's native Modal component. + * + * Must be rendered once near the root of your application, alongside ThemeProvider. + */ export const PortalProvider: React.FC> = ({ children, toastBottomOffset = 0, @@ -63,6 +78,11 @@ export const PortalProvider: React.FC { const { nodes } = usePortal(); return ; diff --git a/packages/mobile/src/overlays/Toast.tsx b/packages/mobile/src/overlays/Toast.tsx index 25010fe5f4..b37ab905a2 100644 --- a/packages/mobile/src/overlays/Toast.tsx +++ b/packages/mobile/src/overlays/Toast.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, memo, useCallback, useEffect, useImperativeHandle } from 'react'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import type { ToastBaseProps as CommonToastBaseProps, ToastRefHandle, @@ -8,7 +9,6 @@ import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Button } from '../buttons'; import { useA11y } from '../hooks/useA11y'; import { useComponentConfig } from '../hooks/useComponentConfig'; -import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps, HStack } from '../layout'; import { ColorSurge } from '../motion/ColorSurge'; import { Text } from '../typography/Text'; @@ -32,7 +32,7 @@ export const Toast = memo( accessibilityLabel, ...props } = mergedProps; - const theme = useTheme(); + const { bottom: safeAreaBottom } = useSafeAreaInsets(); const [{ opacity, bottom }, animateIn, animateOut] = useToastAnimation(); const { announceForA11y } = useA11y(); const defaultA11yLabel = text + (action ? action.label : ''); @@ -79,8 +79,9 @@ export const Toast = memo( return ( - + diff --git a/packages/mobile/src/overlays/__tests__/Toast.test.tsx b/packages/mobile/src/overlays/__tests__/Toast.test.tsx index e3a1a8592b..57aace5792 100644 --- a/packages/mobile/src/overlays/__tests__/Toast.test.tsx +++ b/packages/mobile/src/overlays/__tests__/Toast.test.tsx @@ -1,7 +1,8 @@ import { Animated } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; import { fireEvent, render, screen } from '@testing-library/react-native'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../utils/testHelpers'; import { Toast } from '../Toast'; jest.mock('react-native/Libraries/Animated/Animated', () => { @@ -31,9 +32,11 @@ describe('Toast', () => { it('renders text and passes a11y', () => { const text = 'Toast copy'; render( - - - , + + + + + , ); expect(screen.getByTestId('mock-toast')).toBeAccessible(); @@ -50,15 +53,17 @@ describe('Toast', () => { testID: 'toast-action', }; render( - - - , + + + + + , ); fireEvent.press(screen.getByTestId(action.testID)); @@ -72,9 +77,11 @@ describe('Toast', () => { it('triggers animation', () => { const text = 'Toast copy'; render( - - - , + + + + + , ); expect(animationParallelSpy).toHaveBeenCalled(); diff --git a/packages/mobile/src/overlays/drawer/Drawer.tsx b/packages/mobile/src/overlays/drawer/Drawer.tsx index e83a22499e..f2482ecbee 100644 --- a/packages/mobile/src/overlays/drawer/Drawer.tsx +++ b/packages/mobile/src/overlays/drawer/Drawer.tsx @@ -8,7 +8,7 @@ import React, { useRef, useState, } from 'react'; -import { Animated, Keyboard, Modal, Platform, useWindowDimensions } from 'react-native'; +import { Animated, Keyboard, Modal, Platform, StatusBar, useWindowDimensions } from 'react-native'; import type { ModalProps, PressableProps, StyleProp, ViewStyle } from 'react-native'; import { drawerAnimationDefaultDuration, @@ -29,6 +29,7 @@ import type { } from '@coinbase/cds-common/types'; import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { useHasNotch } from '../../hooks/useHasNotch'; import { useTheme } from '../../hooks/useTheme'; import { Box } from '../../layout/Box'; import { HandleBar, type HandleBarProps } from '../handlebar/HandleBar'; @@ -40,7 +41,7 @@ import { useDrawerAnimation } from './useDrawerAnimation'; import { useDrawerPanResponder } from './useDrawerPanResponder'; import { useDrawerSpacing } from './useDrawerSpacing'; -export type DrawerRenderChildren = React.FC<{ handleClose: () => void }>; +export type DrawerRenderChildren = (args: { handleClose: () => void }) => React.ReactNode; export type DrawerRefBaseProps = { /** ref callback that animates out the drawer */ @@ -310,6 +311,12 @@ export const Drawer = memo( ], ); + // this outdated logic needs to be removed + // rather than hiding on the presence of a "notch" (all modern phones based on how we determine), we should hide the status bar when any overlay is visible + // see: https://linear.app/coinbase/issue/CDS-1557/temporarily-hide-status-bar-in-all-overlay-components + const hasNotch = useHasNotch(); + const hideStatusBar = hasNotch && ['left', 'right', 'top'].includes(pin); + return ( - + {/* for some reason we are hiding on iOS only (see linked issue above) */} + {Platform.select({ + ios: hideStatusBar ? } + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByTestId(TEST_ID)).toBeAccessible(); }); it('renders content', () => { - render( - - test content} - opacity={new Animated.Value(1)} - placement="top" - subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} - testID={TEST_ID} - translateY={new Animated.Value(5)} - /> - , + renderWithProviders( + test content} + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByText('test content')).toBeTruthy(); @@ -56,17 +66,15 @@ describe('InternalTooltip.test', () => { }); it('renders string content', () => { - render( - - - , + renderWithProviders( + , ); expect(screen.getByText('test content')).toBeTruthy(); @@ -74,20 +82,18 @@ describe('InternalTooltip.test', () => { }); it('renders active colorScheme when invertColorScheme sets to false', () => { - render( - - test content} - elevation={2} - invertColorScheme={false} - opacity={new Animated.Value(1)} - placement="top" - subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} - testID={TEST_ID} - translateY={new Animated.Value(5)} - /> - , + renderWithProviders( + test content} + elevation={2} + invertColorScheme={false} + opacity={new Animated.Value(1)} + placement="top" + subjectLayout={{ width: 20, height: 30, pageOffsetX: 15, pageOffsetY: 25 }} + testID={TEST_ID} + translateY={new Animated.Value(5)} + />, ); expect(screen.getByTestId(TEST_ID)).toHaveStyle({ diff --git a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx index e599017ba9..89db3eb0a6 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx +++ b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx @@ -1,5 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { act, fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react-native'; import { Button } from '../../../buttons'; import { useDimensions } from '../../../hooks/useDimensions'; diff --git a/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts b/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts index f1d5a6ef8b..cd0144ba90 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts +++ b/packages/mobile/src/overlays/tooltip/__tests__/UseTooltipPositionTestData.ts @@ -38,7 +38,7 @@ export const basicCenterSubject: UseTooltipPositionTestData = { }, dimensions: galaxyScreenDimensions, - expectedTop: { opacity: 1, start: 112.13333129882812, top: 212.97779083251953 }, + expectedTop: { opacity: 1, start: 112.13333129882812, top: 238.93334579467773 }, // To do: expectedBottom: { diff --git a/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx new file mode 100644 index 0000000000..c22b5b0f75 --- /dev/null +++ b/packages/mobile/src/overlays/tooltip/__tests__/useTooltipPosition.test.tsx @@ -0,0 +1,214 @@ +import { Platform } from 'react-native'; +import { renderHook } from '@testing-library/react-native'; + +import { useDimensions } from '../../../hooks/useDimensions'; +import { DefaultThemeProvider } from '../../../utils/testHelpers'; +import type { UseTooltipPositionParams } from '../TooltipProps'; +import { useTooltipPosition } from '../useTooltipPosition'; + +jest.mock('../../../hooks/useDimensions'); + +const mockUseDimensions = (mocks: ReturnType) => { + (useDimensions as jest.Mock).mockReturnValue(mocks); +}; + +const createHookInstance = (options: UseTooltipPositionParams) => { + return renderHook(() => useTooltipPosition(options), { + wrapper: DefaultThemeProvider, + }); +}; + +const STATUS_BAR_HEIGHT = 24; +const SCREEN_HEIGHT = 800; +const SCREEN_WIDTH = 400; + +const baseSubjectLayout = { + height: 40, + width: 100, + pageOffsetX: 150, + pageOffsetY: 200, +}; + +const baseTooltipLayout = { + height: 50, + width: 150, + x: 0, + y: 0, +}; + +describe('useTooltipPosition - Android Edge-to-Edge', () => { + const originalPlatformOS = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + // Set platform to Android for these tests + Platform.OS = 'android'; + }); + + afterEach(() => { + Platform.OS = originalPlatformOS; + }); + + describe('Android Edge-to-Edge Mode (safe area insets > 0)', () => { + // In edge-to-edge mode, useSafeAreaInsets().top returns the status bar height + // The Modal and main view share the same coordinate system (both start from screen top) + // Therefore, NO adjustment should be made to pageOffsetY + + it('positions tooltip above subject without status bar offset', () => { + // Edge-to-edge: statusBarHeight comes from safe area insets and is > 0 + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = pageOffsetY - tooltipHeight (no status bar subtraction) + // = 200 - 50 = 150 + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); + + it('positions tooltip below subject without status bar offset', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'bottom', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = pageOffsetY + subjectHeight (no status bar subtraction) + // = 200 + 40 = 240 + const expectedTop = baseSubjectLayout.pageOffsetY + baseSubjectLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); + }); + + describe('Android Non-Edge-to-Edge Mode (safe area insets = 0)', () => { + // In non-edge-to-edge mode, useSafeAreaInsets().top returns 0 + // But the coordinate systems are still offset by the status bar + // Therefore, we need to subtract StatusBar.currentHeight from pageOffsetY + + it('positions tooltip above subject with status bar offset adjustment', () => { + // Non-edge-to-edge: statusBarHeight from safe area insets is 0 + // But StatusBar.currentHeight should be used for the offset + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: 0, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = (pageOffsetY - StatusBar.currentHeight) - tooltipHeight + // With StatusBar.currentHeight ≈ 24, this should be: (200 - 24) - 50 = 126 + // Note: The actual StatusBar.currentHeight value would come from the native module + // For this test, we verify the offset IS applied (top should be less than edge-to-edge case) + const edgeToEdgeTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; // 150 + + // In non-edge-to-edge, the top should be offset by the status bar height + // So it should be: 150 - ACTUAL_STATUS_BAR_HEIGHT + // We expect this to be LESS than the edge-to-edge case + expect(result.current.top).toBeLessThan(edgeToEdgeTop); + expect(result.current.opacity).toBe(1); + }); + + it('positions tooltip below subject with status bar offset adjustment', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: 0, + }); + + const { result } = createHookInstance({ + placement: 'bottom', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // Expected: tooltip top = (pageOffsetY - StatusBar.currentHeight) + subjectHeight + // With StatusBar.currentHeight ≈ 24, this should be: (200 - 24) + 40 = 216 + const edgeToEdgeTop = baseSubjectLayout.pageOffsetY + baseSubjectLayout.height; // 240 + + // In non-edge-to-edge, the top should be offset by the status bar height + expect(result.current.top).toBeLessThan(edgeToEdgeTop); + expect(result.current.opacity).toBe(1); + }); + }); + + describe('Android with yShiftByStatusBarHeight flag', () => { + // When yShiftByStatusBarHeight is true, the status bar offset should NOT be applied + // This is for cases where the tooltip is already in a context with aligned coordinates + + it('does not apply status bar offset when yShiftByStatusBarHeight is true', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + yShiftByStatusBarHeight: true, + }); + + // With yShiftByStatusBarHeight=true, should use pageOffsetY directly + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + }); + }); +}); + +describe('useTooltipPosition - iOS (baseline comparison)', () => { + const originalPlatformOS = Platform.OS; + + beforeEach(() => { + jest.clearAllMocks(); + Platform.OS = 'ios'; + }); + + afterEach(() => { + Platform.OS = originalPlatformOS; + }); + + it('positions tooltip without status bar offset on iOS', () => { + mockUseDimensions({ + screenHeight: SCREEN_HEIGHT, + screenWidth: SCREEN_WIDTH, + statusBarHeight: STATUS_BAR_HEIGHT, + }); + + const { result } = createHookInstance({ + placement: 'top', + subjectLayout: baseSubjectLayout, + tooltipLayout: baseTooltipLayout, + }); + + // iOS always uses pageOffsetY directly (no status bar subtraction) + const expectedTop = baseSubjectLayout.pageOffsetY - baseTooltipLayout.height; + + expect(result.current.top).toBe(expectedTop); + expect(result.current.opacity).toBe(1); + }); +}); diff --git a/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts b/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts index 4369b1fb5a..2f63fe42eb 100644 --- a/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts +++ b/packages/mobile/src/overlays/tooltip/useTooltipPosition.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from 'react'; -import { Platform } from 'react-native'; +import { Platform, StatusBar } from 'react-native'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; import { IOS_BOTTOM_NAV_BAR_HEIGHT, useDimensions } from '../../hooks/useDimensions'; @@ -31,10 +31,20 @@ export const useTooltipPosition = ({ const { pageOffsetY } = subjectLayout; + // On Android, we detect edge-to-edge mode by checking useSafeAreaInsets().top: + // - When > 0: App content extends behind the status bar (edge-to-edge enabled). + // The tooltip and subject share the same coordinate origin, so no adjustment needed. + // - When === 0: App content is placed below the status bar (edge-to-edge disabled). + // The subject's pageOffsetY is measured from screen top, but the tooltip is + // rendered inside a Modal whose coordinate system starts below the status bar. + // We subtract StatusBar.currentHeight to reconcile these two origins. + const isEdgeToEdge = statusBarHeight > 0; const actualPageYOffset = Platform.OS === 'ios' || yShiftByStatusBarHeight ? pageOffsetY - : pageOffsetY - (statusBarHeight ?? 0); + : isEdgeToEdge + ? pageOffsetY + : pageOffsetY - (StatusBar.currentHeight ?? 0); return calculatedPlacement === 'bottom' ? actualPageYOffset + (subjectLayout?.height ?? 0) diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index 5031868dcc..cc1872c5f4 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -25,7 +25,7 @@ import { type DrawerRefBaseProps, } from '../drawer/Drawer'; -export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; +export type TrayRenderChildren = (args: { handleClose: () => void }) => React.ReactNode; export type TrayBaseProps = Omit & { /** Component to render as the Tray content */ diff --git a/packages/mobile/src/overlays/useModal.ts b/packages/mobile/src/overlays/useModal.ts index c9dd8af227..56c221bf4e 100644 --- a/packages/mobile/src/overlays/useModal.ts +++ b/packages/mobile/src/overlays/useModal.ts @@ -1,7 +1,7 @@ import { useModal } from '@coinbase/cds-common/overlays/useModal'; /** - * @deprecated Use the visible and onRequestClose props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. + * @deprecated Use the `visible` and `onRequestClose` props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started. This will be removed in a future major release. * @deprecationExpectedRemoval v7 */ export { useModal }; diff --git a/packages/mobile/src/page/PageFooter.tsx b/packages/mobile/src/page/PageFooter.tsx index 488cc18aa1..534a44477e 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -2,10 +2,11 @@ import React, { forwardRef, memo } from 'react'; import type { View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageFooterHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, type BoxProps } from '../layout/Box'; +import type { PositionStyles } from '../styles/styleProps'; export type PageFooterBaseProps = SharedProps & PositionStyles & { diff --git a/packages/mobile/src/page/PageHeader.tsx b/packages/mobile/src/page/PageHeader.tsx index 91260e4164..6a1314529f 100644 --- a/packages/mobile/src/page/PageHeader.tsx +++ b/packages/mobile/src/page/PageHeader.tsx @@ -2,12 +2,13 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { pageHeaderHeight } from '@coinbase/cds-common/tokens/page'; -import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedProps } from '@coinbase/cds-common/types'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, type BoxProps } from '../layout/Box'; import { HStack } from '../layout/HStack'; import { VStack } from '../layout/VStack'; +import type { PositionStyles } from '../styles/styleProps'; import { Text } from '../typography/Text'; export type PageHeaderBaseProps = SharedProps & diff --git a/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx b/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx index 209dc71b08..3787c8bd8e 100644 --- a/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageFooterInPage.stories.tsx @@ -43,11 +43,11 @@ const PageFooterInPageScreen = () => { Primary Content diff --git a/packages/mobile/src/page/__stories__/PageHeader.stories.tsx b/packages/mobile/src/page/__stories__/PageHeader.stories.tsx index ece6b2daa7..9d95ea2526 100644 --- a/packages/mobile/src/page/__stories__/PageHeader.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageHeader.stories.tsx @@ -38,7 +38,7 @@ const exampleProps = { ), intermediary1: Intermediary Content, intermediary2: ( - + Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. Hello there. This is a rather long text sentence since I do not have lorem ipsum handy. diff --git a/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx b/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx index cad188d261..96a7a24872 100644 --- a/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx +++ b/packages/mobile/src/page/__stories__/PageHeaderInErrorEmptyState.stories.tsx @@ -23,7 +23,7 @@ const PageHeaderInErrorEmptyState = () => { - + { } - position="sticky" start={exampleProps.start} title={exampleProps.title} - top="0" + top={0} /> Primary Content diff --git a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx index 6afa2475fd..b40840d8ed 100644 --- a/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperHeaderHorizontal.tsx @@ -1,17 +1,20 @@ -import { memo, useEffect, useMemo } from 'react'; -import { animated, useSpring } from '@react-spring/native'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { HStack } from '../layout/HStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { Text } from '../typography/Text'; import type { StepperHeaderComponent } from './Stepper'; -const AnimatedHStack = animated(HStack); +const AnimatedHStack = Animated.createAnimatedComponent(HStack); export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( function DefaultStepperHeaderHorizontal({ activeStep, complete, + disableAnimateOnMount, flatStepIds, style, paddingBottom = 1.5, @@ -20,27 +23,41 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( fontFamily = font, ...props }) { - const [spring, springApi] = useSpring( - { - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }, - [], - ); + const opacity = useSharedValue(disableAnimateOnMount ? 1 : 0); + const disableAnimateOnMountRef = useRef(disableAnimateOnMount); + const isInitialRender = useRef(true); + + const [displayedStep, setDisplayedStep] = useState(activeStep); + const [displayedComplete, setDisplayedComplete] = useState(complete); - // TO DO: resetting the spring doesn't work like it does in react-spring on web - // need to look into this deeper and understand why there is a difference in behavior useEffect(() => { - springApi.start({ - from: { opacity: 0 }, - to: { opacity: 1 }, - reset: true, - }); - }, [springApi, activeStep]); + if (isInitialRender.current) { + isInitialRender.current = false; + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + if (disableAnimateOnMountRef.current) return; + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + return; + } + + // Fade out with old text, then swap text and fade in + opacity.value = withTiming(0, { duration: durations.fast1, easing: mobileCurves.linear }); + + const timeout = setTimeout(() => { + setDisplayedStep(activeStep); + setDisplayedComplete(complete); + opacity.value = withTiming(1, { duration: durations.fast1, easing: mobileCurves.linear }); + }, durations.fast1 + durations.fast1); + + return () => clearTimeout(timeout); + }, [activeStep, complete, opacity]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + })); - const styles = useMemo(() => [style, spring] as any, [style, spring]); - const flatStepIndex = activeStep ? flatStepIds.indexOf(activeStep.id) : -1; + const styles = useMemo(() => [style, animatedStyle], [style, animatedStyle]); + const flatStepIndex = displayedStep ? flatStepIds.indexOf(displayedStep.id) : -1; const emptyText = ' '; // Simple space for React Native return ( @@ -52,7 +69,7 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( {...props} > - {!activeStep || complete ? ( + {!displayedStep || displayedComplete ? ( emptyText ) : ( @@ -65,12 +82,12 @@ export const DefaultStepperHeaderHorizontal: StepperHeaderComponent = memo( > {flatStepIndex + 1}/{flatStepIds.length} - {activeStep.label && typeof activeStep.label === 'string' ? ( + {displayedStep.label && typeof displayedStep.label === 'string' ? ( - {activeStep.label} + {displayedStep.label} ) : ( - activeStep.label + displayedStep.label )} )} diff --git a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx index 7110050e13..7d07a67213 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressHorizontal.tsx @@ -1,11 +1,12 @@ -import { memo } from 'react'; -import { animated, to } from '@react-spring/native'; +import { memo, useCallback, useEffect } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { Box } from '../layout/Box'; import type { StepperProgressComponent } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( function DefaultStepperProgressHorizontal({ @@ -19,7 +20,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( progress, complete, isDescendentActive, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, style, @@ -33,6 +34,24 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( height = 4, ...props }) { + const containerWidth = useSharedValue(0); + const animatedProgress = useSharedValue(progress); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerWidth.value = event.nativeEvent.layout.width; + }, + [containerWidth], + ); + + useEffect(() => { + animatedProgress.value = withTiming(progress, progressTimingConfig); + }, [progress, progressTimingConfig, animatedProgress]); + + const animatedStyle = useAnimatedStyle(() => ({ + width: animatedProgress.value * containerWidth.value, + })); + return ( @@ -57,7 +77,7 @@ export const DefaultStepperProgressHorizontal: StepperProgressComponent = memo( } borderRadius={borderRadius} height="100%" - width={to([progress], (width) => `${width * 100}%`)} + style={animatedStyle} /> ); diff --git a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx index d582c81709..1530a96872 100644 --- a/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperProgressVertical.tsx @@ -1,13 +1,13 @@ -import { memo, useCallback, useMemo } from 'react'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; +import { memo, useCallback, useEffect, useMemo } from 'react'; +import type { LayoutChangeEvent } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { flattenSteps } from '@coinbase/cds-common/stepper/utils'; -import { animated, to, useSpring } from '@react-spring/native'; import { Box } from '../layout/Box'; import type { StepperProgressComponent, StepperValue } from './Stepper'; -const AnimatedBox = animated(Box); +const AnimatedBox = Animated.createAnimatedComponent(Box); export const DefaultStepperProgressVertical: StepperProgressComponent = memo( function DefaultStepperProgressVertical({ @@ -23,9 +23,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( isDescendentActive, style, activeStepLabelElement, - progressSpringConfig, - animate = true, - disableAnimateOnMount, + progressTimingConfig, background = 'bgLine', defaultFill = 'bgLinePrimarySubtle', activeFill = 'bgLinePrimarySubtle', @@ -36,7 +34,6 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( width = 2, ...props }) { - const hasMounted = useHasMounted(); const isLastStep = flatStepIds[flatStepIds.length - 1] === step.id; // Count the total number of sub-steps in the current step's tree @@ -56,35 +53,45 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( [], ); + // Fractional fill for steps with sub-steps. For all other cases, return 1 + // and let the cascade's `progress` prop control whether the bar is filled. const progressHeight = useMemo(() => { const totalSubSteps = countAllSubSteps(step.subSteps ?? []); - if (complete) return 1; - if (active && totalSubSteps === 0) return 1; - if (active && !isDescendentActive) return 0; - if (isDescendentActive) { + if (active && totalSubSteps > 0 && !isDescendentActive) return 0; + if (isDescendentActive && totalSubSteps > 0) { const activePosition = findSubStepPosition(step.subSteps ?? [], activeStepId); return activePosition / totalSubSteps; } - if (visited) return 1; - return 0; + return 1; }, [ countAllSubSteps, step.subSteps, - complete, active, isDescendentActive, - visited, findSubStepPosition, activeStepId, ]); - const fillHeightSpring = useSpring({ - height: progressHeight, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - config: progressSpringConfig, - }); + const containerHeight = useSharedValue(0); + const targetHeight = progress * progressHeight; + const animatedHeight = useSharedValue(targetHeight); + + const handleLayout = useCallback( + (event: LayoutChangeEvent) => { + containerHeight.value = event.nativeEvent.layout.height; + }, + [containerHeight], + ); + + useEffect(() => { + animatedHeight.value = withTiming(targetHeight, progressTimingConfig); + }, [targetHeight, progressTimingConfig, animatedHeight]); + + const animatedStyle = useAnimatedStyle(() => ({ + height: animatedHeight.value * containerHeight.value, + })); if (depth > 0 || isLastStep) return null; @@ -93,6 +100,7 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( background={background} flexGrow={1} minHeight={minHeight} + onLayout={handleLayout} position="relative" style={style} width={width} @@ -110,8 +118,8 @@ export const DefaultStepperProgressVertical: StepperProgressComponent = memo( ? visitedFill : defaultFill } - height={to([progress, fillHeightSpring.height], (p, h) => `${p * h * 100}%`)} position="absolute" + style={animatedStyle} width="100%" />
diff --git a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx index 8e94ff1798..0609034e20 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepHorizontal.tsx @@ -24,7 +24,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepHorizontal, @@ -75,7 +75,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -111,7 +111,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( style={styles?.substepContainer} visited={visited} > - {step.subSteps.map((subStep, index) => { + {step.subSteps.map((subStep) => { const RenderedStepComponent = subStep.Component ?? StepperStepComponent; const isDescendentActive = activeStepId ? containsStep({ @@ -140,7 +140,7 @@ export const DefaultStepperStepHorizontal: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx index ae544fc189..77aad211e6 100644 --- a/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx +++ b/packages/mobile/src/stepper/DefaultStepperStepVertical.tsx @@ -27,7 +27,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( styles, activeStepLabelElement, setActiveStepLabelElement, - progressSpringConfig, + progressTimingConfig, animate, disableAnimateOnMount, StepperStepComponent = DefaultStepperStepVertical, @@ -80,7 +80,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={parentStep} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} step={step} style={styles?.progress} visited={visited} @@ -146,7 +146,7 @@ export const DefaultStepperStepVertical: StepperStepComponent = memo( isDescendentActive={isDescendentActive} parentStep={step} progress={progress} - progressSpringConfig={progressSpringConfig} + progressTimingConfig={progressTimingConfig} setActiveStepLabelElement={setActiveStepLabelElement} step={subStep} styles={styles} diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx index b0f2346b50..9eed77e131 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -1,20 +1,17 @@ -import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; +import type { WithTimingConfig } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useHasMounted } from '@coinbase/cds-common/hooks/useHasMounted'; -import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; +import { durations } from '@coinbase/cds-common/motion/tokens'; import { containsStep, flattenSteps, isStepVisited } from '@coinbase/cds-common/stepper/utils'; import type { IconName } from '@coinbase/cds-common/types'; -import { - type SpringConfig, - type SpringValue as SpringValueType, - useSprings, -} from '@react-spring/native'; +import type { SpringConfig } from '@react-spring/core'; import { useComponentConfig } from '../hooks/useComponentConfig'; import type { IconProps } from '../icons/Icon'; import { Box, type BoxBaseProps, type BoxProps } from '../layout/Box'; import { VStack } from '../layout/VStack'; +import { mobileCurves } from '../motion/convertMotionConfig'; import { DefaultStepperHeaderHorizontal } from './DefaultStepperHeaderHorizontal'; import { DefaultStepperIconVertical } from './DefaultStepperIconVertical'; @@ -74,13 +71,13 @@ export type StepperStepProps = Record & BoxProps & { /** - * An animated SpringValue between 0 and 1. - * You can use this to animate your own custom Progress subcomponent. + * A value between 0 and 1 representing the step's progress. + * Progress bar subcomponents animate to this value internally. */ - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; setActiveStepLabelElement: (element: View) => void; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; completedStepAccessibilityLabel?: string; @@ -111,6 +108,7 @@ export type StepperHeaderProps = Record activeStep: StepperValue | null; flatStepIds: string[]; complete?: boolean; + disableAnimateOnMount?: boolean; style?: StyleProp; }; @@ -130,9 +128,9 @@ export type StepperProgressProps< Metadata extends Record = Record, > = StepperSubcomponentProps & BoxProps & { - progress: SpringValueType; + progress: number; activeStepLabelElement: View | null; - progressSpringConfig?: SpringConfig; + progressTimingConfig?: WithTimingConfig; animate?: boolean; disableAnimateOnMount?: boolean; defaultFill?: ThemeVars.Color; @@ -216,9 +214,14 @@ export type StepperBaseProps = Record | null; /** An optional component to render in place of the default Header subcomponent. Set to null to render nothing in this slot. */ StepperHeaderComponent?: StepperHeaderComponent | null; - /** The spring config to use for the progress spring. */ + /** The timing config to use for the progress animation. */ + progressTimingConfig?: WithTimingConfig; + /** + * @deprecated Stepper no longer uses react-spring for progress; this value is ignored but retained for migration only. Use {@link progressTimingConfig} instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ progressSpringConfig?: SpringConfig; - /** Whether to animate the progress spring. + /** Whether to animate the progress bar. * @default true */ animate?: boolean; @@ -249,8 +252,22 @@ export type StepperProps = Record = Record>( props: StepperProps & { ref?: React.Ref }, @@ -292,12 +309,11 @@ const StepperBase = memo( StepperHeaderComponent = direction === 'vertical' ? null : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressSpringConfig = defaultProgressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, ...props } = mergedProps; - const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); // Derive activeStep from activeStepId @@ -342,92 +358,57 @@ const StepperBase = memo( : -1; }, [activeStepId, steps]); - const previousComplete = usePreviousValue(complete) ?? false; - const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1; + // The effective cascade target: when complete, fill all steps up to the last one. + // Otherwise, fill up to activeStepIndex. + const cascadeTarget = complete ? steps.length - 1 : activeStepIndex; - const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({ - progress: complete ? 1 : 0, - config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - })); + // Cascade animation state: advances one step at a time toward cascadeTarget. + // When disableAnimateOnMount is false (default), start unfilled (-1) so the + // cascade animates bars one-at-a-time up to the target on mount. + const [filledStepIndex, setFilledStepIndex] = useState(() => + disableAnimateOnMount ? cascadeTarget : -1, + ); + const targetStepIndexRef = useRef(cascadeTarget); useEffect(() => { - // update the previous values for next render - let stepsToAnimate: number[] = []; - let isAnimatingForward = false; - - // Case when going from not-complete to complete - if (Boolean(complete) !== previousComplete) { - if (complete) { - // Going to complete: animate remaining steps to filled. - // Use previousActiveStepIndex to determine which steps are already filled before the completion state update, - const lastFilledIndex = Math.max(activeStepIndex, previousActiveStepIndex); - stepsToAnimate = Array.from( - { length: steps.length - lastFilledIndex - 1 }, - (_, i) => lastFilledIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Going from complete: animate from end down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: steps.length - activeStepIndex - 1 }, - (_, i) => steps.length - 1 - i, - ); - isAnimatingForward = false; - } - } + targetStepIndexRef.current = cascadeTarget; - // Case for normal step navigation (e.g. step 1 => step 2) - else if (activeStepIndex !== previousActiveStepIndex) { - if (activeStepIndex > previousActiveStepIndex) { - // Forward: animate from previousActiveStepIndex+1 to activeStepIndex - stepsToAnimate = Array.from( - { length: activeStepIndex - previousActiveStepIndex }, - (_, i) => previousActiveStepIndex + 1 + i, - ); - isAnimatingForward = true; - } else { - // Backward: animate from previousActiveStepIndex down to activeStepIndex+1 - stepsToAnimate = Array.from( - { length: previousActiveStepIndex - activeStepIndex }, - (_, i) => previousActiveStepIndex - i, - ); - isAnimatingForward = false; - } + if (!animate) { + setFilledStepIndex(cascadeTarget); + return; } - const animateNextStep = () => { - if (stepsToAnimate.length === 0) return; - const stepIndex = stepsToAnimate.shift(); - if (stepIndex === undefined) return; - - progressSpringsApi.start((index) => - index === stepIndex - ? { - progress: isAnimatingForward ? 1 : 0, - config: progressSpringConfig, - onRest: animateNextStep, - immediate: !animate || (disableAnimateOnMount && !hasMounted), - } - : {}, - ); - }; - - // start the animation loop for relevant springs (stepsToAnimate) - animateNextStep(); - }, [ - progressSpringsApi, - complete, - steps.length, - steps, - activeStepIndex, - previousActiveStepIndex, - previousComplete, - progressSpringConfig, - animate, - disableAnimateOnMount, - hasMounted, - ]); + // Advance one step immediately to kick off the cascade + setFilledStepIndex((prev) => { + if (prev === cascadeTarget) return prev; + return prev < cascadeTarget ? prev + 1 : prev - 1; + }); + + // Continue advancing on a fixed interval for fluid, overlapping springs + const interval = setInterval(() => { + setFilledStepIndex((prev) => { + const target = targetStepIndexRef.current; + if (prev === target) return prev; + return prev < target ? prev + 1 : prev - 1; + }); + }, cascadeStaggerMs); + + return () => clearInterval(interval); + }, [cascadeTarget, animate]); + + // Compute progress for each step: 1 if filled, 0 if not + const getStepProgress = useCallback( + (index: number) => { + if (!animate) { + if (complete) return 1; + if (activeStepIndex < 0) return 0; + return index <= activeStepIndex ? 1 : 0; + } + if (filledStepIndex < 0) return 0; + return index <= filledStepIndex ? 1 : 0; + }, + [complete, animate, activeStepIndex, filledStepIndex], + ); return ( @@ -451,42 +433,43 @@ const StepperBase = memo( ? containsStep({ step, targetStepId: activeStepId }) : false; const RenderedStepComponent = step.Component ?? StepperStepComponent; + + if (!RenderedStepComponent) return null; + return ( - RenderedStepComponent && ( - - ) + ); })} diff --git a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx index 835f437ac8..1637559b47 100644 --- a/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperHorizontal.stories.tsx @@ -3,6 +3,7 @@ import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; import { useStepper } from '@coinbase/cds-common/stepper/useStepper'; import { Button } from '../../buttons'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { HStack, VStack } from '../../layout'; @@ -216,6 +217,29 @@ const NoActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Custom Progress Component // ------------------------------------------------------------ @@ -264,6 +288,10 @@ const StepperHorizontalScreen = () => { + + + + diff --git a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx index caf2bb5179..aa067c3d6f 100644 --- a/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx +++ b/packages/mobile/src/stepper/__stories__/StepperVertical.stories.tsx @@ -8,6 +8,7 @@ import { import { Button } from '../../buttons'; import { ListCell } from '../../cells'; import { Collapsible } from '../../collapsible'; +import { Switch } from '../../controls/Switch'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons/Icon'; import { Box, HStack, VStack } from '../../layout'; @@ -247,6 +248,36 @@ const InitialActiveStep = () => { return ; }; +// ------------------------------------------------------------ +// Disable Animate on Mount +// ------------------------------------------------------------ +const disableAnimateOnMountSteps: StepperValue[] = [ + { id: 'first-step', label: 'First step' }, + { id: 'second-step', label: 'Second step' }, + { id: 'third-step', label: 'Third step' }, + { id: 'final-step', label: 'Final step' }, +]; + +const DisableAnimateOnMount = () => { + const [disableAnimateOnMount, setDisableAnimateOnMount] = useState(false); + + return ( + + setDisableAnimateOnMount((prev) => !prev)} + > + disableAnimateOnMount + + + + ); +}; + // ------------------------------------------------------------ // Nested Steps // ------------------------------------------------------------ @@ -733,6 +764,10 @@ const StepperVerticalScreen = () => { + + + + diff --git a/packages/mobile/src/sticky-footer/StickyFooter.tsx b/packages/mobile/src/sticky-footer/StickyFooter.tsx index 8e5c68d452..07b9c80a85 100644 --- a/packages/mobile/src/sticky-footer/StickyFooter.tsx +++ b/packages/mobile/src/sticky-footer/StickyFooter.tsx @@ -7,7 +7,7 @@ import { Box, type BoxProps } from '../layout'; export type StickyFooterProps = BoxProps & { /** * Whether to apply a box shadow to the StickyFooter element. - * @deprecated Use elevation instead. This will be removed in a future major release. + * @deprecated Use `elevation` instead. This will be removed in a future major release. * @deprecationExpectedRemoval v8 */ elevated?: boolean; diff --git a/packages/mobile/src/styles/__tests__/getStyles.test.ts b/packages/mobile/src/styles/__tests__/getStyles.test.ts new file mode 100644 index 0000000000..c95b624328 --- /dev/null +++ b/packages/mobile/src/styles/__tests__/getStyles.test.ts @@ -0,0 +1,93 @@ +import type { Theme } from '../../core/theme'; +import { defaultTheme } from '../../themes/defaultTheme'; +import type { StyleProps } from '../styleProps'; +import { getStyles } from '../styleProps'; + +const theme: Theme = { + ...defaultTheme, + activeColorScheme: 'light', + spectrum: defaultTheme.lightSpectrum!, + color: defaultTheme.lightColor!, +}; + +describe('getStyles', () => { + it('skips undefined values', () => { + const styleProps: StyleProps = { padding: 1, width: undefined }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ padding: 8 }); + expect(result).not.toHaveProperty('width'); + }); + + it('passes through non-themed props as-is', () => { + const styleProps: StyleProps = { + width: 100, + height: '50%', + alignSelf: 'flex-start', + }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ + width: 100, + height: '50%', + alignSelf: 'flex-start', + }); + }); + + it('resolves themed space props (e.g. padding) from theme', () => { + const styleProps: StyleProps = { padding: 1, paddingTop: 2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ padding: 8, paddingTop: 16 }); + }); + + it('resolves margin from theme with negated lookup', () => { + const styleProps: StyleProps = { margin: -1, marginTop: -2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ margin: -8, marginTop: -16 }); + }); + + it('resolves themed color props from theme', () => { + const styleProps: StyleProps = { color: 'fg', background: 'bgPrimary' }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ + color: theme.color.fg, + backgroundColor: theme.color.bgPrimary, + }); + }); + + it('expands paddingX to paddingStart and paddingEnd', () => { + const styleProps: StyleProps = { paddingX: 1 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ paddingStart: 8, paddingEnd: 8 }); + }); + + it('expands marginY to marginTop and marginBottom', () => { + const styleProps: StyleProps = { marginY: -2 }; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ marginTop: -16, marginBottom: -16 }); + }); + + it('skips themed props when value is null', () => { + const styleProps = { + padding: 1, + margin: -2, + color: 'fg', + } as StyleProps & { + padding?: number | null; + margin?: number | null; + color?: keyof Theme['color'] | null; + }; + const withNull = { + ...styleProps, + padding: null, + margin: null, + color: null, + }; + const result = getStyles(withNull as unknown as StyleProps, theme); + expect(result).toEqual({}); + }); + + it('passes through null for non-themed dimension props', () => { + const styleProps = { width: null, height: 100 } as StyleProps; + const result = getStyles(styleProps, theme); + expect(result).toEqual({ width: null, height: 100 }); + }); +}); diff --git a/packages/mobile/src/styles/styleProps.ts b/packages/mobile/src/styles/styleProps.ts index 101ac5c5b9..b1f7a4be90 100644 --- a/packages/mobile/src/styles/styleProps.ts +++ b/packages/mobile/src/styles/styleProps.ts @@ -1,11 +1,16 @@ -import type { TextStyle, ViewStyle } from 'react-native'; -import type { DimensionValue, Position } from '@coinbase/cds-common'; +import type { DimensionValue, TextStyle, ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { ElevationLevels } from '@coinbase/cds-common/types/ElevationLevels'; import type { TypeOrNumber } from '@coinbase/cds-common/types/TypeOrNumber'; import type { Theme } from '../core/theme'; +/** Position-related style props using React Native types. Use this instead of common's PositionStyles for mobile. */ +export type PositionStyles = Pick< + ViewStyle, + 'position' | 'top' | 'bottom' | 'left' | 'right' | 'zIndex' +>; + type NegativeSpace = TypeOrNumber<'0' | `-${Exclude}`>; // TO DO: If possible, refactor DimensionValue to ViewStyle['width'] etc @@ -42,8 +47,8 @@ export type StyleProps = { alignSelf?: ViewStyle['alignSelf']; flexDirection?: ViewStyle['flexDirection']; flexWrap?: ViewStyle['flexWrap']; - position?: Position; - // position?: ViewStyle['position']; + // position?: Position; + position?: ViewStyle['position']; zIndex?: ViewStyle['zIndex']; padding?: ThemeVars.Space; paddingX?: ThemeVars.Space; @@ -67,12 +72,6 @@ export type StyleProps = { minHeight?: DimensionValue; maxWidth?: DimensionValue; maxHeight?: DimensionValue; - // width?: ViewStyle['width']; - // height?: ViewStyle['height']; - // minWidth?: ViewStyle['minWidth']; - // minHeight?: ViewStyle['minHeight']; - // maxWidth?: ViewStyle['maxWidth']; - // maxHeight?: ViewStyle['maxHeight']; aspectRatio?: ViewStyle['aspectRatio']; top?: DimensionValue; bottom?: DimensionValue; @@ -142,7 +141,7 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { for (const styleProp in styleProps) { const value = styleProps[styleProp as keyof StyleProps]; - if (typeof value === 'undefined') continue; + if (value === undefined) continue; // If there are no stylePropAliases for this styleProp... if (typeof stylePropAliases[styleProp as keyof typeof stylePropAliases] === 'undefined') { @@ -151,10 +150,10 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { style[styleProp as keyof typeof style] = value as any; } // If it is themed and it is margin* prop - else if (styleProp.startsWith('margin')) { + else if (styleProp.startsWith('margin') && value !== null) { style[styleProp as keyof typeof style] = -( theme[themedStyleProps[styleProp as keyof typeof themedStyleProps]] as any - )[-value as any] as any; + )[-(value as any)] as any; } // If it is themed... else { @@ -169,10 +168,10 @@ export const getStyles = (styleProps: StyleProps, theme: Theme) => { style[propAlias as keyof typeof style] = value as any; } // If it is themed and it is margin* prop - else if (styleProp.startsWith('margin')) { + else if (styleProp.startsWith('margin') && value !== null) { style[propAlias as keyof typeof style] = -( theme[themedStyleProps[styleProp as keyof typeof themedStyleProps]] as any - )[-value as any] as any; + )[-(value as number)] as any; } // If it is themed... else { diff --git a/packages/mobile/src/system/AndroidNavigationBar.tsx b/packages/mobile/src/system/AndroidNavigationBar.tsx index e333931754..3fa076dfef 100644 --- a/packages/mobile/src/system/AndroidNavigationBar.tsx +++ b/packages/mobile/src/system/AndroidNavigationBar.tsx @@ -35,6 +35,23 @@ export const useAndroidNavigationBarUpdater = ({ }, [bg, statusBarStyle]); }; +/** + * Updates the **Android system navigation bar** (bottom bar) colors to match the active CDS theme. + * + * This component is **side-effect only** (renders `null`). When mounted, it sets: + * - **navigation bar background color** to the theme background (`theme.color.bg`) + * - **navigation bar icon brightness** (light/dark) based on the computed status bar style + * + * ### When to use + * - Your app wants the Android navigation bar to visually match the CDS theme (light/dark) + * - You intentionally want an opaque navigation bar that matches your app background (non edge-to-edge look). + * + * ### When NOT to use + * - Your app already manages system bars via another library/app-level integration. + * - You intentionally want to keep the OS default navigation bar styling. + * - You are using Android edge-to-edge defaults (transparent system bars / scrims) and want the platform to + * manage navigation bar transparency + contrast automatically. + */ export const AndroidNavigationBar = memo((props: AndroidNavigationBarProps) => { const updateAndroidNavigationBar = useAndroidNavigationBarUpdater(props); const hasRun = useRef(false); diff --git a/packages/mobile/src/system/PressableOpacity.tsx b/packages/mobile/src/system/PressableOpacity.tsx index 8b77888c72..324bfb2a89 100644 --- a/packages/mobile/src/system/PressableOpacity.tsx +++ b/packages/mobile/src/system/PressableOpacity.tsx @@ -18,7 +18,7 @@ export type PressableOpacityProps = Omit< */ export const PressableOpacity = ({ children, ...props }: PressableOpacityProps) => { return ( - + {children} ); diff --git a/packages/mobile/src/system/ThemeProvider.tsx b/packages/mobile/src/system/ThemeProvider.tsx index 8c8cdddca4..5eb155d694 100644 --- a/packages/mobile/src/system/ThemeProvider.tsx +++ b/packages/mobile/src/system/ThemeProvider.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useContext, useMemo } from 'react'; +import React, { createContext, memo, useContext, useMemo } from 'react'; import type { ColorScheme } from '@coinbase/cds-common/core/theme'; import type { Theme, ThemeConfig } from '../core/theme'; @@ -7,9 +7,27 @@ export type ThemeContextValue = Theme; export const ThemeContext = createContext(undefined); -// export type ThemeProviderProps = SystemProviderProps & -// ThemeManagerProps & -// FramerMotionProviderProps; +// Not used for any theme optimizations in the mobile ThemeProvider, but exported for feature-parity with cds-web +/** + * Diff two themes and return a new partial theme with only the differences. + */ +export const diffThemes = (theme: Theme, parentTheme?: Theme) => { + if (!parentTheme) return theme; + const themeDiff = { + id: theme.id, + activeColorScheme: theme.activeColorScheme, + } as Record; + (Object.keys(theme) as (keyof Theme)[]).forEach((key) => { + if (key === 'id' || key === 'activeColorScheme') return; + themeDiff[key] = {}; + Object.keys(theme[key] ?? {}).forEach((value) => { + if ((theme[key] as any)?.[value] !== (parentTheme[key] as any)?.[value]) { + themeDiff[key][value] = (theme[key] as any)[value]; + } + }); + }); + return themeDiff as Partial; +}; export type ThemeProviderProps = { theme: ThemeConfig; @@ -17,7 +35,7 @@ export type ThemeProviderProps = { children?: React.ReactNode; }; -export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProviderProps) => { +export const ThemeProvider = memo(({ theme, activeColorScheme, children }: ThemeProviderProps) => { const themeApi = useMemo(() => { const activeSpectrumKey = activeColorScheme === 'dark' ? 'darkSpectrum' : 'lightSpectrum'; const activeColorKey = activeColorScheme === 'dark' ? 'darkColor' : 'lightColor'; @@ -26,22 +44,22 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi if (!theme[activeColorKey]) throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeColorScheme} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (!theme[activeSpectrumKey]) throw Error( - `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider activeColorScheme is ${activeColorScheme} but no ${activeSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (theme[inverseSpectrumKey] && !theme[inverseColorKey]) throw Error( - `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider theme has ${inverseSpectrumKey} values defined but no ${inverseColorKey} colors are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); if (theme[inverseColorKey] && !theme[inverseSpectrumKey]) throw Error( - `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme. See the docs at https://cds.coinbase.com/getting-started/theming/#creating-a-theme`, + `ThemeProvider theme has ${inverseColorKey} colors defined but no ${inverseSpectrumKey} values are defined for the theme. See the docs https://cds.coinbase.com/getting-started/theming`, ); return { @@ -53,14 +71,14 @@ export const ThemeProvider = ({ theme, activeColorScheme, children }: ThemeProvi }, [theme, activeColorScheme]); return {children}; -}; +}); export type InvertedThemeProviderProps = { children?: React.ReactNode; }; /** Falls back to the currently active colorScheme if the inverse colors are not defined in the theme. */ -export const InvertedThemeProvider = ({ children }: InvertedThemeProviderProps) => { +export const InvertedThemeProvider = memo(({ children }: InvertedThemeProviderProps) => { const context = useContext(ThemeContext); if (!context) throw Error('InvertedThemeProvider must be used within a ThemeProvider'); const inverseColorScheme = context.activeColorScheme === 'dark' ? 'light' : 'dark'; @@ -72,4 +90,4 @@ export const InvertedThemeProvider = ({ children }: InvertedThemeProviderProps) {children} ); -}; +}); diff --git a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx b/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx deleted file mode 100644 index a7b0b7cc47..0000000000 --- a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import { figma } from '@figma/code-connect'; - -import { AndroidNavigationBar } from '../AndroidNavigationBar'; - -figma.connect( - AndroidNavigationBar, - 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10414%3A896', - { - imports: [ - "import { AndroidNavigationBar } from '@coinbase/cds-mobile/system/AndroidNavigationBar'", - ], - props: { - showsearch27799: figma.boolean('show search'), - showhelpcenter176314: figma.boolean('show help center'), - showsecondarycta24034: figma.boolean('show secondary cta'), - shownotification24028: figma.boolean('show notification'), - type156900: figma.instance('type'), - showpagetitle80: figma.boolean('show page title'), - showtabs24024: figma.boolean('show tabs'), - showprimarycta24032: figma.boolean('show primary cta'), - showbackarrow24022: figma.boolean('show back arrow'), - device: figma.enum('device', { - desktop: 'desktop', - tablet: 'tablet', - 'responsive mobile': 'responsive-mobile', - }), - }, - example: () => , - }, -); diff --git a/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx b/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx new file mode 100644 index 0000000000..85e210f36b --- /dev/null +++ b/packages/mobile/src/system/__stories__/AndroidNavigationBar.stories.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +import { Button } from '../../buttons'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; +import { VStack } from '../../layout/VStack'; +import { Text } from '../../typography/Text'; +import { AndroidNavigationBar } from '../AndroidNavigationBar'; + +const ThemeDemo = ({ colorScheme }: { colorScheme: 'light' | 'dark' }) => { + const theme = useTheme(); + + return ( + + + Active Color Scheme: {theme.activeColorScheme} + + The Android navigation bar should match the theme background color. + + + + + + + ); +}; + +const AndroidNavigationBarScreen = () => { + return ( + + + + + AndroidNavigationBar is a side-effect only component that renders null. When mounted, it + updates the Android system navigation bar (bottom bar) colors to match the active CDS + theme. + + + Note: Only works on Android API 26+ (Android 8.0+). On iOS or older Android versions, + the component has no effect. + + + + + + + + + ); +}; + +export default AndroidNavigationBarScreen; diff --git a/packages/mobile/src/system/__stories__/Palette.stories.tsx b/packages/mobile/src/system/__stories__/Palette.stories.tsx index a848dcbe20..a155305b7d 100644 --- a/packages/mobile/src/system/__stories__/Palette.stories.tsx +++ b/packages/mobile/src/system/__stories__/Palette.stories.tsx @@ -18,9 +18,9 @@ const Palette = ({ elevation }: { elevation?: ElevationLevels }) => { diff --git a/packages/mobile/src/system/__stories__/Pressable.stories.tsx b/packages/mobile/src/system/__stories__/Pressable.stories.tsx index 0d9e6b8fb4..9ab3cc2011 100644 --- a/packages/mobile/src/system/__stories__/Pressable.stories.tsx +++ b/packages/mobile/src/system/__stories__/Pressable.stories.tsx @@ -102,7 +102,7 @@ const PressableScreen = () => { accessibilityRole="button" background={color as ThemeVars.Color} > - + {color} @@ -123,7 +123,7 @@ const PressableScreen = () => { accessibilityRole="button" background={color as ThemeVars.Color} > - + {color} diff --git a/packages/mobile/src/system/__stories__/Spectrum.stories.tsx b/packages/mobile/src/system/__stories__/Spectrum.stories.tsx index 4c058521ea..0a81478269 100644 --- a/packages/mobile/src/system/__stories__/Spectrum.stories.tsx +++ b/packages/mobile/src/system/__stories__/Spectrum.stories.tsx @@ -34,8 +34,8 @@ const SpectrumScreen = () => { const background = `rgb(${theme.spectrum[paletteValue]})`; const foreground = getAccessibleColor({ background }); return ( - - + + {paletteValue} diff --git a/packages/mobile/src/system/__tests__/StatusBar.test.tsx b/packages/mobile/src/system/__tests__/StatusBar.test.tsx index e37a34d835..2ea44071ac 100644 --- a/packages/mobile/src/system/__tests__/StatusBar.test.tsx +++ b/packages/mobile/src/system/__tests__/StatusBar.test.tsx @@ -1,20 +1,10 @@ -import { StatusBar as RNStatusBar } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/testHelpers'; -import { useStatusBarStyle, useStatusBarUpdater } from '../StatusBar'; +import { useStatusBarStyle } from '../StatusBar'; import { ThemeProvider } from '../ThemeProvider'; -jest.mock('react-native/Libraries/Components/StatusBar/StatusBar', () => ({ - ...jest.requireActual>( - 'react-native/Libraries/Components/StatusBar/StatusBar', - ), - setBarStyle: jest.fn(), - setBackgroundColor: jest.fn(), - setTranslucent: jest.fn(), -})); - const MockDarkMode: React.FC> = ({ children }) => ( {children} @@ -58,37 +48,3 @@ describe('useStatusBarStyle', () => { expect(result.current).toBe('light-content'); }); }); - -describe('useStatusBarUpdater', () => { - it('correctly updates React Native StatusBar bar style', () => { - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - }); - - it('does not call setBackgroundColor or setTranslucent on iOS', () => { - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - expect(RNStatusBar.setBackgroundColor).not.toHaveBeenCalled(); - expect(RNStatusBar.setTranslucent).not.toHaveBeenCalled(); - }); - - it('does call setBackgroundColor or setTranslucent on Android', () => { - jest.mock('react-native/Libraries/Utilities/Platform', () => ({ - ...jest.requireActual>('react-native/Libraries/Utilities/Platform'), - OS: 'android', - })); - const { result } = renderHook(() => useStatusBarUpdater(), { - wrapper: DefaultThemeProvider, - }); - result.current(); - expect(RNStatusBar.setBarStyle).toHaveBeenCalledWith('dark-content', true); - expect(RNStatusBar.setBackgroundColor).toHaveBeenCalled(); - expect(RNStatusBar.setTranslucent).toHaveBeenCalled(); - }); -}); diff --git a/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx b/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx deleted file mode 100644 index 9136f723ae..0000000000 --- a/packages/mobile/src/system/__tests__/useAndroidNavigationBarUpdater.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import changeNavigationBarColor from 'react-native-navigation-bar-color'; -import { renderHook } from '@testing-library/react-hooks'; - -import { defaultTheme } from '../../themes/defaultTheme'; -import { useAndroidNavigationBarUpdater } from '../AndroidNavigationBar'; -import { ThemeProvider } from '../ThemeProvider'; - -const LightModeProvider = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -jest.useFakeTimers(); -jest.mock('react-native-navigation-bar-color'); -const mockPlatform = (OS: 'ios' | 'android', Version?: number) => { - jest.runAllTimers(); - jest.resetModules(); - jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ OS, Version })); -}; - -describe('useAndroidNavigationBarUpdater', () => { - it('does not fire for iOS', () => { - mockPlatform('ios'); - const { result } = renderHook(() => useAndroidNavigationBarUpdater(), { - wrapper: LightModeProvider, - }); - result.current(); - expect(changeNavigationBarColor).not.toHaveBeenCalled(); - }); - - it('correctly fires for android version', () => { - mockPlatform('android', 26); - - const { result } = renderHook(() => useAndroidNavigationBarUpdater(), { - wrapper: LightModeProvider, - }); - result.current(); - expect(changeNavigationBarColor).toHaveBeenCalled(); - }); -}); diff --git a/packages/mobile/src/tabs/TabLabel.tsx b/packages/mobile/src/tabs/TabLabel.tsx index 923b7fe2c4..3a4f51eb02 100644 --- a/packages/mobile/src/tabs/TabLabel.tsx +++ b/packages/mobile/src/tabs/TabLabel.tsx @@ -80,12 +80,12 @@ export const TabLabel = memo( {shouldMeasureElement ? ( - + {/* This element is used to ensure the element width doesn't change when we change font-weight */} - + ) : ( - + )} diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx index 2e632e4c49..9749b4ea72 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -35,7 +35,9 @@ type TabContainerProps = { const TabContainer = ({ id, registerRef, ...props }: TabContainerProps) => { const refCallback = useCallback( - (ref: View | null) => ref && registerRef(id, ref), + (ref: View | null) => { + if (ref) registerRef(id, ref); + }, [id, registerRef], ); return ; @@ -229,6 +231,7 @@ export const TabsActiveIndicator = ({ if (previousActiveTabRect.current !== activeTabRect) { previousActiveTabRect.current = activeTabRect; + // TODO: writing to shared value during render causes a reanimated warning which we have to suppress in jest setup animatedTabRect.value = isFirstRenderWithWidth ? newActiveTabRect : withSpring(newActiveTabRect, tabsSpringConfig); diff --git a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx index 2ccaadadb4..16783f0765 100644 --- a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import { Pressable, ScrollView } from 'react-native'; import { interpolateColor, @@ -240,7 +240,6 @@ const SegmentedTabsScreen = () => ( padding={3} tabs={basicSegments} title="With Padding" - width="fit-content" /> { }} tabs={iconSegments} title="Icon Labels" - width="fit-content" /> ); }; diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx index 1a1505f5b3..2813f22f11 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTab.test.tsx @@ -61,11 +61,6 @@ describe('SegmentedTab', () => { expect(screen.getByText('Buy')).toBeTruthy(); expect(screen.getByText('Buy')).toHaveAnimatedStyle({ color: `rgb(${defaultTheme.lightSpectrum.gray100})`, - fontFamily: 'Inter_600SemiBold', - fontSize: 16, - fontWeight: '600', - lineHeight: 24, - textAlign: 'left', }); }); @@ -80,11 +75,6 @@ describe('SegmentedTab', () => { jest.advanceTimersByTime(300); expect(screen.getByTestId(`${TEST_ID}-label`)).toHaveAnimatedStyle({ color: `rgb(${defaultTheme.lightSpectrum.gray0})`, - fontFamily: 'Inter_600SemiBold', - fontSize: 16, - fontWeight: '600', - lineHeight: 24, - textAlign: 'left', }); }); diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx index 5b461814c5..95557c4658 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -42,6 +42,9 @@ const exampleProps: SegmentedTabsProps = { tabs, activeTab: tabs[0], onChange: jest.fn(), + // Reanimated's Jest matcher can throw when a style array contains `undefined`. + // Providing an explicit indicator style keeps the test environment stable. + styles: { activeIndicator: {} }, }; const mockApi = { @@ -62,6 +65,7 @@ describe('SegmentedTabs', () => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); + it('passes a11y', () => { render( @@ -91,7 +95,6 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 0 }], }); }); @@ -131,7 +134,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 68 }, { translateY: 0 }], }); }); @@ -210,7 +212,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 20 }, { translateY: 0 }], }); }); @@ -244,7 +245,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 8 }], }); }); @@ -278,7 +278,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 20 }, { translateY: 8 }], }); }); diff --git a/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx b/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx index feba9fdfee..8c07becfc6 100644 --- a/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx +++ b/packages/mobile/src/tabs/__tests__/TabIndicator.test.tsx @@ -57,7 +57,7 @@ describe('TabIndicator', () => { it('renders with ref', () => { const TEST_ID = 'tabIndicator'; - const ref = { current: undefined } as unknown as React.RefObject; + const ref = { current: undefined } as unknown as React.RefObject; render( diff --git a/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts b/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts index d19f5831ce..8dca5051d5 100644 --- a/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts +++ b/packages/mobile/src/tabs/hooks/__tests__/useDotAnimation.test.ts @@ -1,6 +1,6 @@ import { act } from 'react'; import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useDotAnimation } from '../useDotAnimation'; diff --git a/packages/mobile/src/tabs/hooks/useDotAnimation.ts b/packages/mobile/src/tabs/hooks/useDotAnimation.ts index 4036efaf20..7210e9cf59 100644 --- a/packages/mobile/src/tabs/hooks/useDotAnimation.ts +++ b/packages/mobile/src/tabs/hooks/useDotAnimation.ts @@ -6,10 +6,28 @@ import { dotHidden, dotVisible, } from '@coinbase/cds-common/animation/dot'; -import { getDotSize } from '@coinbase/cds-common/tokens/dot'; import { convertMotionConfig } from '../../animation/convertMotionConfig'; +/** + * Fixed widths to use to achieve an animated in/out effect on the Dot component + */ +const dotSizeTokens = { s: 28, m: 36, l: 48 } as const; +/** + * Returns the appropriate dot container width based on the notification count. + * Width increases to accommodate more digits (1-digit, 2-digit, 3+ digits). + * + * @param count - The notification count to determine width for + * @returns The pixel width for the dot container + */ +const getDotSize = (count?: number) => { + if (!count || count < 10) return dotSizeTokens.s; + if (count >= 10 && count < 100) return dotSizeTokens.m; + if (count >= 100) return dotSizeTokens.l; + + return dotSizeTokens.s; +}; + // opacity animation const opacityInConfig = convertMotionConfig({ ...animateDotOpacityConfig, @@ -34,6 +52,36 @@ const scaleOutConfig = convertMotionConfig({ fromValue: getDotSize(), }); +/** + * Hook that provides animated values and animation functions for dot badge transitions. + * + * Used to animate the appearance/disappearance of notification dot badges in tab labels. + * Runs parallel opacity (fade) and width (scale) animations for smooth enter/exit effects. + * + * @returns Object containing: + * - `opacity` - Animated.Value controlling the dot's opacity (0 = hidden, 1 = visible) + * - `width` - Animated.Value controlling the dot container's width (0 = collapsed, getDotSize(count) = expanded) + * - `animateIn` - Triggers the enter animation when a dot badge should appear + * - `animateOut` - Triggers the exit animation when a dot badge should disappear + * + * @example + * ```tsx + * const { opacity, width, animateIn, animateOut } = useDotAnimation(); + * + * useEffect(() => { + * if (count > 0) animateIn(count); + * else animateOut(count); + * }, [count]); + * + * return ( + * + * + * + * + * + * ); + * ``` + */ export const useDotAnimation = () => { const opacity = useRef(new Animated.Value(dotHidden)).current; const width = useRef(new Animated.Value(dotHidden)).current; diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index 3c4714a03b..4b83fb6313 100644 --- a/packages/mobile/src/tag/Tag.tsx +++ b/packages/mobile/src/tag/Tag.tsx @@ -99,12 +99,12 @@ export const Tag = memo( alignItems={alignItems} background="bg" borderRadius={tagBorderRadiusMap[intent]} - dangerouslySetBackground={backgroundColor} flexDirection={flexDirection} gap={gap} justifyContent={justifyContent} paddingX={tagHorizontalSpacing[intent]} paddingY={paddingY} + style={{ backgroundColor }} testID={testID} {...props} > @@ -115,9 +115,9 @@ export const Tag = memo( ) : null} {children} diff --git a/packages/mobile/src/tour/DefaultTourMask.tsx b/packages/mobile/src/tour/DefaultTourMask.tsx index 4e21452439..e343c07759 100644 --- a/packages/mobile/src/tour/DefaultTourMask.tsx +++ b/packages/mobile/src/tour/DefaultTourMask.tsx @@ -1,7 +1,9 @@ import React, { memo, useEffect, useMemo, useState } from 'react'; +import { Platform } from 'react-native'; import { Defs, Mask, Rect as NativeRect, Svg } from 'react-native-svg'; import { defaultRect, type Rect } from '@coinbase/cds-common/types/Rect'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout'; @@ -11,6 +13,7 @@ export const DefaultTourMask = memo( ({ activeTourStepTarget, padding, borderRadius = 12 }: TourMaskComponentProps) => { const [rect, setRect] = useState(defaultRect); const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const overlayFillRgba = theme.color.bgOverlay; const defaultPadding = theme.space[2]; @@ -27,10 +30,19 @@ export const DefaultTourMask = memo( ); useEffect(() => { - activeTourStepTarget?.measureInWindow((x, y, width, height) => - setRect({ x, y, width, height }), - ); - }, [activeTourStepTarget]); + activeTourStepTarget?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + + setRect({ x, y: adjustedY, width, height }); + }); + }, [activeTourStepTarget, statusBarHeight]); return ( diff --git a/packages/mobile/src/tour/Tour.tsx b/packages/mobile/src/tour/Tour.tsx index c4ff59ebe6..0dbc00807d 100644 --- a/packages/mobile/src/tour/Tour.tsx +++ b/packages/mobile/src/tour/Tour.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useRef } from 'react'; -import { Modal, View } from 'react-native'; +import { Modal, Platform, View } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; import { OverlayContentContext, @@ -28,6 +28,7 @@ import { import { animated, config as springConfig, useSpring } from '@react-spring/native'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { DefaultTourMask } from './DefaultTourMask'; @@ -121,6 +122,7 @@ const TourComponent = (_props: TourProps(_props: TourProps({ steps, activeTourStep, onChange: handleChange }); + const api = useTour({ steps, activeTourStep, onChange: handleChange }); const { activeTourStepTarget, setActiveTourStepTarget } = api; // Component Lifecycle & Side Effects @@ -180,9 +182,18 @@ const TourComponent = (_props: TourProps { target?.measureInWindow((x, y, width, height) => { + // On Android, measureInWindow returns coordinates relative to the app's visible area. + // The Modal's coordinate system starts from the screen top (y=0 at very top of display). + // In edge-to-edge mode (statusBarHeight > 0), the app extends behind the status bar, + // and measureInWindow returns y relative to below the status bar. We need to ADD + // statusBarHeight to convert to screen coordinates for the Modal. + // In non-edge-to-edge mode (statusBarHeight === 0), measureInWindow returns y from + // screen top, but the Modal still starts from screen top, so no adjustment is needed. + const adjustedY = Platform.OS === 'ios' ? y : y + statusBarHeight; + refs.setReference({ measure: (callback: (x: number, y: number, width: number, height: number) => void) => { - callback(x, y, width, height); + callback(x, adjustedY, width, height); void animationApi.start({ to: { opacity: 1 }, config: springConfig.slow }); }, }); @@ -190,7 +201,7 @@ const TourComponent = (_props: TourProps(_props: TourProps diff --git a/packages/mobile/src/tour/TourStep.tsx b/packages/mobile/src/tour/TourStep.tsx index af91763f0c..6aa4165d84 100644 --- a/packages/mobile/src/tour/TourStep.tsx +++ b/packages/mobile/src/tour/TourStep.tsx @@ -16,7 +16,11 @@ type TourStepProps = { export const TourStep = ({ id, children }: TourStepProps) => { const { activeTourStep, setActiveTourStepTarget } = useTourContext(); const refCallback = useCallback( - (ref: View) => activeTourStep?.id === id && ref && setActiveTourStepTarget(ref), + (ref: View | null) => { + if (activeTourStep?.id === id && ref) { + setActiveTourStepTarget(ref); + } + }, [activeTourStep, id, setActiveTourStepTarget], ); return ( diff --git a/packages/mobile/src/tour/__stories__/Tour.stories.tsx b/packages/mobile/src/tour/__stories__/Tour.stories.tsx index 2b413a93d7..df81e55583 100644 --- a/packages/mobile/src/tour/__stories__/Tour.stories.tsx +++ b/packages/mobile/src/tour/__stories__/Tour.stories.tsx @@ -21,9 +21,9 @@ const TourExamples = ({ step4Ref, ids, }: { - step2Ref: React.RefObject; - step3Ref: React.RefObject; - step4Ref: React.RefObject; + step2Ref: React.RefObject; + step3Ref: React.RefObject; + step4Ref: React.RefObject; ids: TourStepId[]; }) => { const { startTour } = useTourContext(); @@ -85,8 +85,8 @@ const StepOne = () => { }; const scrollIntoView = async ( - scrollViewRef: React.RefObject, - elementRef: React.RefObject, + scrollViewRef: React.RefObject, + elementRef: React.RefObject, ) => { const scrollView = scrollViewRef.current; if (!scrollView) return; diff --git a/packages/mobile/src/tour/__tests__/Tour.test.tsx b/packages/mobile/src/tour/__tests__/Tour.test.tsx index 1d1ad49a01..24421cc9a5 100644 --- a/packages/mobile/src/tour/__tests__/Tour.test.tsx +++ b/packages/mobile/src/tour/__tests__/Tour.test.tsx @@ -3,9 +3,15 @@ import { Button, Text } from 'react-native'; import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; +import { useDimensions } from '../../hooks/useDimensions'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Tour, type TourProps } from '../Tour'; +jest.mock('../../hooks/useDimensions'); +const mockUseDimensions = (mocks: ReturnType) => { + (useDimensions as jest.Mock).mockReturnValue(mocks); +}; + const StepOne = () => { const { goNextTourStep } = useTourContext(); @@ -51,6 +57,14 @@ const exampleProps: TourProps = { }; describe('Tour', () => { + beforeEach(() => { + mockUseDimensions({ + screenHeight: 844, + screenWidth: 390, + statusBarHeight: 47, + }); + }); + it('passes accessibility', async () => { render( diff --git a/packages/mobile/src/typography/Text.tsx b/packages/mobile/src/typography/Text.tsx index 0a58d183b2..59d92595a4 100644 --- a/packages/mobile/src/typography/Text.tsx +++ b/packages/mobile/src/typography/Text.tsx @@ -60,9 +60,15 @@ export type TextBaseProps = StyleProps & { * @default false */ noWrap?: boolean; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style` or the `color` style prop to set custom text colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: TextStyle['color']; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `style` or the `background` style prop to set custom text background colors. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetBackground?: TextStyle['backgroundColor']; /** * @deprecated Do not use this prop, it is a migration escape hatch. This will be removed in a future major release. @@ -90,6 +96,8 @@ const styles = StyleSheet.create({ }, }); +const HEADER_FONTS = new Set(['display1', 'display2', 'display3', 'title1', 'title2']); + export const Text = memo( forwardRef( ( @@ -176,6 +184,7 @@ export const Text = memo( flexGrow, opacity, renderEmptyNode = true, + accessibilityRole = HEADER_FONTS.has(font) ? 'header' : undefined, ...props }, ref, @@ -372,9 +381,11 @@ export const Text = memo( return ( } testID={testID} {...props} > diff --git a/packages/mobile/src/typography/TextBody.tsx b/packages/mobile/src/typography/TextBody.tsx index 6a7b58da4e..88784b044f 100644 --- a/packages/mobile/src/typography/TextBody.tsx +++ b/packages/mobile/src/typography/TextBody.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextBodyBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextBodyProps = TextProps; +/** + * @deprecated Use `Text` with `font="body"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextBody = memo( forwardRef(({ font = 'body', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextCaption.tsx b/packages/mobile/src/typography/TextCaption.tsx index f41aced2f3..45ecf00653 100644 --- a/packages/mobile/src/typography/TextCaption.tsx +++ b/packages/mobile/src/typography/TextCaption.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextCaptionBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextCaptionProps = TextProps; +/** + * @deprecated Use `Text` with `font="caption"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextCaption = memo( forwardRef(({ font = 'caption', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay1.tsx b/packages/mobile/src/typography/TextDisplay1.tsx index 6fdaedc810..2e7faca203 100644 --- a/packages/mobile/src/typography/TextDisplay1.tsx +++ b/packages/mobile/src/typography/TextDisplay1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay1Props = TextProps; +/** + * @deprecated Use `Text` with `font="display1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay1 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay2.tsx b/packages/mobile/src/typography/TextDisplay2.tsx index d0f06dc745..fa3cc37e56 100644 --- a/packages/mobile/src/typography/TextDisplay2.tsx +++ b/packages/mobile/src/typography/TextDisplay2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay2Props = TextProps; +/** + * @deprecated Use `Text` with `font="display2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay2 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextDisplay3.tsx b/packages/mobile/src/typography/TextDisplay3.tsx index 4ed5184b6e..76a353e08f 100644 --- a/packages/mobile/src/typography/TextDisplay3.tsx +++ b/packages/mobile/src/typography/TextDisplay3.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay3BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextDisplay3Props = TextProps; +/** + * @deprecated Use `Text` with `font="display3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextDisplay3 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'display3', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextHeadline.tsx b/packages/mobile/src/typography/TextHeadline.tsx index cb7622b3e9..d6adaf333f 100644 --- a/packages/mobile/src/typography/TextHeadline.tsx +++ b/packages/mobile/src/typography/TextHeadline.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextHeadlineBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextHeadlineProps = TextProps; +/** + * @deprecated Use `Text` with `font="headline"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextHeadline = memo( forwardRef(({ font = 'headline', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextInherited.tsx b/packages/mobile/src/typography/TextInherited.tsx index dc2994c3e4..4caf1a5457 100644 --- a/packages/mobile/src/typography/TextInherited.tsx +++ b/packages/mobile/src/typography/TextInherited.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextInheritedBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextInheritedProps = TextProps; +/** + * @deprecated Use `Text` with `font="inherit"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextInherited = memo( forwardRef(({ font = 'inherit', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLabel1.tsx b/packages/mobile/src/typography/TextLabel1.tsx index 3679c7290e..1109425446 100644 --- a/packages/mobile/src/typography/TextLabel1.tsx +++ b/packages/mobile/src/typography/TextLabel1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel1Props = TextProps; +/** + * @deprecated Use `Text` with `font="label1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLabel1 = memo( forwardRef(({ font = 'label1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLabel2.tsx b/packages/mobile/src/typography/TextLabel2.tsx index 007095fc11..8ef01ac486 100644 --- a/packages/mobile/src/typography/TextLabel2.tsx +++ b/packages/mobile/src/typography/TextLabel2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLabel2Props = TextProps; +/** + * @deprecated Use `Text` with `font="label2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLabel2 = memo( forwardRef(({ font = 'label2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextLegal.tsx b/packages/mobile/src/typography/TextLegal.tsx index 79bf152f41..8abd316515 100644 --- a/packages/mobile/src/typography/TextLegal.tsx +++ b/packages/mobile/src/typography/TextLegal.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLegalBaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextLegalProps = TextProps; +/** + * @deprecated Use `Text` with `font="legal"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextLegal = memo( forwardRef(({ font = 'legal', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle1.tsx b/packages/mobile/src/typography/TextTitle1.tsx index e674a9ae90..e47a91ead1 100644 --- a/packages/mobile/src/typography/TextTitle1.tsx +++ b/packages/mobile/src/typography/TextTitle1.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle1BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle1Props = TextProps; +/** + * @deprecated Use `Text` with `font="title1"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle1 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'title1', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle2.tsx b/packages/mobile/src/typography/TextTitle2.tsx index eed58710e0..b57b96b7ce 100644 --- a/packages/mobile/src/typography/TextTitle2.tsx +++ b/packages/mobile/src/typography/TextTitle2.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle2BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle2Props = TextProps; +/** + * @deprecated Use `Text` with `font="title2"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle2 = memo( forwardRef( ({ accessibilityRole = 'header', font = 'title2', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle3.tsx b/packages/mobile/src/typography/TextTitle3.tsx index e179427bfc..9348ee8639 100644 --- a/packages/mobile/src/typography/TextTitle3.tsx +++ b/packages/mobile/src/typography/TextTitle3.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle3BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle3Props = TextProps; +/** + * @deprecated Use `Text` with `font="title3"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle3 = memo( forwardRef(({ font = 'title3', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/TextTitle4.tsx b/packages/mobile/src/typography/TextTitle4.tsx index a7741f0d25..53dfbe80d9 100644 --- a/packages/mobile/src/typography/TextTitle4.tsx +++ b/packages/mobile/src/typography/TextTitle4.tsx @@ -3,10 +3,22 @@ import type { Text as NativeText } from 'react-native'; import { Text, type TextBaseProps, type TextProps } from './Text'; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle4BaseProps = TextBaseProps; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TextTitle4Props = TextProps; +/** + * @deprecated Use `Text` with `font="title4"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const TextTitle4 = memo( forwardRef(({ font = 'title4', ...props }, ref) => ( diff --git a/packages/mobile/src/typography/__stories__/Link.stories.tsx b/packages/mobile/src/typography/__stories__/Link.stories.tsx index 7e70074e9e..35d7fcc099 100644 --- a/packages/mobile/src/typography/__stories__/Link.stories.tsx +++ b/packages/mobile/src/typography/__stories__/Link.stories.tsx @@ -5,7 +5,6 @@ import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { useWebBrowserOpener } from '../../hooks/useWebBrowserOpener'; import { Link } from '../Link'; import { Text } from '../Text'; -import { TextLegal } from '../TextLegal'; const typographies = [ 'display1', @@ -191,11 +190,12 @@ const LinkScreen = function LinkScreen() { Nested link in text {/** refer to this blog about this best practice: https://www.yeti.co/blog/accessibility-first-in-react-native */} - { try { const screenReaderEnabled = await AccessibilityInfo.isScreenReaderEnabled(); @@ -211,7 +211,7 @@ const LinkScreen = function LinkScreen() { Consider a case where you have a block of text with an inline link.{' '} Like so. You may want to write your code like this. - + Multiple nested link in text diff --git a/packages/mobile/src/typography/__stories__/Text.stories.tsx b/packages/mobile/src/typography/__stories__/Text.stories.tsx index 82fa5831d2..c888a98ee0 100644 --- a/packages/mobile/src/typography/__stories__/Text.stories.tsx +++ b/packages/mobile/src/typography/__stories__/Text.stories.tsx @@ -1,52 +1,66 @@ import React from 'react'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; -import { TextBody } from '../TextBody'; -import { TextCaption } from '../TextCaption'; -import { TextDisplay1 } from '../TextDisplay1'; -import { TextDisplay2 } from '../TextDisplay2'; -import { TextDisplay3 } from '../TextDisplay3'; -import { TextHeadline } from '../TextHeadline'; -import { TextLabel1 } from '../TextLabel1'; -import { TextLabel2 } from '../TextLabel2'; -import { TextLegal } from '../TextLegal'; -import { TextTitle1 } from '../TextTitle1'; -import { TextTitle2 } from '../TextTitle2'; -import { TextTitle3 } from '../TextTitle3'; -import { TextTitle4 } from '../TextTitle4'; +import { Text } from '../Text'; const TextScreen = () => { return ( - Display1 - Display2 - Display3 - Title1 - Title2 - Title3 - Title4 - Label1 - Label2 - Headline - Body - Caption - Legal + Display1 + Display2 + Display3 + Title1 + Title2 + Title3 + Title4 + Label1 + Label2 + Headline + Body + Caption + Legal - Display1 - Display2 - Display3 - Title1 - Title2 - Title3 - Title4 - Label1 - Label2 - Headline - Body - Caption - Legal + + Display1 + + + Display2 + + + Display3 + + + Title1 + + + Title2 + + + Title3 + + + Title4 + + + Label1 + + + Label2 + + + Headline + + + Body + + + Caption + + + Legal + ); diff --git a/packages/mobile/src/typography/__tests__/Link.test.tsx b/packages/mobile/src/typography/__tests__/Link.test.tsx index 28473b7d05..39b2f5affe 100644 --- a/packages/mobile/src/typography/__tests__/Link.test.tsx +++ b/packages/mobile/src/typography/__tests__/Link.test.tsx @@ -1,9 +1,12 @@ -import TestRenderer from 'react-test-renderer'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { useWebBrowserOpener } from '../../hooks/useWebBrowserOpener'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Link, type LinkProps } from '../Link'; +jest.mock('../../hooks/useWebBrowserOpener'); +const mockUseWebBrowserOpener = useWebBrowserOpener as jest.Mock; + const TEST_ID = 'link'; const URL = 'www.coinbase.com'; const variants = [ @@ -24,6 +27,10 @@ const variants = [ ] as LinkProps['font'][]; describe('Link', () => { + beforeEach(() => { + mockUseWebBrowserOpener.mockReturnValue(jest.fn()); + }); + it('passes a11y', () => { render( @@ -89,40 +96,58 @@ describe('Link', () => { expect(spy).toHaveBeenCalled(); }); - it('to prop works as expected', async () => { - const linkRenderer = TestRenderer.create( + it('opens URL when pressed', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const linkInstance = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(linkInstance.props.to).toEqual(URL); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith(URL, expect.any(Object)); }); - it('can set forceOpenOutsideApp to true', async () => { - const linkRenderer = TestRenderer.create( + it('passes forceOpenOutsideApp option to browser opener', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const link = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(link.props.forceOpenOutsideApp).toBe(true); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith( + URL, + expect.objectContaining({ forceOpenOutsideApp: true }), + ); }); - it('can set readerMode to true', async () => { - const linkRenderer = TestRenderer.create( + it('passes readerMode option to browser opener', () => { + const mockOpenUrl = jest.fn(); + mockUseWebBrowserOpener.mockReturnValue(mockOpenUrl); + + render( Child , ); - const link = await linkRenderer.root.findByProps({ testID: TEST_ID }); - expect(link.props.readerMode).toBe(true); + + fireEvent.press(screen.getByTestId(TEST_ID)); + + expect(mockOpenUrl).toHaveBeenCalledWith(URL, expect.objectContaining({ readerMode: true })); }); it('removes text style when inherited', () => { diff --git a/packages/mobile/src/utils/testHelpers.tsx b/packages/mobile/src/utils/testHelpers.tsx index 26171df19f..3a9308400d 100644 --- a/packages/mobile/src/utils/testHelpers.tsx +++ b/packages/mobile/src/utils/testHelpers.tsx @@ -29,3 +29,33 @@ export const DefaultThemeProvider = ({ ); }; + +export function flattenStyle(style: unknown): Array> { + if (!style) return []; + if (Array.isArray(style)) return style.flatMap(flattenStyle); + if (typeof style === 'object') return [style as Record]; + return []; +} + +export function treeHasStyleProp( + tree: unknown, + predicate: (style: Record) => boolean, +): boolean { + if (!tree) return false; + + if (Array.isArray(tree)) { + return tree.some((node) => treeHasStyleProp(node, predicate)); + } + + if (typeof tree !== 'object') return false; + + const node = tree as { + props?: { style?: unknown }; + children?: unknown[]; + }; + + const styles = flattenStyle(node.props?.style); + if (styles.some(predicate)) return true; + + return (node.children ?? []).some((child) => treeHasStyleProp(child, predicate)); +} diff --git a/packages/mobile/src/visualizations/ProgressBar.tsx b/packages/mobile/src/visualizations/ProgressBar.tsx index 5787665d2d..307164eadf 100644 --- a/packages/mobile/src/visualizations/ProgressBar.tsx +++ b/packages/mobile/src/visualizations/ProgressBar.tsx @@ -157,7 +157,10 @@ export const ProgressBar = memo( flexShrink={0} height="100%" justifyContent="center" - style={progressStyle} + style={[ + { backgroundColor: !disabled ? theme.color[color] : theme.color.bgLineHeavy }, + progressStyle, + ]} testID="cds-progress-bar" width="100%" /> diff --git a/packages/mobile/src/visualizations/ProgressCircle.tsx b/packages/mobile/src/visualizations/ProgressCircle.tsx index 0e35fced43..68e85a7689 100644 --- a/packages/mobile/src/visualizations/ProgressCircle.tsx +++ b/packages/mobile/src/visualizations/ProgressCircle.tsx @@ -4,7 +4,7 @@ import type { CircleProps } from 'react-native-svg'; import { Circle, G, Svg } from 'react-native-svg'; import type { SharedProps, ThemeVars } from '@coinbase/cds-common'; import { animateProgressBaseSpec } from '@coinbase/cds-common/animation/progress'; -import { getCircumference, getRadius } from '@coinbase/cds-common/utils/circle'; +import { getCenter, getCircumference, getRadius } from '@coinbase/cds-common/utils/circle'; import { getProgressCircleParams } from '@coinbase/cds-common/visualizations/getProgressCircleParams'; import { getProgressSize } from '@coinbase/cds-common/visualizations/getProgressSize'; import { isTest } from '@coinbase/cds-utils'; diff --git a/packages/mobile/src/visualizations/VisualizationContainer.tsx b/packages/mobile/src/visualizations/VisualizationContainer.tsx index aeace396c0..11cc01ead4 100644 --- a/packages/mobile/src/visualizations/VisualizationContainer.tsx +++ b/packages/mobile/src/visualizations/VisualizationContainer.tsx @@ -1,5 +1,5 @@ import React, { memo } from 'react'; -import type { DimensionValue } from '@coinbase/cds-common/types'; +import type { DimensionValue } from 'react-native'; import { useVisualizationDimensions } from '@coinbase/cds-common/visualizations/useVisualizationDimensions'; import { useLayout } from '../hooks/useLayout'; @@ -27,7 +27,7 @@ export const VisualizationContainer: React.FC = mem ({ width, height, children }) => { const [{ width: layoutWidth, height: layoutHeight }, onLayout] = useLayout(); - const dimensions = useVisualizationDimensions({ + const dimensions = useVisualizationDimensions({ userDefinedWidth: width, userDefinedHeight: height, calculatedWidth: layoutWidth, diff --git a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx index d8db07d081..32631e572a 100644 --- a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx +++ b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx @@ -1,5 +1,4 @@ import React, { act } from 'react'; -import type { ReactTestInstance } from 'react-test-renderer'; import type { UseCounterParams } from '@coinbase/cds-common/visualizations/useCounter'; import { fireEvent, render, screen } from '@testing-library/react-native'; @@ -16,7 +15,7 @@ jest.mock('@coinbase/cds-common/visualizations/useCounter', () => ({ useCounter: ({ endNum }: UseCounterParams) => endNum, })); -function fireTextEvent(floatLabel: ReactTestInstance) { +function fireTextEvent(floatLabel: Parameters[0]) { fireEvent(floatLabel, 'layout', { nativeEvent: { layout: { @@ -31,7 +30,7 @@ function fireTextEvent(floatLabel: ReactTestInstance) { }); } -function fireTextContainerEvent(floatLabelContainer: ReactTestInstance) { +function fireTextContainerEvent(floatLabelContainer: Parameters[0]) { fireEvent(floatLabelContainer, 'layout', { nativeEvent: { layout: { @@ -54,7 +53,7 @@ describe('ProgressBar test', () => { it('places bar label in correct position if it flows off the left container and passes a11y', async () => { render( - + @@ -78,7 +77,7 @@ describe('ProgressBar test', () => { it('places bar label in correct position in middle', () => { render( - + @@ -104,7 +103,7 @@ describe('ProgressBar test', () => { it('renders fixed labels in correct position', () => { render( - + { it('has correct bar width', () => { render( - + , @@ -146,7 +145,7 @@ describe('ProgressBar test', () => { it('has correct bar height', () => { render( - + , @@ -164,7 +163,7 @@ describe('ProgressBar test', () => { it('handles disabled state for just ProgressBar correctly & passes a11y', () => { render( - + , @@ -185,7 +184,7 @@ describe('ProgressBar test', () => { it('handles disabled state correctly for fixed labels', () => { render( - + { render( - + { render( - + { it('applies custom styles correctly', () => { render( - + { it('applies custom styles to ProgressBarWithFixedLabels', () => { render( - + { it('applies custom styles to ProgressBarWithFloatLabel', () => { render( - + { it('rounds accessibilityValue.now to the nearest integer', () => { render( - + , @@ -378,7 +377,7 @@ describe('ProgressBar test', () => { it('skips mount animation when disableAnimateOnMount is true for ProgressBar', () => { render( - + , @@ -397,7 +396,7 @@ describe('ProgressBar test', () => { it('starts at animation start position when disableAnimateOnMount is not set', () => { render( - + , @@ -416,7 +415,7 @@ describe('ProgressBar test', () => { it('skips mount animation when disableAnimateOnMount is true for ProgressBarWithFixedLabels', () => { render( - + { it('skips mount animation when disableAnimateOnMount is true for ProgressBarWithFloatLabel', () => { render( - + [] = [ - ThemeContext, - CartesianChartContext, - ScrubberContext, -]; +const BRIDGED_CONTEXTS: Context[] = [ThemeContext, CartesianChartContext, ScrubberContext]; /** * Represents a react-internal tree node. @@ -55,7 +63,7 @@ function traverseTreeNode( /** * Wraps context to hide React development warnings about using contexts between renderers. */ -function wrapContext(context: React.Context): React.Context { +function wrapContext(context: Context): Context { try { return Object.defineProperties(context, { _currentRenderer: { @@ -89,12 +97,12 @@ console.error = function (...args: any[]) { return error.apply(this, args); }; -const TreeNodeContext = wrapContext(React.createContext(null!)); +const TreeNodeContext = wrapContext(createContext(null!)); /** * A react-internal tree node provider that binds React children to the React tree for chart context bridging. */ -export class ChartBridgeProvider extends React.Component<{ children?: React.ReactNode }> { +export class ChartBridgeProvider extends Component<{ children?: ReactNode }> { private _reactInternals!: TreeNode; render() { @@ -110,12 +118,12 @@ export class ChartBridgeProvider extends React.Component<{ children?: React.Reac * Returns the current react-internal tree node. */ function useTreeNode(): TreeNode | undefined { - const root = React.useContext(TreeNodeContext); + const root = useContext(TreeNodeContext); if (root === null) throw new Error('useTreeNode must be called within a !'); - const id = React.useId(); - const treeNode = React.useMemo(() => { + const id = useId(); + const treeNode = useMemo(() => { for (const maybeNode of [root, root?.alternate]) { if (!maybeNode) continue; const node = traverseTreeNode(maybeNode, false, (node) => { @@ -132,8 +140,8 @@ function useTreeNode(): TreeNode | undefined { return treeNode; } -export type ContextMap = Map, any> & { - get(context: React.Context): T | undefined; +export type ContextMap = Map, any> & { + get(context: Context): T | undefined; }; /** @@ -141,7 +149,7 @@ export type ContextMap = Map, any> & { */ function useContextMap(): ContextMap { const treeNode = useTreeNode(); - const [contextMap] = React.useState(() => new Map, any>()); + const [contextMap] = useState(() => new Map, any>()); // Collect live context contextMap.clear(); @@ -159,7 +167,7 @@ function useContextMap(): ContextMap { !contextMap.has(context) ) { // eslint-disable-next-line react-hooks/rules-of-hooks - contextMap.set(context, React.useContext(wrapContext(context))); + contextMap.set(context, useContext(wrapContext(context))); } } @@ -172,7 +180,7 @@ function useContextMap(): ContextMap { /** * Represents a chart context bridge provider component. */ -export type ChartContextBridge = React.FC>; +export type ChartContextBridge = FC>; /** * Returns a ChartContextBridge of live context providers to pierce Context across renderers. @@ -182,7 +190,7 @@ export function useChartContextBridge(): ChartContextBridge { const contextMap = useContextMap(); // Flatten context and their memoized values into a `ChartContextBridge` provider - return React.useMemo( + return useMemo( () => Array.from(contextMap.keys()).reduce( (Prev, context) => (props) => ( diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile/src/visualizations/chart/ChartProvider.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/ChartProvider.tsx rename to packages/mobile/src/visualizations/chart/ChartProvider.tsx diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile/src/visualizations/chart/Path.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/Path.tsx rename to packages/mobile/src/visualizations/chart/Path.tsx diff --git a/packages/mobile-visualization/src/chart/PeriodSelector.tsx b/packages/mobile/src/visualizations/chart/PeriodSelector.tsx similarity index 93% rename from packages/mobile-visualization/src/chart/PeriodSelector.tsx rename to packages/mobile/src/visualizations/chart/PeriodSelector.tsx index 6dfe5369c2..d7026cb3c7 100644 --- a/packages/mobile-visualization/src/chart/PeriodSelector.tsx +++ b/packages/mobile/src/visualizations/chart/PeriodSelector.tsx @@ -1,16 +1,17 @@ import React, { forwardRef, memo, useMemo } from 'react'; import { StyleSheet, View, type ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../hooks/useTheme'; import { SegmentedTabs, type SegmentedTabsProps, type TabComponent, type TabsActiveIndicatorProps, -} from '@coinbase/cds-mobile/tabs'; -import { SegmentedTab, type SegmentedTabProps } from '@coinbase/cds-mobile/tabs/SegmentedTab'; -import { tabsSpringConfig } from '@coinbase/cds-mobile/tabs/Tabs'; -import { Text, type TextBaseProps } from '@coinbase/cds-mobile/typography'; +} from '../../tabs'; +import { SegmentedTab, type SegmentedTabProps } from '../../tabs/SegmentedTab'; +import { tabsSpringConfig } from '../../tabs/Tabs'; +import { Text, type TextBaseProps } from '../../typography'; // Animated active indicator to support smooth transition of background color export const PeriodSelectorActiveIndicator = ({ diff --git a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/mobile/src/visualizations/chart/__stories__/CartesianChart.stories.tsx similarity index 94% rename from packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx rename to packages/mobile/src/visualizations/chart/__stories__/CartesianChart.stories.tsx index abad603bd2..e5cfe88d8d 100644 --- a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx +++ b/packages/mobile/src/visualizations/chart/__stories__/CartesianChart.stories.tsx @@ -1,13 +1,13 @@ import { memo, useCallback, useMemo, useState } from 'react'; -import { Image, ScrollView, StyleSheet } from 'react-native'; +import { Image, StyleSheet } from 'react-native'; import { assets } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; import { Circle, Group, Skia } from '@shopify/react-native-skia'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { useTheme } from '../../../hooks/useTheme'; +import { Box, HStack, VStack } from '../../../layout'; +import { Text } from '../../../typography'; import { Area } from '../area/Area'; import { XAxis, YAxis } from '../axis'; import { BarPlot } from '../bar/BarPlot'; @@ -524,28 +524,26 @@ const ScatterplotWithCustomLabels = memo(() => { const ChartStories = () => { return ( - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx b/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx rename to packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx index 09cc7603c1..924f94d4c8 100644 --- a/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx +++ b/packages/mobile/src/visualizations/chart/__stories__/ChartAccessibility.stories.tsx @@ -4,17 +4,17 @@ import { assets } from '@coinbase/cds-common/internal/data/assets'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; -import { useTheme } from '@coinbase/cds-mobile'; -import { IconButton } from '@coinbase/cds-mobile/buttons'; -import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { RemoteImage } from '@coinbase/cds-mobile/media'; -import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader'; -import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs'; -import { SegmentedTab, type SegmentedTabProps } from '@coinbase/cds-mobile/tabs/SegmentedTab'; -import { Text } from '@coinbase/cds-mobile/typography'; import { FontWeight, Skia, type SkTextStyle, TextAlign } from '@shopify/react-native-skia'; +import { IconButton } from '../../../buttons'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { useTheme } from '../../../index'; +import { HStack, VStack } from '../../../layout'; +import { RemoteImage } from '../../../media'; +import { SectionHeader } from '../../../section-header/SectionHeader'; +import { type TabComponent, type TabsActiveIndicatorProps } from '../../../tabs'; +import { SegmentedTab, type SegmentedTabProps } from '../../../tabs/SegmentedTab'; +import { Text } from '../../../typography'; import { XAxis, YAxis } from '../axis'; import { BarChart } from '../bar/BarChart'; import { BarPlot } from '../bar/BarPlot'; @@ -660,7 +660,7 @@ function ExampleNavigator() { Swipe to navigate chart segments. - {currentExample.component} + {currentExample.component} diff --git a/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx b/packages/mobile/src/visualizations/chart/__stories__/ChartTransitions.stories.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx rename to packages/mobile/src/visualizations/chart/__stories__/ChartTransitions.stories.tsx index 38e809b32a..5a0e22c8da 100644 --- a/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx +++ b/packages/mobile/src/visualizations/chart/__stories__/ChartTransitions.stories.tsx @@ -1,10 +1,10 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Button } from '@coinbase/cds-mobile/buttons/Button'; -import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; -import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { Button } from '../../../buttons/Button'; +import { IconButton } from '../../../buttons/IconButton'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { Box, HStack, VStack } from '../../../layout'; +import { Text } from '../../../typography'; import { AreaChart } from '../area/AreaChart'; import type { BarProps } from '../bar/Bar'; import { BarChart } from '../bar/BarChart'; @@ -538,7 +538,7 @@ function ExampleNavigator() { variant="secondary" /> - {currentExample.component} + {currentExample.component} ); diff --git a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx b/packages/mobile/src/visualizations/chart/__stories__/PeriodSelector.stories.tsx similarity index 92% rename from packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx rename to packages/mobile/src/visualizations/chart/__stories__/PeriodSelector.stories.tsx index 2cfb07603c..8fa8f36117 100644 --- a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx +++ b/packages/mobile/src/visualizations/chart/__stories__/PeriodSelector.stories.tsx @@ -10,16 +10,16 @@ import { import { assets } from '@coinbase/cds-common/internal/data/assets'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; -import { IconButton } from '@coinbase/cds-mobile/buttons'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Icon, type IconProps } from '@coinbase/cds-mobile/icons'; -import { HStack } from '@coinbase/cds-mobile/layout'; -import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs'; -import { SegmentedTab, type SegmentedTabProps } from '@coinbase/cds-mobile/tabs/SegmentedTab'; -import { tabsSpringConfig } from '@coinbase/cds-mobile/tabs/Tabs'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { IconButton } from '../../../buttons'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { useTheme } from '../../../hooks/useTheme'; +import { Icon, type IconProps } from '../../../icons'; +import { HStack } from '../../../layout'; +import { type TabComponent, type TabsActiveIndicatorProps } from '../../../tabs'; +import { SegmentedTab, type SegmentedTabProps } from '../../../tabs/SegmentedTab'; +import { tabsSpringConfig } from '../../../tabs/Tabs'; +import { Text } from '../../../typography'; import { LiveTabLabel, type LiveTabLabelProps, @@ -56,7 +56,7 @@ const MinWidthPeriodSelectorExample = () => { gap={0.5} onChange={(tab) => setActiveTab(tab)} tabs={tabs} - width="fit-content" + width={null} /> ); }; @@ -75,7 +75,7 @@ const PaddedPeriodSelectorExample = () => { onChange={(tab) => setActiveTab(tab)} padding={3} tabs={tabs} - width="fit-content" + width={null} /> ); }; @@ -148,7 +148,7 @@ const TooManyPeriodsSelectorExample = () => { justifyContent="flex-start" onChange={setActiveTab} tabs={tabs} - width="fit-content" + width={null} /> + {label} ) : ( @@ -341,7 +341,7 @@ function IconsPeriodSelectorExample() { activeIndicator: { borderRadius: theme.borderRadius[200] }, }} tabs={tabs} - width="fit-content" + width={null} /> ); } diff --git a/packages/mobile-visualization/src/chart/area/Area.tsx b/packages/mobile/src/visualizations/chart/area/Area.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/area/Area.tsx rename to packages/mobile/src/visualizations/chart/area/Area.tsx diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile/src/visualizations/chart/area/AreaChart.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/area/AreaChart.tsx rename to packages/mobile/src/visualizations/chart/area/AreaChart.tsx index 8ba6175219..cd9d692f62 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile/src/visualizations/chart/area/AreaChart.tsx @@ -205,12 +205,12 @@ export const AreaChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/mobile-visualization/src/chart/area/DottedArea.tsx b/packages/mobile/src/visualizations/chart/area/DottedArea.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/area/DottedArea.tsx rename to packages/mobile/src/visualizations/chart/area/DottedArea.tsx index d9a432f20d..6281d2619f 100644 --- a/packages/mobile-visualization/src/chart/area/DottedArea.tsx +++ b/packages/mobile/src/visualizations/chart/area/DottedArea.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { Group, Skia } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/area/GradientArea.tsx b/packages/mobile/src/visualizations/chart/area/GradientArea.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/area/GradientArea.tsx rename to packages/mobile/src/visualizations/chart/area/GradientArea.tsx index 8d3c3604d2..eeff480e68 100644 --- a/packages/mobile-visualization/src/chart/area/GradientArea.tsx +++ b/packages/mobile/src/visualizations/chart/area/GradientArea.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/area/SolidArea.tsx b/packages/mobile/src/visualizations/chart/area/SolidArea.tsx similarity index 94% rename from packages/mobile-visualization/src/chart/area/SolidArea.tsx rename to packages/mobile/src/visualizations/chart/area/SolidArea.tsx index 7162439e7b..58b3418cf5 100644 --- a/packages/mobile-visualization/src/chart/area/SolidArea.tsx +++ b/packages/mobile/src/visualizations/chart/area/SolidArea.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx b/packages/mobile/src/visualizations/chart/area/__stories__/AreaChart.stories.tsx similarity index 96% rename from packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx rename to packages/mobile/src/visualizations/chart/area/__stories__/AreaChart.stories.tsx index 5396bca8e6..145a6ac0e8 100644 --- a/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx +++ b/packages/mobile/src/visualizations/chart/area/__stories__/AreaChart.stories.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../hooks/useTheme'; import { DottedLine } from '../../line'; import { Scrubber } from '../../scrubber/Scrubber'; import { AreaChart } from '..'; diff --git a/packages/mobile-visualization/src/chart/area/index.ts b/packages/mobile/src/visualizations/chart/area/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/area/index.ts rename to packages/mobile/src/visualizations/chart/area/index.ts diff --git a/packages/mobile-visualization/src/chart/axis/Axis.tsx b/packages/mobile/src/visualizations/chart/axis/Axis.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/axis/Axis.tsx rename to packages/mobile/src/visualizations/chart/axis/Axis.tsx diff --git a/packages/mobile-visualization/src/chart/axis/DefaultAxisTickLabel.tsx b/packages/mobile/src/visualizations/chart/axis/DefaultAxisTickLabel.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/axis/DefaultAxisTickLabel.tsx rename to packages/mobile/src/visualizations/chart/axis/DefaultAxisTickLabel.tsx diff --git a/packages/mobile-visualization/src/chart/axis/XAxis.tsx b/packages/mobile/src/visualizations/chart/axis/XAxis.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/axis/XAxis.tsx rename to packages/mobile/src/visualizations/chart/axis/XAxis.tsx index 5b791a8e13..495bd6c468 100644 --- a/packages/mobile-visualization/src/chart/axis/XAxis.tsx +++ b/packages/mobile/src/visualizations/chart/axis/XAxis.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useId, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { Group } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; import { SolidLine } from '../line/SolidLine'; diff --git a/packages/mobile-visualization/src/chart/axis/YAxis.tsx b/packages/mobile/src/visualizations/chart/axis/YAxis.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/axis/YAxis.tsx rename to packages/mobile/src/visualizations/chart/axis/YAxis.tsx index 14d3e1635b..b5429d67a8 100644 --- a/packages/mobile-visualization/src/chart/axis/YAxis.tsx +++ b/packages/mobile/src/visualizations/chart/axis/YAxis.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useId, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { Group, vec } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { DottedLine } from '../line/DottedLine'; import { SolidLine } from '../line/SolidLine'; diff --git a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx b/packages/mobile/src/visualizations/chart/axis/__stories__/Axis.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx rename to packages/mobile/src/visualizations/chart/axis/__stories__/Axis.stories.tsx index 9477a00dd5..04cf90291f 100644 --- a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx +++ b/packages/mobile/src/visualizations/chart/axis/__stories__/Axis.stories.tsx @@ -1,7 +1,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../hooks/useTheme'; import { BarChart, BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { LineChart, SolidLine, type SolidLineProps } from '../../line'; diff --git a/packages/mobile-visualization/src/chart/axis/index.ts b/packages/mobile/src/visualizations/chart/axis/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/axis/index.ts rename to packages/mobile/src/visualizations/chart/axis/index.ts diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile/src/visualizations/chart/bar/Bar.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/bar/Bar.tsx rename to packages/mobile/src/visualizations/chart/bar/Bar.tsx index 2ad84355c6..f00953eb27 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile/src/visualizations/chart/bar/Bar.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react'; import type { Rect } from '@coinbase/cds-common'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { type BarTransition, getBarPath, type Transition } from '../utils'; diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile/src/visualizations/chart/bar/BarChart.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/bar/BarChart.tsx rename to packages/mobile/src/visualizations/chart/bar/BarChart.tsx index e178468dc4..02c1229509 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile/src/visualizations/chart/bar/BarChart.tsx @@ -207,12 +207,12 @@ export const BarChart = memo( return ( {showXAxis && } {showYAxis && } diff --git a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx b/packages/mobile/src/visualizations/chart/bar/BarPlot.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/bar/BarPlot.tsx rename to packages/mobile/src/visualizations/chart/bar/BarPlot.tsx diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile/src/visualizations/chart/bar/BarStack.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/bar/BarStack.tsx rename to packages/mobile/src/visualizations/chart/bar/BarStack.tsx index 6130a32b03..f94e30078d 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile/src/visualizations/chart/bar/BarStack.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react'; import type { Rect } from '@coinbase/cds-common'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartScaleFunction, Series } from '../utils'; import { EPSILON, getBars, getStackBaseline, getStackOrigin } from '../utils/bar'; diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile/src/visualizations/chart/bar/BarStackGroup.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx rename to packages/mobile/src/visualizations/chart/bar/BarStackGroup.tsx index 5579c6e1fe..d3107c626b 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile/src/visualizations/chart/bar/BarStackGroup.tsx @@ -118,7 +118,6 @@ export const BarStackGroup = memo( return orderedConfigs.map(({ categoryIndex, indexPos, thickness }) => ( ( valueScale={valueScaleComputed} xAxisId={xAxisId} yAxisId={yAxisId} + {...props} /> )); }, diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile/src/visualizations/chart/bar/DefaultBar.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/bar/DefaultBar.tsx rename to packages/mobile/src/visualizations/chart/bar/DefaultBar.tsx index 33e8da8047..04a1041be6 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile/src/visualizations/chart/bar/DefaultBar.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Path } from '../Path'; import { defaultBarEnterTransition, getBarPath, withStaggerDelayTransition } from '../utils'; diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx b/packages/mobile/src/visualizations/chart/bar/DefaultBarStack.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx rename to packages/mobile/src/visualizations/chart/bar/DefaultBarStack.tsx diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/mobile/src/visualizations/chart/bar/__stories__/BarChart.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx rename to packages/mobile/src/visualizations/chart/bar/__stories__/BarChart.stories.tsx index ad1fe16c83..ab1088dd64 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile/src/visualizations/chart/bar/__stories__/BarChart.stories.tsx @@ -2,13 +2,13 @@ import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; import { useDerivedValue } from 'react-native-reanimated'; import { assets } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; -import { Button, IconButton } from '@coinbase/cds-mobile/buttons'; -import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; import { Line as SkiaLine, Rect } from '@shopify/react-native-skia'; +import { Button, IconButton } from '../../../../buttons'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../hooks/useTheme'; +import { HStack, VStack } from '../../../../layout'; +import { Text } from '../../../../typography'; import { XAxis, YAxis } from '../../axis'; import { CartesianChart, type CartesianChartProps } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; @@ -1319,7 +1319,7 @@ function ExampleNavigator() { variant="secondary" /> - {currentExample.component} + {currentExample.component} ); diff --git a/packages/mobile-visualization/src/chart/bar/index.ts b/packages/mobile/src/visualizations/chart/bar/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/bar/index.ts rename to packages/mobile/src/visualizations/chart/bar/index.ts diff --git a/packages/mobile-visualization/src/chart/gradient/Gradient.tsx b/packages/mobile/src/visualizations/chart/gradient/Gradient.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/gradient/Gradient.tsx rename to packages/mobile/src/visualizations/chart/gradient/Gradient.tsx diff --git a/packages/mobile-visualization/src/chart/gradient/index.ts b/packages/mobile/src/visualizations/chart/gradient/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/gradient/index.ts rename to packages/mobile/src/visualizations/chart/gradient/index.ts diff --git a/packages/mobile/src/visualizations/chart/index.ts b/packages/mobile/src/visualizations/chart/index.ts new file mode 100644 index 0000000000..40620f86c0 --- /dev/null +++ b/packages/mobile/src/visualizations/chart/index.ts @@ -0,0 +1,17 @@ +// codegen:start {preset: barrel, include: [./*.tsx, ./*/index.ts]} +export * from './area'; +export * from './axis'; +export * from './bar'; +export * from './CartesianChart'; +export * from './ChartContextBridge'; +export * from './ChartProvider'; +export * from './gradient'; +export * from './legend'; +export * from './line'; +export * from './Path'; +export * from './PeriodSelector'; +export * from './point'; +export * from './scrubber'; +export * from './text'; +export * from './utils'; +// codegen:end diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx b/packages/mobile/src/visualizations/chart/legend/DefaultLegendEntry.tsx similarity index 88% rename from packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx rename to packages/mobile/src/visualizations/chart/legend/DefaultLegendEntry.tsx index e20665a6f7..81d4d99584 100644 --- a/packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx +++ b/packages/mobile/src/visualizations/chart/legend/DefaultLegendEntry.tsx @@ -1,7 +1,8 @@ import { memo } from 'react'; import { StyleSheet } from 'react-native'; -import { HStack, type HStackProps } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography/Text'; + +import { HStack, type HStackProps } from '../../../layout'; +import { Text } from '../../../typography/Text'; import { DefaultLegendShape } from './DefaultLegendShape'; import type { LegendEntryProps } from './Legend'; diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/mobile/src/visualizations/chart/legend/DefaultLegendShape.tsx similarity index 92% rename from packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx rename to packages/mobile/src/visualizations/chart/legend/DefaultLegendShape.tsx index e8913e4dbd..c43ba6f1af 100644 --- a/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx +++ b/packages/mobile/src/visualizations/chart/legend/DefaultLegendShape.tsx @@ -1,8 +1,8 @@ import { memo } from 'react'; import { StyleSheet, View, type ViewStyle } from 'react-native'; -import { useTheme } from '@coinbase/cds-mobile'; -import { Box, type BoxProps } from '@coinbase/cds-mobile/layout'; +import { useTheme } from '../../../index'; +import { Box, type BoxProps } from '../../../layout'; import type { LegendShape, LegendShapeVariant } from '../utils/chart'; import type { LegendShapeProps } from './Legend'; diff --git a/packages/mobile-visualization/src/chart/legend/Legend.tsx b/packages/mobile/src/visualizations/chart/legend/Legend.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/legend/Legend.tsx rename to packages/mobile/src/visualizations/chart/legend/Legend.tsx index 72373612fd..c1e50d945d 100644 --- a/packages/mobile-visualization/src/chart/legend/Legend.tsx +++ b/packages/mobile/src/visualizations/chart/legend/Legend.tsx @@ -1,7 +1,7 @@ import { forwardRef, memo, useMemo } from 'react'; import type { StyleProp, View, ViewStyle } from 'react-native'; -import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-mobile/layout'; +import { Box, type BoxBaseProps, type BoxProps } from '../../../layout'; import { useCartesianChartContext } from '../ChartProvider'; import type { LegendShape } from '../utils'; diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile/src/visualizations/chart/legend/__stories__/Legend.stories.tsx similarity index 92% rename from packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx rename to packages/mobile/src/visualizations/chart/legend/__stories__/Legend.stories.tsx index 5890ad80b5..f9ec31ea71 100644 --- a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx +++ b/packages/mobile/src/visualizations/chart/legend/__stories__/Legend.stories.tsx @@ -1,13 +1,11 @@ import { memo, useCallback, useMemo, useState } from 'react'; -import { ScrollView } from 'react-native'; -import { Chip } from '@coinbase/cds-mobile/chips'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { TextLabel1, TextLabel2 } from '@coinbase/cds-mobile/typography'; -import { Text } from '@coinbase/cds-mobile/typography/Text'; import { Canvas, Group, Path as SkiaPath, Skia } from '@shopify/react-native-skia'; +import { Chip } from '../../../../chips'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../hooks/useTheme'; +import { Box, HStack, VStack } from '../../../../layout'; +import { Text } from '../../../../typography/Text'; import { XAxis, YAxis } from '../../axis'; import type { BarComponentProps } from '../../bar'; import { BarChart, BarPlot, DefaultBar } from '../../bar'; @@ -274,8 +272,8 @@ const DynamicData = () => { return ( - {label} - {formattedValue} + {label} + {formattedValue} ); }, @@ -377,7 +375,9 @@ const Interactive = () => { > - {label} + + {label} + ); @@ -594,34 +594,32 @@ const LegendShapes = () => { const LegendStories = () => { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/mobile-visualization/src/chart/legend/index.ts b/packages/mobile/src/visualizations/chart/legend/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/legend/index.ts rename to packages/mobile/src/visualizations/chart/legend/index.ts diff --git a/packages/mobile-visualization/src/chart/line/DefaultReferenceLineLabel.tsx b/packages/mobile/src/visualizations/chart/line/DefaultReferenceLineLabel.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/line/DefaultReferenceLineLabel.tsx rename to packages/mobile/src/visualizations/chart/line/DefaultReferenceLineLabel.tsx index 3e99964e07..79f195ad00 100644 --- a/packages/mobile-visualization/src/chart/line/DefaultReferenceLineLabel.tsx +++ b/packages/mobile/src/visualizations/chart/line/DefaultReferenceLineLabel.tsx @@ -1,6 +1,6 @@ import { memo, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile'; +import { useTheme } from '../../../index'; import { useCartesianChartContext } from '../ChartProvider'; import { ChartText } from '../text'; import { type ChartInset, getChartInset } from '../utils'; diff --git a/packages/mobile-visualization/src/chart/line/DottedLine.tsx b/packages/mobile/src/visualizations/chart/line/DottedLine.tsx similarity index 96% rename from packages/mobile-visualization/src/chart/line/DottedLine.tsx rename to packages/mobile/src/visualizations/chart/line/DottedLine.tsx index 09ac58f998..5f8e684189 100644 --- a/packages/mobile-visualization/src/chart/line/DottedLine.tsx +++ b/packages/mobile/src/visualizations/chart/line/DottedLine.tsx @@ -1,7 +1,7 @@ import { memo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { DashPathEffect } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/line/Line.tsx b/packages/mobile/src/visualizations/chart/line/Line.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/line/Line.tsx rename to packages/mobile/src/visualizations/chart/line/Line.tsx index 1c9060bc89..8ce7cc5021 100644 --- a/packages/mobile-visualization/src/chart/line/Line.tsx +++ b/packages/mobile/src/visualizations/chart/line/Line.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile'; import { type AnimatedProp, Group } from '@shopify/react-native-skia'; +import { useTheme } from '../../../index'; import { Area, type AreaComponent } from '../area/Area'; import { useCartesianChartContext } from '../ChartProvider'; import type { PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile/src/visualizations/chart/line/LineChart.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/line/LineChart.tsx rename to packages/mobile/src/visualizations/chart/line/LineChart.tsx index bf578f7e48..eab8704648 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile/src/visualizations/chart/line/LineChart.tsx @@ -198,7 +198,6 @@ export const LineChart = memo( return ( {/* Render axes first for grid lines to appear behind everything else */} {showXAxis && } diff --git a/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/line/ReferenceLine.tsx rename to packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx index 5d1bff7622..f4ae6eb4f4 100644 --- a/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx +++ b/packages/mobile/src/visualizations/chart/line/ReferenceLine.tsx @@ -1,8 +1,8 @@ import { memo } from 'react'; import { useDerivedValue } from 'react-native-reanimated'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import type { AnimatedProp } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartTextChildren, diff --git a/packages/mobile-visualization/src/chart/line/SolidLine.tsx b/packages/mobile/src/visualizations/chart/line/SolidLine.tsx similarity index 95% rename from packages/mobile-visualization/src/chart/line/SolidLine.tsx rename to packages/mobile/src/visualizations/chart/line/SolidLine.tsx index 42cf5b42f2..9ba0b23552 100644 --- a/packages/mobile-visualization/src/chart/line/SolidLine.tsx +++ b/packages/mobile/src/visualizations/chart/line/SolidLine.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; +import { useTheme } from '../../../hooks/useTheme'; import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx rename to packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx index a2a66f4a0a..55b13f3daa 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile/src/visualizations/chart/line/__stories__/LineChart.stories.tsx @@ -14,19 +14,6 @@ import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualiz import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; -import { useTheme } from '@coinbase/cds-mobile'; -import { DataCard } from '@coinbase/cds-mobile/alpha/data-card/DataCard'; -import { Button, IconButton } from '@coinbase/cds-mobile/buttons'; -import { ListCell } from '@coinbase/cds-mobile/cells'; -import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Box, type BoxBaseProps, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { Avatar, RemoteImage } from '@coinbase/cds-mobile/media'; -import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation'; -import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader'; -import { Pressable } from '@coinbase/cds-mobile/system'; -import { type TabComponent, type TabsActiveIndicatorProps } from '@coinbase/cds-mobile/tabs'; -import { SegmentedTab, type SegmentedTabProps } from '@coinbase/cds-mobile/tabs/SegmentedTab'; -import { Text } from '@coinbase/cds-mobile/typography'; import { Circle, FontWeight, @@ -36,6 +23,19 @@ import { TextAlign, } from '@shopify/react-native-skia'; +import { DataCard } from '../../../../alpha/data-card/DataCard'; +import { Button, IconButton } from '../../../../buttons'; +import { ListCell } from '../../../../cells'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../index'; +import { Box, type BoxBaseProps, HStack, VStack } from '../../../../layout'; +import { Avatar, RemoteImage } from '../../../../media'; +import { NavigationTitleSelect } from '../../../../navigation'; +import { SectionHeader } from '../../../../section-header/SectionHeader'; +import { Pressable } from '../../../../system'; +import { type TabComponent, type TabsActiveIndicatorProps } from '../../../../tabs'; +import { SegmentedTab, type SegmentedTabProps } from '../../../../tabs/SegmentedTab'; +import { Text } from '../../../../typography'; import { Area, DottedArea, type DottedAreaProps } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; import { CartesianChart } from '../../CartesianChart'; @@ -1863,7 +1863,7 @@ function DataCardWithLineChart() { thumbnail={exampleThumbnail} title="Line Chart with Tag" titleAccessory={ - + ↗ 25.25% } @@ -1886,7 +1886,7 @@ function DataCardWithLineChart() { thumbnail={exampleThumbnail} title="Actionable Line Chart" titleAccessory={ - + ↗ 8.5% } @@ -1916,7 +1916,7 @@ function DataCardWithLineChart() { } title="Card with Line Chart" titleAccessory={ - + ↗ 25.25% } @@ -2246,7 +2246,7 @@ function ExampleNavigator() { variant="secondary" /> - {currentExample.component} + {currentExample.component} ); diff --git a/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx b/packages/mobile/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx rename to packages/mobile/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx index 94203d3197..c70f86470c 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx +++ b/packages/mobile/src/visualizations/chart/line/__stories__/ReferenceLine.stories.tsx @@ -1,10 +1,10 @@ import { memo, useCallback, useMemo } from 'react'; import { useDerivedValue, withTiming } from 'react-native-reanimated'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; -import { useTheme } from '@coinbase/cds-mobile'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { VStack } from '@coinbase/cds-mobile/layout'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../index'; +import { VStack } from '../../../../layout'; import { useCartesianChartContext } from '../../ChartProvider'; import { Scrubber } from '../../scrubber'; import { getPointOnSerializableScale, useScrubberContext } from '../../utils'; diff --git a/packages/mobile-visualization/src/chart/line/index.ts b/packages/mobile/src/visualizations/chart/line/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/line/index.ts rename to packages/mobile/src/visualizations/chart/line/index.ts diff --git a/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx b/packages/mobile/src/visualizations/chart/point/DefaultPointLabel.tsx similarity index 100% rename from packages/web-visualization/src/chart/point/DefaultPointLabel.tsx rename to packages/mobile/src/visualizations/chart/point/DefaultPointLabel.tsx index e978f7524b..c93c3e4488 100644 --- a/packages/web-visualization/src/chart/point/DefaultPointLabel.tsx +++ b/packages/mobile/src/visualizations/chart/point/DefaultPointLabel.tsx @@ -26,11 +26,11 @@ export const DefaultPointLabel = memo( return ( {children} diff --git a/packages/mobile-visualization/src/chart/point/Point.tsx b/packages/mobile/src/visualizations/chart/point/Point.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/point/Point.tsx rename to packages/mobile/src/visualizations/chart/point/Point.tsx index 55ea47e4e8..22fb894281 100644 --- a/packages/mobile-visualization/src/chart/point/Point.tsx +++ b/packages/mobile/src/visualizations/chart/point/Point.tsx @@ -1,9 +1,9 @@ import { type ComponentType, memo, useEffect, useMemo } from 'react'; import { cancelAnimation, useDerivedValue, useSharedValue } from 'react-native-reanimated'; import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { Circle, type Color, Group, interpolateColors } from '@shopify/react-native-skia'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartTextChildren, ChartTextProps } from '../text/ChartText'; import { type PointLabelPosition, projectPoint } from '../utils'; diff --git a/packages/mobile-visualization/src/chart/point/index.ts b/packages/mobile/src/visualizations/chart/point/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/point/index.ts rename to packages/mobile/src/visualizations/chart/point/index.ts diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeacon.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx rename to packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeacon.tsx index 0f05bc9cd8..a020cb1544 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeacon.tsx @@ -10,9 +10,9 @@ import { withSequence, withTiming, } from 'react-native-reanimated'; -import { useTheme } from '@coinbase/cds-mobile'; import { Circle, Group } from '@shopify/react-native-skia'; +import { useTheme } from '../../../index'; import { useCartesianChartContext } from '../ChartProvider'; import { unwrapAnimatedValue } from '../utils'; import { projectPointWithSerializableScale } from '../utils/point'; diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx similarity index 96% rename from packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx rename to packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 6f3234bea1..51b4b636c9 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -1,6 +1,6 @@ import { memo } from 'react'; -import { useTheme } from '@coinbase/cds-mobile'; +import { useTheme } from '../../../index'; import { ChartText, type ChartTextProps } from '../text'; import type { ScrubberBeaconLabelProps } from './Scrubber'; diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx b/packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberLabel.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx rename to packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberLabel.tsx diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx rename to packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx index 21e1e121e0..80fbcb9147 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/Scrubber.tsx @@ -12,9 +12,9 @@ import { useDerivedValue, useSharedValue, } from 'react-native-reanimated'; -import { useTheme } from '@coinbase/cds-mobile'; import { type AnimatedProp, Group, Rect, type SkParagraph } from '@shopify/react-native-skia'; +import { useTheme } from '../../../index'; import { useCartesianChartContext } from '../ChartProvider'; import { ReferenceLine, diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberAccessibilityView.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx rename to packages/mobile/src/visualizations/chart/scrubber/ScrubberAccessibilityView.tsx index ed9c73f5a9..059d00fa37 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberAccessibilityView.tsx @@ -1,8 +1,8 @@ import React, { memo, useCallback, useMemo } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import type { Rect } from '@coinbase/cds-common/types'; -import { useScreenReaderStatus } from '@coinbase/cds-mobile/hooks/useScreenReaderStatus'; +import { useScreenReaderStatus } from '../../../hooks/useScreenReaderStatus'; import { useCartesianChartContext } from '../ChartProvider'; import { useScrubberContext } from '../utils'; import type { AxisConfig } from '../utils/axis'; diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx rename to packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx index 239408eb86..781ef90335 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconGroup.tsx @@ -2,8 +2,8 @@ import { forwardRef, memo, useCallback, useImperativeHandle, useMemo } from 'rea import type { SharedValue } from 'react-native-reanimated'; import { useDerivedValue } from 'react-native-reanimated'; import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap'; -import { useTheme } from '@coinbase/cds-mobile'; +import { useTheme } from '../../../index'; import { useCartesianChartContext } from '../ChartProvider'; import { evaluateGradientAtValue, getGradientStops, useScrubberContext } from '../utils'; import { convertToSerializableScale } from '../utils/scale'; diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconLabelGroup.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx rename to packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconLabelGroup.tsx diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx b/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx rename to packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx index 0989ae598d..be0be769f3 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberProvider.tsx @@ -2,8 +2,8 @@ import React, { useCallback, useMemo } from 'react'; import { Platform } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; -import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; +import { Haptics } from '../../../utils/haptics'; import { useCartesianChartContext } from '../ChartProvider'; import { invertSerializableScale, ScrubberContext, type ScrubberContextValue } from '../utils'; import { getPointOnSerializableScale } from '../utils/point'; diff --git a/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx b/packages/mobile/src/visualizations/chart/scrubber/__stories__/Scrubber.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx rename to packages/mobile/src/visualizations/chart/scrubber/__stories__/Scrubber.stories.tsx index 4f3a63f782..55f001f3d4 100644 --- a/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/__stories__/Scrubber.stories.tsx @@ -1,13 +1,13 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { useDerivedValue } from 'react-native-reanimated'; import { assets } from '@coinbase/cds-common/internal/data/assets'; -import { useTheme } from '@coinbase/cds-mobile'; -import { Button, IconButton } from '@coinbase/cds-mobile/buttons'; -import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; import { FontWeight, Skia, type SkTextStyle, TextAlign } from '@shopify/react-native-skia'; +import { Button, IconButton } from '../../../../buttons'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { useTheme } from '../../../../index'; +import { Box, HStack, VStack } from '../../../../layout'; +import { Text } from '../../../../typography'; import { useCartesianChartContext } from '../../ChartProvider'; import { LineChart, SolidLine } from '../../line'; import { @@ -1040,7 +1040,7 @@ const ExampleNavigator = () => { variant="secondary" /> - {currentExample.component} + {currentExample.component} ); diff --git a/packages/mobile-visualization/src/chart/scrubber/index.ts b/packages/mobile/src/visualizations/chart/scrubber/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/scrubber/index.ts rename to packages/mobile/src/visualizations/chart/scrubber/index.ts diff --git a/packages/mobile-visualization/src/chart/text/ChartText.tsx b/packages/mobile/src/visualizations/chart/text/ChartText.tsx similarity index 97% rename from packages/mobile-visualization/src/chart/text/ChartText.tsx rename to packages/mobile/src/visualizations/chart/text/ChartText.tsx index 4d556750ca..bd55c87b2c 100644 --- a/packages/mobile-visualization/src/chart/text/ChartText.tsx +++ b/packages/mobile/src/visualizations/chart/text/ChartText.tsx @@ -2,8 +2,6 @@ import { memo, useMemo } from 'react'; import { runOnJS, useAnimatedReaction, useDerivedValue } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { Rect } from '@coinbase/cds-common/types'; -import type { Theme } from '@coinbase/cds-mobile/core/theme'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { type AnimatedProp, type Color, @@ -21,6 +19,8 @@ import { type Transforms3d, } from '@shopify/react-native-skia'; +import type { Theme } from '../../../core/theme'; +import { useTheme } from '../../../hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { type ChartInset, getChartInset, getColorWithOpacity, unwrapAnimatedValue } from '../utils'; @@ -465,16 +465,16 @@ export const ChartText = memo( switch (paragraphAlignment) { case TextAlign.Center: // For center-aligned text, account for half the width - minOffset = Math.min(...rects.map((rect) => rect.x - rect.width / 2)); + minOffset = Math.min(...rects.map((rect) => rect.left - rect.width / 2)); break; case TextAlign.Right: case TextAlign.End: // For right-aligned text, account for the full width - minOffset = Math.min(...rects.map((rect) => rect.x - rect.width)); + minOffset = Math.min(...rects.map((rect) => rect.left - rect.width)); break; default: // For left-aligned text, use the x position directly - minOffset = Math.min(...rects.map((rect) => rect.x)); + minOffset = Math.min(...rects.map((rect) => rect.left)); break; } diff --git a/packages/mobile-visualization/src/chart/text/ChartTextGroup.tsx b/packages/mobile/src/visualizations/chart/text/ChartTextGroup.tsx similarity index 100% rename from packages/mobile-visualization/src/chart/text/ChartTextGroup.tsx rename to packages/mobile/src/visualizations/chart/text/ChartTextGroup.tsx diff --git a/packages/mobile-visualization/src/chart/text/index.ts b/packages/mobile/src/visualizations/chart/text/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/text/index.ts rename to packages/mobile/src/visualizations/chart/text/index.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/axis.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/axis.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/bar.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/bar.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/chart.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/chart.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/gradient.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/gradient.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/path.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/point.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/point.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/scale.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/scale.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/scale.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/scale.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/scrubber.test.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/scrubber.test.ts diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/mobile/src/visualizations/chart/utils/__tests__/transition.test.ts similarity index 97% rename from packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts rename to packages/mobile/src/visualizations/chart/utils/__tests__/transition.test.ts index 378a128c60..61a2e1bb51 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/mobile/src/visualizations/chart/utils/__tests__/transition.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { buildTransition, @@ -228,7 +228,7 @@ describe('useInterpolator', () => { // Update value value.value = 0.5; - rerender(); + rerender(undefined); expect(result.current).toBeDefined(); }); @@ -279,7 +279,7 @@ describe('usePathTransition', () => { }); it('should handle path updates', () => { - const { result, rerender } = renderHook( + const { result, rerender } = renderHook, { path: string }>( ({ path }) => usePathTransition({ currentPath: path, @@ -321,7 +321,7 @@ describe('usePathTransition', () => { const nextPath = 'M0,0L30,30'; const { result, rerender } = renderHook( - ({ path }) => + ({ path }: { path: string }) => usePathTransition({ currentPath: path, transitions: { update: null }, @@ -364,7 +364,7 @@ describe('usePathTransition', () => { const path1 = 'M0,0L10,10'; const path2 = 'M0,0L20,20'; - const { result, rerender } = renderHook( + const { result, rerender } = renderHook, { path: string }>( ({ path }) => usePathTransition({ currentPath: path, diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile/src/visualizations/chart/utils/axis.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/axis.ts rename to packages/mobile/src/visualizations/chart/utils/axis.ts diff --git a/packages/mobile-visualization/src/chart/utils/bar.ts b/packages/mobile/src/visualizations/chart/utils/bar.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/bar.ts rename to packages/mobile/src/visualizations/chart/utils/bar.ts diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile/src/visualizations/chart/utils/chart.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/chart.ts rename to packages/mobile/src/visualizations/chart/utils/chart.ts diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile/src/visualizations/chart/utils/context.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/context.ts rename to packages/mobile/src/visualizations/chart/utils/context.ts diff --git a/packages/mobile-visualization/src/chart/utils/gradient.ts b/packages/mobile/src/visualizations/chart/utils/gradient.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/gradient.ts rename to packages/mobile/src/visualizations/chart/utils/gradient.ts diff --git a/packages/mobile-visualization/src/chart/utils/index.ts b/packages/mobile/src/visualizations/chart/utils/index.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/index.ts rename to packages/mobile/src/visualizations/chart/utils/index.ts diff --git a/packages/mobile-visualization/src/chart/utils/path.ts b/packages/mobile/src/visualizations/chart/utils/path.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/path.ts rename to packages/mobile/src/visualizations/chart/utils/path.ts diff --git a/packages/mobile-visualization/src/chart/utils/point.ts b/packages/mobile/src/visualizations/chart/utils/point.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/point.ts rename to packages/mobile/src/visualizations/chart/utils/point.ts diff --git a/packages/mobile-visualization/src/chart/utils/scale.ts b/packages/mobile/src/visualizations/chart/utils/scale.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/scale.ts rename to packages/mobile/src/visualizations/chart/utils/scale.ts diff --git a/packages/mobile-visualization/src/chart/utils/scrubber.ts b/packages/mobile/src/visualizations/chart/utils/scrubber.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/scrubber.ts rename to packages/mobile/src/visualizations/chart/utils/scrubber.ts diff --git a/packages/mobile-visualization/src/chart/utils/transition.ts b/packages/mobile/src/visualizations/chart/utils/transition.ts similarity index 100% rename from packages/mobile-visualization/src/chart/utils/transition.ts rename to packages/mobile/src/visualizations/chart/utils/transition.ts diff --git a/packages/mobile/src/visualizations/index.ts b/packages/mobile/src/visualizations/index.ts index a581697264..0a59aaaaf8 100644 --- a/packages/mobile/src/visualizations/index.ts +++ b/packages/mobile/src/visualizations/index.ts @@ -1,6 +1,8 @@ +export * from './chart'; export * from './DefaultProgressCircleContent'; export * from './getProgressBarLabelParts'; export * from './ProgressBar'; export * from './ProgressBarWithFixedLabels'; export * from './ProgressBarWithFloatLabel'; export * from './ProgressCircle'; +export * from './sparkline'; diff --git a/packages/mobile-visualization/src/sparkline/Counter.tsx b/packages/mobile/src/visualizations/sparkline/Counter.tsx similarity index 86% rename from packages/mobile-visualization/src/sparkline/Counter.tsx rename to packages/mobile/src/visualizations/sparkline/Counter.tsx index 28186a2939..9094879f76 100644 --- a/packages/mobile-visualization/src/sparkline/Counter.tsx +++ b/packages/mobile/src/visualizations/sparkline/Counter.tsx @@ -1,8 +1,9 @@ import React, { useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { useCounter } from '@coinbase/cds-common/visualizations/useCounter'; -import { Box } from '@coinbase/cds-mobile/layout'; -import type { CounterBaseProps } from '@coinbase/cds-mobile/visualizations/Counter'; + +import { Box } from '../../layout'; +import type { CounterBaseProps } from '../Counter'; const styles = StyleSheet.create({ hidden: { diff --git a/packages/mobile-visualization/src/sparkline/Sparkline.tsx b/packages/mobile/src/visualizations/sparkline/Sparkline.tsx similarity index 99% rename from packages/mobile-visualization/src/sparkline/Sparkline.tsx rename to packages/mobile/src/visualizations/sparkline/Sparkline.tsx index 66a3dec026..acf0beb732 100644 --- a/packages/mobile-visualization/src/sparkline/Sparkline.tsx +++ b/packages/mobile/src/visualizations/sparkline/Sparkline.tsx @@ -6,9 +6,10 @@ import type { ElementChildren, SharedProps } from '@coinbase/cds-common/types'; import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; import { getSparklineRange } from '@coinbase/cds-common/visualizations/getSparklineRange'; import { getSparklineTransform } from '@coinbase/cds-common/visualizations/getSparklineTransform'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { generateRandomId } from '@coinbase/cds-utils'; +import { useTheme } from '../../hooks/useTheme'; + import { generateSparklineAreaWithId } from './generateSparklineWithId'; import type { SparklineAreaBaseProps } from './SparklineArea'; import { SparklineAreaPattern } from './SparklineAreaPattern'; diff --git a/packages/mobile-visualization/src/sparkline/SparklineArea.tsx b/packages/mobile/src/visualizations/sparkline/SparklineArea.tsx similarity index 100% rename from packages/mobile-visualization/src/sparkline/SparklineArea.tsx rename to packages/mobile/src/visualizations/sparkline/SparklineArea.tsx diff --git a/packages/mobile-visualization/src/sparkline/SparklineAreaPattern.tsx b/packages/mobile/src/visualizations/sparkline/SparklineAreaPattern.tsx similarity index 93% rename from packages/mobile-visualization/src/sparkline/SparklineAreaPattern.tsx rename to packages/mobile/src/visualizations/sparkline/SparklineAreaPattern.tsx index 13973bb508..30e9e61b40 100644 --- a/packages/mobile-visualization/src/sparkline/SparklineAreaPattern.tsx +++ b/packages/mobile/src/visualizations/sparkline/SparklineAreaPattern.tsx @@ -1,7 +1,8 @@ import React from 'react'; import { Circle, G, Pattern, Rect } from 'react-native-svg'; import { useSparklineAreaOpacity } from '@coinbase/cds-common/visualizations/useSparklineAreaOpacity'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../hooks/useTheme'; export type SparklineAreaPatternBaseProps = { color: string; diff --git a/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx b/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx similarity index 97% rename from packages/mobile-visualization/src/sparkline/SparklineGradient.tsx rename to packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx index 1c07139634..6016d2fbd7 100644 --- a/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx +++ b/packages/mobile/src/visualizations/sparkline/SparklineGradient.tsx @@ -4,9 +4,10 @@ import { getAccessibleForegroundGradient } from '@coinbase/cds-common/color/getA import { borderWidth } from '@coinbase/cds-common/tokens/sparkline'; import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; import { getSparklineTransform } from '@coinbase/cds-common/visualizations/getSparklineTransform'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { generateRandomId } from '@coinbase/cds-utils'; +import { useTheme } from '../../hooks/useTheme'; + import { generateSparklineAreaWithId } from './generateSparklineWithId'; import type { SparklineBaseProps } from './Sparkline'; import { SparklineAreaPattern } from './SparklineAreaPattern'; diff --git a/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx b/packages/mobile/src/visualizations/sparkline/__figma__/Sparkline.figma.tsx similarity index 89% rename from packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx rename to packages/mobile/src/visualizations/sparkline/__figma__/Sparkline.figma.tsx index f5b0c4e639..2e3887046d 100644 --- a/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx +++ b/packages/mobile/src/visualizations/sparkline/__figma__/Sparkline.figma.tsx @@ -9,7 +9,7 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=320%3A15040', { imports: [ - "import { Sparkline } from '@coinbase/cds-mobile-visualization'", + "import { Sparkline } from '@coinbase/cds-mobile/visualizations/sparkline'", "import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'", ], example: function Example() { diff --git a/packages/mobile-visualization/src/sparkline/__stories__/Sparkline.stories.tsx b/packages/mobile/src/visualizations/sparkline/__stories__/Sparkline.stories.tsx similarity index 88% rename from packages/mobile-visualization/src/sparkline/__stories__/Sparkline.stories.tsx rename to packages/mobile/src/visualizations/sparkline/__stories__/Sparkline.stories.tsx index 27c7dfe3ae..e50ffc1d09 100644 --- a/packages/mobile-visualization/src/sparkline/__stories__/Sparkline.stories.tsx +++ b/packages/mobile/src/visualizations/sparkline/__stories__/Sparkline.stories.tsx @@ -5,14 +5,14 @@ import { prices, pricesWithScalingFactor } from '@coinbase/cds-common/internal/d import { gutter } from '@coinbase/cds-common/tokens/sizing'; import { useSparklineArea } from '@coinbase/cds-common/visualizations/useSparklineArea'; import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'; -import { Cell } from '@coinbase/cds-mobile/cells/Cell'; -import { CellMedia } from '@coinbase/cds-mobile/cells/CellMedia'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { HStack } from '@coinbase/cds-mobile/layout/HStack'; -import { VStack } from '@coinbase/cds-mobile/layout/VStack'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { Cell } from '../../../cells/Cell'; +import { CellMedia } from '../../../cells/CellMedia'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { useTheme } from '../../../hooks/useTheme'; +import { HStack } from '../../../layout/HStack'; +import { VStack } from '../../../layout/VStack'; +import { Text } from '../../../typography'; import { Sparkline } from '../Sparkline'; import { SparklineArea } from '../SparklineArea'; diff --git a/packages/mobile-visualization/src/sparkline/__stories__/SparklineGradient.stories.tsx b/packages/mobile/src/visualizations/sparkline/__stories__/SparklineGradient.stories.tsx similarity index 91% rename from packages/mobile-visualization/src/sparkline/__stories__/SparklineGradient.stories.tsx rename to packages/mobile/src/visualizations/sparkline/__stories__/SparklineGradient.stories.tsx index c66614b4da..05bf74f8e9 100644 --- a/packages/mobile-visualization/src/sparkline/__stories__/SparklineGradient.stories.tsx +++ b/packages/mobile/src/visualizations/sparkline/__stories__/SparklineGradient.stories.tsx @@ -5,13 +5,13 @@ import { prices, pricesWithScalingFactor } from '@coinbase/cds-common/internal/d import { gutter } from '@coinbase/cds-common/tokens/sizing'; import { useSparklineArea } from '@coinbase/cds-common/visualizations/useSparklineArea'; import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'; -import { Cell } from '@coinbase/cds-mobile/cells/Cell'; -import { CellMedia } from '@coinbase/cds-mobile/cells/CellMedia'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { VStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { Cell } from '../../../cells/Cell'; +import { CellMedia } from '../../../cells/CellMedia'; +import { Example, ExampleScreen } from '../../../examples/ExampleScreen'; +import { useTheme } from '../../../hooks/useTheme'; +import { VStack } from '../../../layout'; +import { Text } from '../../../typography'; import { SparklineArea } from '../SparklineArea'; import { SparklineGradient } from '../SparklineGradient'; diff --git a/packages/mobile-visualization/src/sparkline/generateSparklineWithId.ts b/packages/mobile/src/visualizations/sparkline/generateSparklineWithId.ts similarity index 100% rename from packages/mobile-visualization/src/sparkline/generateSparklineWithId.ts rename to packages/mobile/src/visualizations/sparkline/generateSparklineWithId.ts diff --git a/packages/mobile/src/visualizations/sparkline/index.ts b/packages/mobile/src/visualizations/sparkline/index.ts new file mode 100644 index 0000000000..5eab72d409 --- /dev/null +++ b/packages/mobile/src/visualizations/sparkline/index.ts @@ -0,0 +1,5 @@ +export * from './Sparkline'; +export * from './sparkline-interactive/SparklineInteractive'; +export * from './sparkline-interactive-header/SparklineInteractiveHeader'; +export * from './SparklineArea'; +export * from './SparklineGradient'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx similarity index 99% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx index a7a67c73a2..e5b945fd18 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx @@ -7,7 +7,8 @@ import type { SparklineInteractiveHeaderSignVariant, SparklineInteractiveHeaderVariant, } from '@coinbase/cds-common/types'; -import { HStack, VStack } from '@coinbase/cds-mobile/layout'; + +import { HStack, VStack } from '../../../layout'; import { useSparklineInteractiveHeaderStyles } from './useSparklineInteractiveHeaderStyles'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx index 5ab1de8746..f408b9b8fe 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx @@ -9,8 +9,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=320-14931&m=dev', { imports: [ - "import { SparklineInteractiveHeader } from '@coinbase/cds-mobile-visualization'", - "import { SparklineInteractive } from '@coinbase/cds-mobile-visualization'", + "import { SparklineInteractiveHeader } from '@coinbase/cds-mobile/visualizations/sparkline'", + "import { SparklineInteractive } from '@coinbase/cds-mobile/visualizations/sparkline'", ], props: { compact: figma.boolean('compact'), diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx index de9a8c7ef9..40f0b40732 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx @@ -6,12 +6,12 @@ import { type SparklinePeriod, } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; import type { ChartData, ChartDataPoint, ChartScrubParams } from '@coinbase/cds-common/types'; -import { IconButton } from '@coinbase/cds-mobile/buttons'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Icon } from '@coinbase/cds-mobile/icons'; -import { Box, HStack } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { IconButton } from '../../../../buttons'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { Icon } from '../../../../icons'; +import { Box, HStack } from '../../../../layout'; +import { Text } from '../../../../typography'; import { SparklineInteractive, type SparklineInteractiveBaseProps, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx similarity index 96% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx index c289df293e..3cf1b9990c 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/SparklineInteractiveHeader.test.tsx @@ -1,6 +1,6 @@ -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { render, screen } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { SparklineInteractiveHeader } from '../SparklineInteractiveHeader'; const defaultSubHead = { diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts similarity index 90% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts index 8d28faf187..a7380578bf 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/__tests__/useSparklineInteractiveHeaderStyles.test.ts @@ -1,8 +1,8 @@ import type { StyleProp, TextStyle } from 'react-native'; -import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; +import { defaultTheme } from '../../../../themes/defaultTheme'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { useSparklineInteractiveHeaderStyles } from '../useSparklineInteractiveHeaderStyles'; const getStyleValue = (styles: StyleProp, styleProp: keyof TextStyle) => diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts similarity index 90% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts index b1f88e1b7f..60b3096677 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive-header/useSparklineInteractiveHeaderStyles.ts @@ -2,8 +2,9 @@ import { useCallback, useMemo } from 'react'; import { StyleSheet } from 'react-native'; import type { StyleProp, TextStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { getAdjustedFontScale } from '@coinbase/cds-mobile/utils/getAdjustedFontScale'; + +import { useTheme } from '../../../hooks/useTheme'; +import { getAdjustedFontScale } from '../../../utils/getAdjustedFontScale'; import type { SparklineInteractiveSubHead } from './SparklineInteractiveHeader'; @@ -128,16 +129,15 @@ export function useSparklineInteractiveHeaderStyles() { subHead: ( color: SparklineInteractiveSubHeadIconColor, useFullWidth = true, - ): StyleProp => - [ - typography.label1, - styles.tabularNumbers, - ...(useFullWidth ? [styles.fullWidth] : [{ width: 'auto' }]), - styles.inputReset, - { - color: theme.color[variantColorMap[color]], - }, - ] as TextStyle, + ): StyleProp => [ + typography.label1, + styles.tabularNumbers, + ...(useFullWidth ? [styles.fullWidth] : [{ width: 'auto' as const }]), + styles.inputReset, + { + color: theme.color[variantColorMap[color]], + }, + ], subHeadAccessory: (): StyleProp => [ typography.label2, styles.inputReset, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineAccessibleView.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineAccessibleView.tsx similarity index 100% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineAccessibleView.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineAccessibleView.tsx diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractive.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractive.tsx index 9d5ca57d31..5b9f938442 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractive.tsx @@ -15,14 +15,15 @@ import { minMax } from '@coinbase/cds-common/utils/chart'; import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; import { useSparklineCoordinates } from '@coinbase/cds-common/visualizations/useSparklineCoordinates'; import { chartFallbackNegative, chartFallbackPositive } from '@coinbase/cds-lottie-files'; -import { Lottie } from '@coinbase/cds-mobile/animation'; -import { useScreenReaderStatus } from '@coinbase/cds-mobile/hooks/useScreenReaderStatus'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box } from '@coinbase/cds-mobile/layout'; import { emptyArray, noop } from '@coinbase/cds-utils'; import isEqual from 'lodash/isEqual'; import isObject from 'lodash/isObject'; +import { Lottie } from '../../../animation'; +import { useScreenReaderStatus } from '../../../hooks/useScreenReaderStatus'; +import { useTheme } from '../../../hooks/useTheme'; +import { Box } from '../../../layout'; + import { SparklineAccessibleView } from './SparklineAccessibleView'; import { SparklineInteractiveHoverDate } from './SparklineInteractiveHoverDate'; import { SparklineInteractiveLineVertical } from './SparklineInteractiveLineVertical'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveAnimatedPath.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveAnimatedPath.tsx similarity index 100% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveAnimatedPath.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveAnimatedPath.tsx diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx index 89f727222a..027dc6c6da 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveHoverDate.tsx @@ -1,7 +1,8 @@ import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; import { Animated, StyleSheet, TextInput } from 'react-native'; import type { ChartScrubParams } from '@coinbase/cds-common/types/Chart'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../../hooks/useTheme'; import type { SparklineInteractiveBaseProps } from './SparklineInteractive'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx index f4813b761d..417faa6bf8 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveLineVertical.tsx @@ -3,7 +3,8 @@ import { Animated as RNAnimated, StyleSheet } from 'react-native'; import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { Line, Svg } from 'react-native-svg'; import { maskOpacity } from '@coinbase/cds-common/tokens/sparkline'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../../hooks/useTheme'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; import { useSparklineInteractiveConstants } from './useSparklineInteractiveConstants'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx similarity index 92% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx index 0b4720f21c..76867e783e 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMarkerDates.tsx @@ -3,11 +3,12 @@ import { Animated, StyleSheet } from 'react-native'; import type { ViewStyle } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { useDateLookup } from '@coinbase/cds-common/visualizations/useDateLookup'; -import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { TextLabel2 } from '@coinbase/cds-mobile/typography'; import times from 'lodash/times'; +import { useLayout } from '../../../hooks/useLayout'; +import { useTheme } from '../../../hooks/useTheme'; +import { Text } from '../../../typography'; + import type { ChartFormatDate, ChartGetMarker } from './SparklineInteractive'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; @@ -39,15 +40,16 @@ const SparklineInteractiveMarkerDate: React.FunctionComponent< }, [label.width, label.x]); return ( - {getFormattedDate(x)} - + ); }); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx similarity index 91% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx index 4f11b15def..e62aa9e279 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveMinMax.tsx @@ -2,9 +2,10 @@ import React, { memo, useCallback, useMemo, useRef } from 'react'; import { Animated, StyleSheet } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; import type { ChartDataPoint, ChartFormatAmount, ChartXFunction } from '@coinbase/cds-common/types'; -import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { TextLabel2 } from '@coinbase/cds-mobile/typography'; + +import { useLayout } from '../../../hooks/useLayout'; +import { useTheme } from '../../../hooks/useTheme'; +import { Text } from '../../../typography'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; import { useMinMaxTransform } from './useMinMaxTransform'; @@ -68,9 +69,9 @@ const SparklineInteractiveMinMaxContent: React.FunctionComponent< return ( - + {children} - + ); }); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx index f49a5c41f9..2273ffd54c 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePanGestureHandler.tsx @@ -3,9 +3,10 @@ import { Animated as RNAnimated, Platform, View } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS } from 'react-native-reanimated'; import type { ChartGetMarker, ChartScrubParams } from '@coinbase/cds-common/types/Chart'; -import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; import { noop } from '@coinbase/cds-utils'; +import { Haptics } from '../../../utils/haptics'; + import { type SparklineInteractiveProps } from './SparklineInteractive'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; import { useSparklineInteractiveConstants } from './useSparklineInteractiveConstants'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePaths.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePaths.tsx similarity index 100% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePaths.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePaths.tsx diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx similarity index 87% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx index d0f4f2e9e6..2bbc1b2fc0 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractivePeriodSelector.tsx @@ -3,14 +3,15 @@ import { ScrollView, StyleSheet } from 'react-native'; import type { LayoutChangeEvent } from 'react-native'; import { periodLabelMap } from '@coinbase/cds-common/tokens/sparkline'; import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; -import { useHorizontallyScrollingPressables } from '@coinbase/cds-mobile/hooks/useHorizontallyScrollingPressables'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box } from '@coinbase/cds-mobile/layout/Box'; -import { HStack } from '@coinbase/cds-mobile/layout/HStack'; -import { OverflowGradient } from '@coinbase/cds-mobile/layout/OverflowGradient'; -import { Pressable } from '@coinbase/cds-mobile/system/Pressable'; -import { TextLabel1 } from '@coinbase/cds-mobile/typography'; -import { Haptics } from '@coinbase/cds-mobile/utils/haptics'; + +import { useHorizontallyScrollingPressables } from '../../../hooks/useHorizontallyScrollingPressables'; +import { useTheme } from '../../../hooks/useTheme'; +import { Box } from '../../../layout/Box'; +import { HStack } from '../../../layout/HStack'; +import { OverflowGradient } from '../../../layout/OverflowGradient'; +import { Pressable } from '../../../system/Pressable'; +import { Text } from '../../../typography'; +import { Haptics } from '../../../utils/haptics'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; @@ -84,9 +85,9 @@ function SparklineInteractivePeriodWithGeneric({ borderRadius={1000} onPress={handleOnPress} > - + {period.label} - + ); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx similarity index 99% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx index 86d4e6236a..eed5afb63b 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveProvider.tsx @@ -46,7 +46,7 @@ type SparklineInteractiveContextInterface = { const SparklineInteractiveContext = createContext({ isFallbackVisible: true, markerXPosition: makeMutable(0), - markerGestureState: makeMutable(0), + markerGestureState: makeMutable<0 | 1>(0), showFallback: noop, hideFallback: noop, chartOpacity: new Animated.Value(0), diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx index 19cb09dd65..56334e01f9 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/SparklineInteractiveTimeseriesPaths.tsx @@ -5,10 +5,11 @@ import type { ChartDataPoint, ChartTimeseries } from '@coinbase/cds-common/types import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; import { getSparklineTransform } from '@coinbase/cds-common/visualizations/getSparklineTransform'; import { useTimeseriesPaths } from '@coinbase/cds-common/visualizations/useTimeseriesPaths'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import * as interpolate from 'd3-interpolate-path'; import type { Area, Line } from 'd3-shape'; +import { useTheme } from '../../../hooks/useTheme'; + import { useInterruptiblePathAnimation } from './useInterruptiblePathAnimation'; export type TimeseriesPathOnRenderParams = { diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx similarity index 95% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx index f1100bce79..2d069f433b 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx @@ -7,7 +7,9 @@ figma.connect( SparklineInteractive, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=320-14858&m=dev', { - imports: ["import { SparklineInteractive } from '@coinbase/cds-mobile-visualization'"], + imports: [ + "import { SparklineInteractive } from '@coinbase/cds-mobile/visualizations/sparkline'", + ], props: { compact: figma.boolean('compact'), disableScrubbing: figma.boolean('scrubbing', { diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx similarity index 98% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx index f5bf3c99df..2947398027 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx @@ -7,10 +7,10 @@ import { strokeColor, } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; import type { ChartData, ChartDataPoint, ChartScrubParams } from '@coinbase/cds-common/types'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; -import { Box } from '@coinbase/cds-mobile/layout'; -import { Text } from '@coinbase/cds-mobile/typography'; +import { Example, ExampleScreen } from '../../../../examples/ExampleScreen'; +import { Box } from '../../../../layout'; +import { Text } from '../../../../typography'; import { SparklineInteractiveHeader, type SparklineInteractiveHeaderRef, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx similarity index 97% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx index 71c132e8df..a62268a88c 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractive.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { sparklineInteractiveData } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { render, screen } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { SparklineInteractiveHeader } from '../..'; import { SparklineInteractive } from '../SparklineInteractive'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx similarity index 94% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx index b1b837278b..79a91ca2b0 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveHoverDate.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Animated } from 'react-native'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { render } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { setTransform, SparklineInteractiveHoverDate, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx similarity index 92% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx index f69dd4b778..b2e946eeac 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePanGestureHandler.test.tsx @@ -1,7 +1,7 @@ import { Text } from 'react-native'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { render, screen } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { SparklineInteractivePanGestureHandler } from '../SparklineInteractivePanGestureHandler'; describe('SparklineInteractivePanGestureHandler.test', () => { diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx similarity index 94% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx index 8a457b1405..da4ddaf406 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractivePeriodSelector.test.tsx @@ -1,6 +1,6 @@ -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { SparklineInteractivePeriodSelector } from '../SparklineInteractivePeriodSelector'; const periods = [ diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx similarity index 91% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx index f1ef895b69..e684ba7e9e 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/SparklineInteractiveTimeseriesPaths.test.tsx @@ -1,7 +1,7 @@ import type { ChartTimeseries } from '@coinbase/cds-common'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; import { render } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { SparklineInteractiveTimeseriesPaths } from '../SparklineInteractiveTimeseriesPaths'; describe('SparklineInteractiveTimeseriesPaths.test', () => { diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts similarity index 86% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts index 5048b438f1..0a628218e7 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/__tests__/useMinMaxTransform.test.ts @@ -1,7 +1,7 @@ import { Animated } from 'react-native'; -import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; +import { DefaultThemeProvider } from '../../../../utils/testHelpers'; import { useMinMaxTransform } from '../useMinMaxTransform'; jest.useFakeTimers(); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts similarity index 94% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts index 273266cce7..9989cb6ea2 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useInterruptiblePathAnimation.test.disable.ts @@ -1,5 +1,5 @@ import { Animated } from 'react-native'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react-native'; import { useInterruptiblePathAnimation } from './useInterruptiblePathAnimation'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useInterruptiblePathAnimation.ts similarity index 100% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useInterruptiblePathAnimation.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useInterruptiblePathAnimation.ts diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useMinMaxTransform.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useMinMaxTransform.ts similarity index 97% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useMinMaxTransform.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useMinMaxTransform.ts index 6ddc2c5d7a..042c7eb389 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useMinMaxTransform.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useMinMaxTransform.ts @@ -2,7 +2,8 @@ import { useEffect } from 'react'; import { Animated } from 'react-native'; import type { LayoutRectangle } from 'react-native'; import { durations } from '@coinbase/cds-common/motion/tokens'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../../hooks/useTheme'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; import { useSparklineInteractiveConstants } from './useSparklineInteractiveConstants'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useOpacityAnimation.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useOpacityAnimation.ts similarity index 100% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useOpacityAnimation.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useOpacityAnimation.ts diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts similarity index 96% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts index 913fa95f8b..7a3f4efbf6 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveConstants.ts @@ -5,7 +5,8 @@ import { chartCompactHeight, chartHeight as chartHeightToken, } from '@coinbase/cds-common/tokens/sparkline'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../../hooks/useTheme'; import { useSparklineInteractiveContext } from './SparklineInteractiveProvider'; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts similarity index 93% rename from packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts rename to packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts index a20066a0c2..a29d317042 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts +++ b/packages/mobile/src/visualizations/sparkline/sparkline-interactive/useSparklineInteractiveLineStyles.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { borderWidth, lineDashArray, lineOpacity } from '@coinbase/cds-common/tokens/sparkline'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; + +import { useTheme } from '../../../hooks/useTheme'; const staticLineProps = { x1: 0, diff --git a/packages/ui-mobile-playground/CHANGELOG.md b/packages/ui-mobile-playground/CHANGELOG.md deleted file mode 100644 index a72ffa7744..0000000000 --- a/packages/ui-mobile-playground/CHANGELOG.md +++ /dev/null @@ -1,161 +0,0 @@ -# @coinbase/ui-mobile-playground - -> [NPM registry](https://www.npmjs.com/package/@coinbase/ui-mobile-playground) - -All notable changes to this project will be documented in this file. - -`@coinbase/ui-mobile-playground` adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - - -## 4.16.0 (3/30/2026 PST) - -#### 🚀 Updates - -- Add mobile component config route. [[#507](https://github.com/coinbase/cds/pull/507)] - -## 4.15.2 (3/27/2026 PST) - -#### 🐞 Fixes - -- Fit lint errors. [[#528](https://github.com/coinbase/cds/pull/528)] - -## 4.15.1 (3/26/2026 PST) - -#### 🐞 Fixes - -- Remove detox dependency. [[#517](https://github.com/coinbase/cds/pull/517)] - -## 4.15.0 (3/23/2026 PST) - -#### 🚀 Updates - -- Add custom modal padding route. [[#534](https://github.com/coinbase/cds/pull/534)] - -## 4.14.0 (3/18/2026 PST) - -#### 🚀 Updates - -- Add a route for new Calendar component. [[#139](https://github.com/coinbase/cds/pull/139)] - -## 4.13.0 (3/17/2026 PST) - -#### 🚀 Updates - -- Update svg map for new illustrations. [[#511](https://github.com/coinbase/cds/pull/511)] - -## 4.12.0 (3/11/2026 PST) - -#### 🚀 Updates - -- Update mobile routes. [[#492](https://github.com/coinbase/cds/pull/492)] - -## 4.11.0 (3/10/2026 PST) - -#### 🚀 Updates - -- Add new route for Fallback component. [[#388](https://github.com/coinbase/cds/pull/388)] - -## 4.10.0 (3/9/2026 PST) - -#### 🚀 Updates - -- Update icons. [[#486](https://github.com/coinbase/cds/pull/486)] - -## 4.9.0 (2/20/2026 PST) - -#### 🚀 Updates - -- Add new mobile routes. [[#400](https://github.com/coinbase/cds/pull/400)] - -## 4.8.0 (2/6/2026 PST) - -#### 🚀 Updates - -- Add new tray design. [[#349](https://github.com/coinbase/cds/pull/349)] - -## 4.7.0 (2/4/2026 PST) - -#### 🚀 Updates - -- Added routes for MediaCard, MessagingCard, and alpha DataCard. [[#329](https://github.com/coinbase/cds/pull/329)] - -## 4.6.0 (2/4/2026 PST) - -#### 🚀 Updates - -- Add new screen. [[#366](https://github.com/coinbase/cds/pull/366)] - -## 4.5.12 (1/13/2026 PST) - -#### 🐞 Fixes - -- Regenerate routes. [[#302](https://github.com/coinbase/cds/pull/302)] - -## 4.5.11 (12/22/2025 PST) - -#### 🐞 Fixes - -- Chore: update icon svg map. [[#275](https://github.com/coinbase/cds/pull/275)] - -## 4.5.10 (12/11/2025 PST) - -#### 🐞 Fixes - -- Update svg map. [[#232](https://github.com/coinbase/cds/pull/232)] - -## 4.5.9 (12/2/2025 PST) - -#### 🐞 Fixes - -- Regenerate routes. [[#200](https://github.com/coinbase/cds/pull/200)] - -## 4.5.8 (12/1/2025 PST) - -#### 🐞 Fixes - -- Chore: QOL improvements for searching/navigating example component routes. [[#199](https://github.com/coinbase/cds/pull/199)] - -## 4.5.7 (11/19/2025 PST) - -#### 🐞 Fixes - -- Update icon map. [[#177](https://github.com/coinbase/cds/pull/177)] - -#### 📘 Misc - -- Update icon svg map. [[#138](https://github.com/coinbase/cds/pull/138)] - -## 4.5.6 (11/4/2025 PST) - -This is an artificial version bump with no new change. - -## 4.5.5 (10/27/2025 PST) - -This is an artificial version bump with no new change. - -## 4.5.4 (10/6/2025 PST) - -This is an artificial version bump with no new change. - -## 4.5.3 (10/3/2025 PST) - -#### 🐞 Fixes - -- Update icon map. - -## 4.5.2 (10/2/2025 PST) - -#### 🐞 Fixes - -- Chore: add clear error message when icon is missing from icon<>svg map file. - -## 4.5.1 (10/1/2025 PST) - -#### 🐞 Fixes - -- Update links for new repo. [[#42](https://github.com/coinbase/cds/pull/42)] - -## 4.5.0 (9/18/2025 PST) - -- Prepare for open source release. diff --git a/packages/ui-mobile-playground/README.md b/packages/ui-mobile-playground/README.md deleted file mode 100644 index 4eac3d2435..0000000000 --- a/packages/ui-mobile-playground/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# @coinbase/ui-mobile-playground - -This package contains the Mobile Playground UI that renders our storybook mobile components. - -## Releasing UI Mobile Playground - -1. Commit your changes & open a PR - -2. Bump the package version and update the changelog - -```shell -yarn bump-version ui-mobile-playground -``` - -- When prompted, do the following: - - Type of change?: Select what makes the most sense - - Changelog message?: Short and sweet description :) - - PR number?: Copy/paste your PR number - - Skip the rest (press enter to use defaults) - -3. Commit and push the changes to your existing PR. Get reviews & merge. - - - -5. After the deploy has succeeded, verify that the new package was published at the [production Coinbase NPM registry](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master). It usually takes about 10 min or so for the package to be uploaded. Look for the version number at the bottom of the artifact list in the [package directory](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master/@coinbase/ui-mobile-playground/-/@coinbase/ui-mobile-playground-1.0.0-rc.1.tgz). diff --git a/packages/ui-mobile-playground/deploy.yml b/packages/ui-mobile-playground/deploy.yml deleted file mode 100644 index 3107158924..0000000000 --- a/packages/ui-mobile-playground/deploy.yml +++ /dev/null @@ -1,3 +0,0 @@ -engine: Node -build_name: package-ui-mobile-playground -continuous: true diff --git a/packages/ui-mobile-playground/docker-compose.yml b/packages/ui-mobile-playground/docker-compose.yml deleted file mode 100644 index b12d94768b..0000000000 --- a/packages/ui-mobile-playground/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: '3' -services: - app: - build: - context: ../../ - dockerfile: packages/ui-mobile-playground/publish.Dockerfile diff --git a/packages/ui-mobile-playground/package.json b/packages/ui-mobile-playground/package.json deleted file mode 100644 index 82f513277b..0000000000 --- a/packages/ui-mobile-playground/package.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "name": "@coinbase/ui-mobile-playground", - "version": "4.16.0", - "description": "Mobile UI Components in a Playground", - "repository": { - "type": "git", - "url": "git@github.com:coinbase/cds.git", - "directory": "packages/ui-mobile-playground" - }, - "routes": "./esm/routes.js", - "type": "module", - "main": "./esm/index.js", - "types": "./dts/index.d.ts", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dts/index.d.ts", - "default": "./esm/index.js" - }, - "./components": { - "types": "./dts/components/index.d.ts", - "default": "./esm/components/index.js" - }, - "./*": { - "types": "./dts/*.d.ts", - "default": "./esm/*.js" - } - }, - "sideEffects": false, - "files": [ - "dts", - "esm", - "src", - "CHANGELOG" - ], - "peerDependencies": { - "@coinbase/cds-common": "workspace:^", - "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", - "@react-navigation/elements": "^1.3.17", - "@react-navigation/native": "^6.1.6", - "@react-navigation/stack": "^6.3.16", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-safe-area-context": "^4.10.5" - }, - "dependencies": { - "lodash": "^4.17.21" - }, - "devDependencies": { - "@babel/core": "^7.28.0", - "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", - "@react-navigation/elements": "^1.3.17", - "@react-navigation/native": "^6.1.6", - "@react-navigation/stack": "^6.3.16", - "@types/react": "^18.3.12", - "react-native-safe-area-context": "4.10.5" - } -} diff --git a/packages/ui-mobile-playground/project.json b/packages/ui-mobile-playground/project.json deleted file mode 100644 index 122079c5ed..0000000000 --- a/packages/ui-mobile-playground/project.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "ui-mobile-playground", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "packages/ui-mobile-playground/src", - "projectType": "library", - "targets": { - "generate-icon-svg-map": { - "executor": "nx:run-commands", - "options": { - "commands": [ - "tsx packages/ui-mobile-playground/scripts/generateIconSvgMap.ts" - ] - } - }, - "build": { - "executor": "nx:run-commands", - "defaultConfiguration": "dev", - "dependsOn": [ - "generate-icon-svg-map" - ], - "configurations": { - "dev": { - "command": "rm -rf esm && babel ./src --out-dir esm --extensions .ts,.tsx,.js,.jsx --copy-files --no-copy-ignored" - }, - "prod": { - "commands": [ - "rm -rf esm && babel ./src --out-dir esm --extensions .ts,.tsx,.js,.jsx --copy-files --no-copy-ignored" - ], - "parallel": false - } - } - }, - "lint": { - "executor": "@nx/eslint:lint" - }, - "typecheck": { - "executor": "nx:run-commands", - "defaultConfiguration": "dev", - "configurations": { - "dev": { - "command": "tsc --build --pretty --verbose" - }, - "prod": { - "command": "tsc --build ./tsconfig.build.json --pretty --verbose" - } - } - } - } -} diff --git a/packages/ui-mobile-playground/publish.Dockerfile b/packages/ui-mobile-playground/publish.Dockerfile deleted file mode 100644 index 4389fd0b20..0000000000 --- a/packages/ui-mobile-playground/publish.Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM 652969937640.dkr.ecr.us-east-1.amazonaws.com/containers/node:v22-ub22 - -RUN apt-get update && apt-get install - -WORKDIR /repo - -COPY . . - -# Install dependencies -RUN yarn --immutable - -# Build the package with nx -RUN yarn nx run ui-mobile-playground:typecheck:prod -RUN yarn nx run ui-mobile-playground:build:prod - -# Prepare the package for publish -RUN cd /repo/packages/ui-mobile-playground && yarn pack -RUN mv /repo/packages/ui-mobile-playground /shared - -WORKDIR /shared diff --git a/packages/ui-mobile-playground/scripts/generateIconSvgMap.ts b/packages/ui-mobile-playground/scripts/generateIconSvgMap.ts deleted file mode 100644 index eb4f3e3d87..0000000000 --- a/packages/ui-mobile-playground/scripts/generateIconSvgMap.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; - -const svgOutputPath = path.join(process.cwd(), 'packages', 'icons', 'src', 'svgs'); -const nativeMapOutputPath = path.join( - process.cwd(), - 'packages', - 'ui-mobile-playground', - 'src', - '__generated__', - 'iconSvgMap.ts', -); - -/** - * Generates a React Native SVG map file that reads each SVG file content - * and exports a map keyed by `${name}-${size}-${state}` with content strings. - */ -export const generateSvgMap = (): void => { - console.log('Generating React Native SVG map...'); - - // Read all SVG files from the output directory - const svgFiles: string[] = fs - .readdirSync(svgOutputPath) - .filter((file: string) => file.endsWith('.svg')) - .sort((a: string, b: string) => a.localeCompare(b)); - - if (svgFiles.length === 0) { - console.log('No SVG files found, skipping SVG map generation'); - return; - } - - const mapEntries: string[] = []; - - for (const file of svgFiles) { - const base: string = file.replace(/\.svg$/, ''); - const svgFilePath: string = path.join(svgOutputPath, file); - - // Read the SVG file content as string - const svgContent: string = fs.readFileSync(svgFilePath, 'utf8'); - - // Escape quotes and newlines for JavaScript string literal - const escapedContent: string = svgContent - .replace(/\\/g, '\\\\') - .replace(/"/g, '\\"') - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r'); - - mapEntries.push(` '${base}': { content: "${escapedContent}" },`); - } - - const header: string = `/** - * DO NOT MODIFY - * This file is generated by ui-mobile-playground/scripts/generateIconSvgMap.ts - * - * Why this exists: - * - Provides a static map of icon names to their SVG content for rendering Icons directly with react-native-svg components - * - * What this provides: - * - A static map of iconName-12|16|24|32-active|inactive → { content: "svg-string" } - * - * Usage: - * - Access SVG string content via: svgMap['icon-name-12-active'].content - */`; - - const content: string = `${header} - -export const svgMap: Record = { -${mapEntries.join('\n')} -} as const; - -export type SvgMapEntry = { content: string }; -export type SvgMap = Record; -export type SvgKey = keyof typeof svgMap; - -export default svgMap; -`; - - // Ensure the output directory exists - const outputDir: string = path.dirname(nativeMapOutputPath); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Write the file - fs.writeFileSync(nativeMapOutputPath, content, 'utf8'); - - console.log( - `Generated React Native SVG map with ${svgFiles.length} entries -> ${path.relative( - process.cwd(), - nativeMapOutputPath, - )}`, - ); -}; - -generateSvgMap(); diff --git a/packages/ui-mobile-playground/src/components/Playground.tsx b/packages/ui-mobile-playground/src/components/Playground.tsx deleted file mode 100644 index ed7709f263..0000000000 --- a/packages/ui-mobile-playground/src/components/Playground.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React, { memo, useMemo } from 'react'; -import type { ColorScheme } from '@coinbase/cds-common/core/theme'; -import type { StackNavigationOptions } from '@react-navigation/stack'; -import { createStackNavigator, HeaderStyleInterpolators } from '@react-navigation/stack'; - -import { ExamplesListScreen } from './ExamplesListScreen'; -import { ExamplesSearchProvider } from './ExamplesSearchProvider'; -import { IconSheetScreen } from './IconSheetScreen'; -import type { PlaygroundRoute } from './PlaygroundRoute'; -import { createStaticRoute, initialRouteKey, searchRouteKey } from './staticRoutes'; -import { transformRouteToNavComponent } from './transformRouteToNavComponent'; -import { useExampleNavigatorProps } from './useExampleNavigatorProps'; - -const Stack = createStackNavigator(); - -type PlaygroundProps = { - routes?: PlaygroundRoute[]; - listScreenTitle?: string; - setColorScheme?: React.Dispatch>; -}; - -const PlaygroundContent = memo( - ({ routes = [], listScreenTitle, setColorScheme }: PlaygroundProps) => { - const navigatorProps = useExampleNavigatorProps({ setColorScheme }); - - const routeKeys = useMemo(() => { - return routes.map(({ key }) => key); - }, [routes]); - - const { key: listScreenKey, ...listScreenProps } = useMemo(() => { - let options: StackNavigationOptions = {}; - - if (listScreenTitle) { - options = { ...options, title: listScreenTitle }; - } - - return { - ...transformRouteToNavComponent({ - route: createStaticRoute(initialRouteKey, ExamplesListScreen), - options, - }), - initialParams: { routeKeys }, - }; - }, [listScreenTitle, routeKeys]); - - const { key: searchScreenKey, ...searchScreenProps } = useMemo(() => { - return { - ...transformRouteToNavComponent({ - route: createStaticRoute(searchRouteKey, ExamplesListScreen), - }), - initialParams: { routeKeys }, - }; - }, [routeKeys]); - - const exampleScreens = useMemo( - () => - [...routes].map((route) => { - const { key, ...routeProps } = transformRouteToNavComponent({ - route, - }); - return ; - }), - [routes], - ); - - return ( - - - - - {exampleScreens} - - ); - }, -); - -export const Playground = memo((props: PlaygroundProps) => { - return ( - - - - ); -}); diff --git a/packages/ui-mobile-playground/src/components/PlaygroundRoute.ts b/packages/ui-mobile-playground/src/components/PlaygroundRoute.ts deleted file mode 100644 index c755cd8b1d..0000000000 --- a/packages/ui-mobile-playground/src/components/PlaygroundRoute.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type PlaygroundRoute = { - key: string; - getComponent: () => React.ComponentType>; -}; diff --git a/packages/ui-mobile-playground/src/components/index.ts b/packages/ui-mobile-playground/src/components/index.ts deleted file mode 100644 index 469667aff6..0000000000 --- a/packages/ui-mobile-playground/src/components/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Playground'; diff --git a/packages/ui-mobile-playground/src/components/staticRoutes.ts b/packages/ui-mobile-playground/src/components/staticRoutes.ts deleted file mode 100644 index 65757231c5..0000000000 --- a/packages/ui-mobile-playground/src/components/staticRoutes.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { keyToRouteName } from './keyToRouteName'; - -export const initialRouteKey = 'Examples'; -export const searchRouteKey = 'Search'; - -export const initialRouteName = keyToRouteName(initialRouteKey); -export const searchRouteName = keyToRouteName(searchRouteKey); - -export const createStaticRoute = (key: string, component: () => JSX.Element) => ({ - key, - getComponent: () => component, -}); diff --git a/packages/ui-mobile-playground/src/components/transformRouteToNavComponent.ts b/packages/ui-mobile-playground/src/components/transformRouteToNavComponent.ts deleted file mode 100644 index 4ad45254c2..0000000000 --- a/packages/ui-mobile-playground/src/components/transformRouteToNavComponent.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { StackNavigationOptions } from '@react-navigation/stack'; -import { HeaderStyleInterpolators } from '@react-navigation/stack'; - -import { keyToRouteName } from './keyToRouteName'; -import type { PlaygroundRoute } from './PlaygroundRoute'; - -const titleOverrides: Record = { - Examples: 'CDS', - Text: 'Text (all)', -}; - -type TransformRouteToNavComponentParams = { - route: PlaygroundRoute; - options?: StackNavigationOptions | undefined; -}; - -export function transformRouteToNavComponent({ - route: { key, getComponent }, - options = {}, -}: TransformRouteToNavComponentParams) { - return { - key, - name: keyToRouteName(key), - getComponent, - options: { - title: titleOverrides[key] ?? key, - headerStyleInterpolator: HeaderStyleInterpolators.forFade, - ...options, - }, - } as const; -} diff --git a/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx b/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx deleted file mode 100644 index 3c4fb60f83..0000000000 --- a/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import React, { useContext, useMemo } from 'react'; -import type { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import type { ColorScheme } from '@coinbase/cds-common/core/theme'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; -import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; -import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; -import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; -import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { Box } from '@coinbase/cds-mobile/layout/Box'; -import { HStack } from '@coinbase/cds-mobile/layout/HStack'; -import { Spacer } from '@coinbase/cds-mobile/layout/Spacer'; -import { TextHeadline } from '@coinbase/cds-mobile/typography/TextHeadline'; -import type { StackHeaderProps, StackNavigationOptions } from '@react-navigation/stack'; - -import { SearchFilterContext, SetSearchFilterContext } from './ExamplesSearchProvider'; -import { initialRouteName, searchRouteName } from './staticRoutes'; - -type UseExampleNavigatorPropsOptions = { - setColorScheme?: React.Dispatch>; -}; - -const iconButtonHeight = interactableHeight.regular; - -export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigatorPropsOptions) { - const theme = useTheme(); - const { top } = useSafeAreaInsets(); - const [headerSize, onLayout] = useLayout(); - const searchFilter = useContext(SearchFilterContext); - const setFilter = useContext(SetSearchFilterContext); - - const style = useMemo(() => ({ marginTop: top }), [top]); - - const header = useMemo(() => { - return ({ navigation, route, options }: StackHeaderProps) => { - const isFocused = navigation.isFocused(); - const canGoBack = navigation.canGoBack(); - const goBack = () => { - navigation.goBack(); - }; - const goBackFromSearch = () => { - setFilter(''); - navigation.goBack(); - }; - const goToSearch = () => navigation.navigate(searchRouteName); - const routeName = route.name; - const titleForScene = options.title; - const isSearch = routeName === searchRouteName; - const showBackButton = isFocused && canGoBack && !isSearch; - const showSearch = routeName === initialRouteName; - - const iconButtonPlaceholder = ; - - const leftHeaderButton = showSearch ? ( - - - - ) : showBackButton ? ( - - - - ) : ( - iconButtonPlaceholder - ); - - const handleSearch = (e: NativeSyntheticEvent) => - setFilter(e.nativeEvent.text); - - const toggleDark = () => setColorScheme?.((s) => (s === 'dark' ? 'light' : 'dark')); - - const rightHeaderButton = isSearch ? ( - iconButtonPlaceholder - ) : ( - - - - ); - - return ( - - - {leftHeaderButton} - - - {isSearch ? ( - - } - value={searchFilter} - /> - ) : ( - {titleForScene} - )} - - - {rightHeaderButton} - - - ); - }; - }, [onLayout, searchFilter, setFilter, style, theme.activeColorScheme, setColorScheme]); - - return useMemo(() => { - const screenOptions: StackNavigationOptions = { - headerBackAllowFontScaling: false, - headerBackTitleVisible: false, - headerTitleAllowFontScaling: false, - headerStyle: { - backgroundColor: theme.color.bg, - borderWidth: 0, - shadowColor: 'transparent', - height: headerSize.height, - }, - header, - gestureDirection: 'horizontal', - }; - - return { - initialRouteName, - screenOptions, - }; - }, [header, headerSize.height, theme.color.bg]); -} diff --git a/packages/ui-mobile-playground/src/index.ts b/packages/ui-mobile-playground/src/index.ts deleted file mode 100644 index 07635cbbc8..0000000000 --- a/packages/ui-mobile-playground/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components'; diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts deleted file mode 100644 index 9aaf028390..0000000000 --- a/packages/ui-mobile-playground/src/routes.ts +++ /dev/null @@ -1,904 +0,0 @@ -/** - * DO NOT MODIFY - * Generated from scripts/codegen/main.ts - */ -export const routes = [ - { - key: 'Accordion', - getComponent: () => - require('@coinbase/cds-mobile/accordion/__stories__/Accordion.stories').default, - }, - { - key: 'AlertBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertBasic.stories').default, - }, - { - key: 'AlertLongTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertLongTitle.stories').default, - }, - { - key: 'AlertOverModal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertOverModal.stories').default, - }, - { - key: 'AlertPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertPortal.stories').default, - }, - { - key: 'AlertSingleAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertSingleAction.stories').default, - }, - { - key: 'AlertVerticalActions', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, - }, - { - key: 'AlphaSelect', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, - }, - { - key: 'AlphaSelectChip', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, - }, - { - key: 'AlphaTabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') - .default, - }, - { - key: 'AnimatedCaret', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, - }, - { - key: 'AreaChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') - .default, - }, - { - key: 'Avatar', - getComponent: () => require('@coinbase/cds-mobile/media/__stories__/Avatar.stories').default, - }, - { - key: 'AvatarButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/AvatarButton.stories').default, - }, - { - key: 'Axis', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, - }, - { - key: 'Banner', - getComponent: () => require('@coinbase/cds-mobile/banner/__stories__/Banner.stories').default, - }, - { - key: 'BannerActions', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerActions.stories').default, - }, - { - key: 'BannerLayout', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerLayout.stories').default, - }, - { - key: 'BarChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, - }, - { - key: 'Box', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Box.stories').default, - }, - { - key: 'BrowserBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBar.stories').default, - }, - { - key: 'BrowserBarSearchInput', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBarSearchInput.stories').default, - }, - { - key: 'Button', - getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/Button.stories').default, - }, - { - key: 'ButtonGroup', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, - }, - { - key: 'Calendar', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/Calendar.stories').default, - }, - { - key: 'Card', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, - }, - { - key: 'Carousel', - getComponent: () => - require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, - }, - { - key: 'CarouselMedia', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, - }, - { - key: 'CartesianChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') - .default, - }, - { - key: 'ChartAccessibility', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') - .default, - }, - { - key: 'ChartTransitions', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') - .default, - }, - { - key: 'Checkbox', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/Checkbox.stories').default, - }, - { - key: 'CheckboxCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/CheckboxCell.stories').default, - }, - { - key: 'Chip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/Chip.stories').default, - }, - { - key: 'Coachmark', - getComponent: () => - require('@coinbase/cds-mobile/coachmark/__stories__/Coachmark.stories').default, - }, - { - key: 'Collapsible', - getComponent: () => - require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, - }, - { - key: 'Combobox', - getComponent: () => - require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, - }, - { - key: 'ComponentConfigProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, - }, - { - key: 'ComponentConfigProviderCustom', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') - .default, - }, - { - key: 'ContainedAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContainedAssetCard.stories').default, - }, - { - key: 'ContentCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/ContentCard.stories').default, - }, - { - key: 'ContentCell', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCell.stories').default, - }, - { - key: 'ContentCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ContentCellFallback.stories').default, - }, - { - key: 'ControlGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/ControlGroup.stories').default, - }, - { - key: 'DataCard', - getComponent: () => - require('@coinbase/cds-mobile/alpha/data-card/__stories__/DataCard.stories').default, - }, - { - key: 'DateInput', - getComponent: () => require('@coinbase/cds-mobile/dates/__stories__/DateInput.stories').default, - }, - { - key: 'DatePicker', - getComponent: () => - require('@coinbase/cds-mobile/dates/__stories__/DatePicker.stories').default, - }, - { - key: 'Divider', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Divider.stories').default, - }, - { - key: 'Dot', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/Dot.stories').default, - }, - { - key: 'DotMisc', - getComponent: () => require('@coinbase/cds-mobile/dots/__stories__/DotMisc.stories').default, - }, - { - key: 'DrawerBottom', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerBottom.stories').default, - }, - { - key: 'DrawerFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerFallback.stories').default, - }, - { - key: 'DrawerLeft', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerLeft.stories').default, - }, - { - key: 'DrawerMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerMisc.stories').default, - }, - { - key: 'DrawerReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerReduceMotion.stories').default, - }, - { - key: 'DrawerRight', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerRight.stories').default, - }, - { - key: 'DrawerScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerScrollable.stories').default, - }, - { - key: 'DrawerTop', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/DrawerTop.stories').default, - }, - { - key: 'Fallback', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Fallback.stories').default, - }, - { - key: 'FloatingAssetCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/FloatingAssetCard.stories').default, - }, - { - key: 'Frontier', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Frontier.stories').default, - }, - { - key: 'Group', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Group.stories').default, - }, - { - key: 'HeroSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/HeroSquare.stories').default, - }, - { - key: 'HintMotion', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/HintMotion.stories').default, - }, - { - key: 'IconButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconButton.stories').default, - }, - { - key: 'IconCounterButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/IconCounterButton.stories').default, - }, - { - key: 'InputChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/InputChip.stories').default, - }, - { - key: 'InputIcon', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIcon.stories').default, - }, - { - key: 'InputIconButton', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputIconButton.stories').default, - }, - { - key: 'InputStack', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, - }, - { - key: 'Legend', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, - }, - { - key: 'LinearGradient', - getComponent: () => - require('@coinbase/cds-mobile/gradients/__stories__/LinearGradient.stories').default, - }, - { - key: 'LineChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') - .default, - }, - { - key: 'Link', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Link.stories').default, - }, - { - key: 'ListCell', - getComponent: () => require('@coinbase/cds-mobile/cells/__stories__/ListCell.stories').default, - }, - { - key: 'ListCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ListCellFallback.stories').default, - }, - { - key: 'Logo', - getComponent: () => require('@coinbase/cds-mobile/icons/__stories__/Logo.stories').default, - }, - { - key: 'Lottie', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/Lottie.stories').default, - }, - { - key: 'LottieStatusAnimation', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default, - }, - { - key: 'MediaCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/MediaCard.stories').default, - }, - { - key: 'MediaChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.stories').default, - }, - { - key: 'MessagingCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/MessagingCard.stories').default, - }, - { - key: 'ModalBackButton', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBackButton.stories').default, - }, - { - key: 'ModalBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalBasic.stories').default, - }, - { - key: 'ModalCustomPadding', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalCustomPadding.stories').default, - }, - { - key: 'ModalLong', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalLong.stories').default, - }, - { - key: 'ModalPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalPortal.stories').default, - }, - { - key: 'MultiContentModule', - getComponent: () => - require('@coinbase/cds-mobile/multi-content-module/__stories__/MultiContentModule.stories') - .default, - }, - { - key: 'NavBarIconButton', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavBarIconButton.stories').default, - }, - { - key: 'NavigationSubtitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationSubtitle.stories').default, - }, - { - key: 'NavigationTitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitle.stories').default, - }, - { - key: 'NavigationTitleSelect', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitleSelect.stories').default, - }, - { - key: 'NudgeCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/NudgeCard.stories').default, - }, - { - key: 'Numpad', - getComponent: () => require('@coinbase/cds-mobile/numpad/__stories__/Numpad.stories').default, - }, - { - key: 'Overlay', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/Overlay.stories').default, - }, - { - key: 'PageFooter', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageFooter.stories').default, - }, - { - key: 'PageFooterInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageFooterInPage.stories').default, - }, - { - key: 'PageHeader', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageHeader.stories').default, - }, - { - key: 'PageHeaderInErrorEmptyState', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInErrorEmptyState.stories').default, - }, - { - key: 'PageHeaderInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInPage.stories').default, - }, - { - key: 'Palette', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Palette.stories').default, - }, - { - key: 'PatternDisclosureHighFrictionBenefit', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionBenefit.stories') - .default, - }, - { - key: 'PatternDisclosureHighFrictionRisk', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionRisk.stories') - .default, - }, - { - key: 'PatternDisclosureLowFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureLowFriction.stories') - .default, - }, - { - key: 'PatternDisclosureMedFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureMedFriction.stories') - .default, - }, - { - key: 'PatternError', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, - }, - { - key: 'PeriodSelector', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') - .default, - }, - { - key: 'Pictogram', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, - }, - { - key: 'Pressable', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/Pressable.stories').default, - }, - { - key: 'PressableOpacity', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PressableOpacity.stories').default, - }, - { - key: 'ProgressBar', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressBar.stories').default, - }, - { - key: 'ProgressCircle', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressCircle.stories').default, - }, - { - key: 'RadioCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioCell.stories').default, - }, - { - key: 'RadioGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioGroup.stories').default, - }, - { - key: 'ReferenceLine', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') - .default, - }, - { - key: 'RemoteImage', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImage.stories').default, - }, - { - key: 'RemoteImageGroup', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImageGroup.stories').default, - }, - { - key: 'RollingNumber', - getComponent: () => - require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, - }, - { - key: 'Scrubber', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') - .default, - }, - { - key: 'SearchInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SearchInput.stories').default, - }, - { - key: 'SectionHeader', - getComponent: () => - require('@coinbase/cds-mobile/section-header/__stories__/SectionHeader.stories').default, - }, - { - key: 'SegmentedTabs', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/SegmentedTabs.stories').default, - }, - { - key: 'Select', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Select.stories').default, - }, - { - key: 'SelectChip', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, - }, - { - key: 'SelectOption', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SelectOption.stories').default, - }, - { - key: 'SlideButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/SlideButton.stories').default, - }, - { - key: 'Spacer', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Spacer.stories').default, - }, - { - key: 'Sparkline', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, - }, - { - key: 'SparklineGradient', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') - .default, - }, - { - key: 'SparklineInteractive', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') - .default, - }, - { - key: 'SparklineInteractiveHeader', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') - .default, - }, - { - key: 'Spectrum', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Spectrum.stories').default, - }, - { - key: 'Spinner', - getComponent: () => require('@coinbase/cds-mobile/loaders/__stories__/Spinner.stories').default, - }, - { - key: 'SpotIcon', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotIcon.stories').default, - }, - { - key: 'SpotRectangle', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotRectangle.stories').default, - }, - { - key: 'SpotSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotSquare.stories').default, - }, - { - key: 'StepperHorizontal', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperHorizontal.stories').default, - }, - { - key: 'StepperVertical', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperVertical.stories').default, - }, - { - key: 'StickyFooter', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooter.stories').default, - }, - { - key: 'StickyFooterWithTray', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooterWithTray.stories') - .default, - }, - { - key: 'Switch', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Switch.stories').default, - }, - { - key: 'TabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/TabbedChips.stories').default, - }, - { - key: 'TabIndicator', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabIndicator.stories').default, - }, - { - key: 'TabLabel', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/TabLabel.stories').default, - }, - { - key: 'TabNavigation', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabNavigation.stories').default, - }, - { - key: 'Tabs', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, - }, - { - key: 'Tag', - getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, - }, - { - key: 'Text', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Text.stories').default, - }, - { - key: 'TextBody', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextBody.stories').default, - }, - { - key: 'TextCaption', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCaption.stories').default, - }, - { - key: 'TextCore', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCore.stories').default, - }, - { - key: 'TextDisplay1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay1.stories').default, - }, - { - key: 'TextDisplay2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay2.stories').default, - }, - { - key: 'TextDisplay3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay3.stories').default, - }, - { - key: 'TextHeadline', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextHeadline.stories').default, - }, - { - key: 'TextInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/TextInput.stories').default, - }, - { - key: 'TextLabel1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel1.stories').default, - }, - { - key: 'TextLabel2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel2.stories').default, - }, - { - key: 'TextLegal', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLegal.stories').default, - }, - { - key: 'TextTitle1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle1.stories').default, - }, - { - key: 'TextTitle2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle2.stories').default, - }, - { - key: 'TextTitle3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle3.stories').default, - }, - { - key: 'TextTitle4', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle4.stories').default, - }, - { - key: 'ThemeProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ThemeProvider.stories').default, - }, - { - key: 'Toast', - getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/Toast.stories').default, - }, - { - key: 'TooltipV2', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TooltipV2.stories').default, - }, - { - key: 'TopNavBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/TopNavBar.stories').default, - }, - { - key: 'Tour', - getComponent: () => require('@coinbase/cds-mobile/tour/__stories__/Tour.stories').default, - }, - { - key: 'TrayAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayAction.stories').default, - }, - { - key: 'TrayBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayBasic.stories').default, - }, - { - key: 'TrayFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFallback.stories').default, - }, - { - key: 'TrayFeedCard', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, - }, - { - key: 'TrayInformational', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayInformational.stories').default, - }, - { - key: 'TrayMessaging', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMessaging.stories').default, - }, - { - key: 'TrayMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMisc.stories').default, - }, - { - key: 'TrayNavigation', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayNavigation.stories').default, - }, - { - key: 'TrayPromotional', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayPromotional.stories').default, - }, - { - key: 'TrayRedesign', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayRedesign.stories').default, - }, - { - key: 'TrayReduceMotion', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayReduceMotion.stories').default, - }, - { - key: 'TrayScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayScrollable.stories').default, - }, - { - key: 'TrayTall', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayTall.stories').default, - }, - { - key: 'TrayWithTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayWithTitle.stories').default, - }, - { - key: 'UpsellCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/UpsellCard.stories').default, - }, -]; diff --git a/packages/ui-mobile-playground/tsconfig.json b/packages/ui-mobile-playground/tsconfig.json deleted file mode 100644 index a2b8cbe0f5..0000000000 --- a/packages/ui-mobile-playground/tsconfig.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../tsconfig.project.json", - "compilerOptions": { - "declarationDir": "dts", - "rootDir": "src" - }, - "include": [ - "src/**/*" - ], - "exclude": [], - "references": [ - { - "path": "../../packages/common" - }, - { - "path": "../../packages/mobile" - }, - { - "path": "../../packages/mobile-visualization" - } - ] -} diff --git a/packages/utils/package.json b/packages/utils/package.json index e118e45dea..28da6e5f50 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1" } } diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index 04b4dbfd9e..f9ccd48f91 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -2,6 +2,7 @@ "name": "@coinbase/cds-web-visualization", "version": "3.4.0", "description": "Coinbase Design System - Web Sparkline", + "deprecated": "Use @coinbase/cds-web/visualizations/chart and @coinbase/cds-web/visualizations/sparkline instead.", "repository": { "type": "git", "url": "git@github.com:coinbase/cds.git", @@ -43,8 +44,8 @@ "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^18.0.0 || ~19.1.2", + "react-dom": "^18.0.0 || ~19.1.2" }, "dependencies": { "d3-color": "^3.1.0", @@ -57,15 +58,18 @@ "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-common": "workspace:^", "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", "@linaria/core": "^3.0.0-beta.22", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "framer-motion": "^10.18.0" + "@testing-library/react": "^16.3.2", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", + "framer-motion": "^10.18.0", + "react": "19.1.2", + "react-dom": "19.1.2" } } diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 7fa7b37e08..ac6ce80819 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -1,16 +1 @@ -// codegen:start {preset: barrel, include: [./*.tsx, ./*/index.ts]} -export * from './area/index'; -export * from './axis/index'; -export * from './bar/index'; -export * from './CartesianChart'; -export * from './ChartProvider'; -export * from './gradient/index'; -export * from './legend/index'; -export * from './line/index'; -export * from './Path'; -export * from './PeriodSelector'; -export * from './point/index'; -export * from './scrubber/index'; -export * from './text/index'; -export * from './utils/index'; -// codegen:end +export * from '@coinbase/cds-web/visualizations/chart'; diff --git a/packages/web-visualization/src/index.ts b/packages/web-visualization/src/index.ts index 21bfe928b2..c8fa7f668d 100644 --- a/packages/web-visualization/src/index.ts +++ b/packages/web-visualization/src/index.ts @@ -1,4 +1,2 @@ -// codegen:start {preset: barrel, include: ./*/index.ts} export * from './chart'; export * from './sparkline'; -// codegen:end diff --git a/packages/web-visualization/src/sparkline/index.ts b/packages/web-visualization/src/sparkline/index.ts index 5eab72d409..ba3ff3d326 100644 --- a/packages/web-visualization/src/sparkline/index.ts +++ b/packages/web-visualization/src/sparkline/index.ts @@ -1,5 +1 @@ -export * from './Sparkline'; -export * from './sparkline-interactive/SparklineInteractive'; -export * from './sparkline-interactive-header/SparklineInteractiveHeader'; -export * from './SparklineArea'; -export * from './SparklineGradient'; +export * from '@coinbase/cds-web/visualizations/sparkline'; diff --git a/packages/web/babel.config.cjs b/packages/web/babel.config.cjs index d0d6a3df6e..716b0af62f 100644 --- a/packages/web/babel.config.cjs +++ b/packages/web/babel.config.cjs @@ -25,6 +25,7 @@ module.exports = { }, ], ], + // NOTE: To enable the React Compiler, install babel-plugin-react-compiler and react-compiler-runtime // plugins: [ // [ // 'babel-plugin-react-compiler', diff --git a/packages/web/package.json b/packages/web/package.json index c9dd2da71b..8e8d469894 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -180,6 +180,14 @@ "types": "./dts/typography/index.d.ts", "default": "./esm/typography/index.js" }, + "./visualizations/chart": { + "types": "./dts/visualizations/chart/index.d.ts", + "default": "./esm/visualizations/chart/index.js" + }, + "./visualizations/sparkline": { + "types": "./dts/visualizations/sparkline/index.d.ts", + "default": "./esm/visualizations/sparkline/index.js" + }, "./visualizations": { "types": "./dts/visualizations/index.d.ts", "default": "./esm/visualizations/index.js" @@ -203,8 +211,8 @@ ], "peerDependencies": { "framer-motion": "^10.18.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^18.0.0 || ~19.1.2", + "react-dom": "^18.0.0 || ~19.1.2" }, "dependencies": { "@coinbase/cds-common": "workspace:^", @@ -215,35 +223,40 @@ "@floating-ui/react-dom": "^2.1.1", "@popperjs/core": "^2.9.0", "@react-spring/web": "^9.7.4", + "d3-color": "^3.1.0", "d3-interpolate": "^3.0.1", "d3-interpolate-path": "^2.3.0", "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1", "fuse.js": "^7.1.0", "lodash": "^4.17.21", "lottie-web": "^5.13.0", "react-popper": "^2.2.4", - "react-use-measure": "^2", + "react-use-measure": "^2.1.7", "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", + "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.27.1", "@coinbase/cds-web-utils": "workspace:^", "@linaria/core": "^3.0.0-beta.22", "@linaria/shaker": "^3.0.0-beta.22", "@storybook/jest": "^0.2.3", - "@storybook/react": "^6.5.17-alpha.0", + "@storybook/react": "^9.1.2", "@storybook/testing-library": "^0.2.2", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.0.4", + "@types/react": "19.1.2", + "@types/react-dom": "19.1.2", "csstype": "^3.1.3", "framer-motion": "^10.18.0", "glob": "^10.3.10", + "react": "19.1.2", + "react-dom": "19.1.2", "storybook-addon-performance": "^0.16.1", "typescript": "~5.9.2", - "vite": "^7.1.2", "zx": "^8.1.9" } } diff --git a/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx b/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx index c1f8d51bb7..2f95cba1a3 100644 --- a/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx +++ b/packages/web/src/AccessibilityAnnouncer/__stories__/AccessibilityAnnouncer.stories.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useState } from 'react'; -import type { ComponentStoryObj } from '@storybook/react'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons'; import { VStack } from '../../layout/VStack'; @@ -35,18 +35,18 @@ const MockAppScreen = ({ message, ...rest }: AccessibilityAnnouncerProps) => { ); }; -export default { +const meta = { title: 'Components/AccessibilityAnnouncer', component: MockAppScreen, args: { message: DEFAULT_MESSAGE }, -}; +} satisfies Meta; -export const Default: ComponentStoryObj = { - ...MockAppScreen, -}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; -export const Assertive: ComponentStoryObj = { - ...MockAppScreen, +export const Assertive: Story = { args: { politeness: 'assertive', message: diff --git a/packages/web/src/accordion/AccordionItem.tsx b/packages/web/src/accordion/AccordionItem.tsx index cb949c2ef6..5d84cd7c5a 100644 --- a/packages/web/src/accordion/AccordionItem.tsx +++ b/packages/web/src/accordion/AccordionItem.tsx @@ -9,8 +9,8 @@ import { AccordionPanel, type AccordionPanelBaseProps } from './AccordionPanel'; export type AccordionItemBaseProps = Omit & Pick & { - headerRef?: React.RefObject; - panelRef?: React.RefObject; + headerRef?: React.RefObject; + panelRef?: React.RefObject; style?: React.CSSProperties; }; diff --git a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx index 7df2e595df..03424d610b 100644 --- a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx +++ b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx @@ -99,9 +99,11 @@ export const DefaultComboboxControl = memo( }} placeholder={typeof placeholder === 'string' ? placeholder : undefined} style={{ + // unset default padding to let DefaultSelectControl handle layout/spacing paddingLeft: 0, paddingRight: 0, - height: !hasValue ? (compact ? 40 : 48) : undefined, + paddingTop: 0, + paddingBottom: 0, minWidth: 0, flexGrow: 1, width: '100%', @@ -140,8 +142,6 @@ export const DefaultComboboxControl = memo( }, controlValueNode: { ...props.styles?.controlValueNode, - paddingTop: hasValue ? (compact ? 'var(--space-1)' : 'var(--space-1_5)') : 0, - paddingBottom: hasValue ? (compact ? 'var(--space-1)' : 'var(--space-1_5)') : 0, }, }} tabIndex={shouldShowSearchInput ? -1 : 0} diff --git a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx index 7242dac7ed..4f1d43ba69 100644 --- a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx +++ b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { type JSX, useRef } from 'react'; import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { ProgressBar, @@ -38,7 +38,7 @@ export const BasicExamples = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Bar Card" titleAccessory={ - + ↗ 25.25% } @@ -106,7 +106,7 @@ export const Features = (): JSX.Element => { thumbnail={exampleThumbnail} title="High Progress" titleAccessory={ - + ↗ 25.25% } @@ -181,7 +181,7 @@ export const Interactive = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Bar with Button" titleAccessory={ - + ↗ 8.5% } @@ -207,7 +207,7 @@ export const Interactive = (): JSX.Element => { thumbnail={exampleThumbnail} title="Progress Circle with Link" titleAccessory={ - + ↗ 8.5% } @@ -315,7 +315,7 @@ export const MultipleCards = (): JSX.Element => { thumbnail={exampleThumbnail} title="Card 2" titleAccessory={ - + ↗ 25.25% } diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 2c93074c3f..2171d36439 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -7,6 +7,7 @@ import { HelperText } from '../../controls/HelperText'; import { InputLabel } from '../../controls/InputLabel'; import { InputStack } from '../../controls/InputStack'; import { cx } from '../../cx'; +import { useTheme } from '../../hooks/useTheme'; import { HStack } from '../../layout/HStack'; import { VStack } from '../../layout/VStack'; import { AnimatedCaret } from '../../motion/AnimatedCaret'; @@ -21,12 +22,6 @@ import { type SelectType, } from './Select'; -// The height is smaller for the inside label variant since the label takes -// up space above the input. -const LABEL_VARIANT_INSIDE_HEIGHT = 32; -const COMPACT_HEIGHT = 40; -const DEFAULT_HEIGHT = 56; - const noFocusOutlineCss = css` &:focus, &:focus-visible, @@ -105,7 +100,10 @@ const DefaultSelectControlComponent = memo( type ValueType = Type extends 'multi' ? SelectOptionValue | SelectOptionValue[] | null : SelectOptionValue | null; + const theme = useTheme(); const isMultiSelect = type === 'multi'; + // horizontal/inline label is used for compact selesct exepct for multi-selects + // multi-selects render their label outside of the control unless labelVariant is set to 'inside' const shouldShowCompactLabel = compact && label && !isMultiSelect; const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); // Map of options to their values @@ -242,13 +240,12 @@ const DefaultSelectControlComponent = memo( const labelNode = useMemo( () => - labelVariant === 'inside' ? ( + // labelVariant has no effect when compact is true + labelVariant === 'inside' && !compact ? ( setOpen((s) => !s)} tabIndex={-1}> {label} @@ -258,6 +255,8 @@ const DefaultSelectControlComponent = memo( {label} @@ -265,7 +264,15 @@ const DefaultSelectControlComponent = memo( ) : ( label ), - [labelVariant, classNames?.controlLabelNode, styles?.controlLabelNode, label, setOpen], + [ + labelVariant, + compact, + classNames?.controlLabelNode, + styles?.controlLabelNode, + label, + shouldShowCompactLabel, + setOpen, + ], ); const valueNode = useMemo(() => { @@ -360,13 +367,6 @@ const DefaultSelectControlComponent = memo( flexGrow={1} flexShrink={1} focusable={false} - minHeight={ - labelVariant === 'inside' - ? LABEL_VARIANT_INSIDE_HEIGHT - : compact - ? COMPACT_HEIGHT - : DEFAULT_HEIGHT - } minWidth={0} onClick={() => setOpen((s) => !s)} onKeyDown={onKeyDown} @@ -382,14 +382,14 @@ const DefaultSelectControlComponent = memo( height="100%" justifyContent="center" minWidth={0} - paddingX={1} + paddingEnd={2} style={styles?.controlStartNode} > {startNode} )} {shouldShowCompactLabel ? ( - + {labelNode} ) : null} @@ -413,8 +413,6 @@ const DefaultSelectControlComponent = memo( justifyContent="flex-start" minWidth={0} overflow="hidden" - paddingX={1} - paddingY={labelVariant === 'inside' && !isMultiSelect ? 0 : compact ? 1 : 1.5} style={styles?.controlValueNode} > {valueNode} @@ -433,8 +431,6 @@ const DefaultSelectControlComponent = memo( classNames?.controlStartNode, classNames?.controlValueNode, disabled, - labelVariant, - compact, styles?.controlInputNode, styles?.controlStartNode, styles?.controlValueNode, @@ -444,7 +440,6 @@ const DefaultSelectControlComponent = memo( shouldShowCompactLabel, labelNode, align, - isMultiSelect, valueNode, contentNode, setOpen, @@ -460,8 +455,6 @@ const DefaultSelectControlComponent = memo( flexGrow={1} height="100%" justifyContent={labelVariant === 'inside' ? 'flex-end' : undefined} - paddingX={2} - paddingY={compact ? 1 : 1.5} style={styles?.controlEndNode} > {customEndNode ? ( @@ -478,7 +471,6 @@ const DefaultSelectControlComponent = memo( [ classNames?.controlEndNode, labelVariant, - compact, styles?.controlEndNode, customEndNode, open, @@ -487,6 +479,18 @@ const DefaultSelectControlComponent = memo( ], ); + const inputStackStyles: Record = useMemo( + () => ({ + input: { + paddingTop: compact || labelVariant === 'inside' ? theme.space[1] : theme.space[2], + paddingBottom: compact ? theme.space[1] : theme.space[2], + paddingLeft: theme.space[2], + paddingRight: theme.space[2], + }, + }), + [compact, theme.space, labelVariant], + ); + return ( diff --git a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx index 3a8068c5f0..528b02e566 100644 --- a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx +++ b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx @@ -16,6 +16,10 @@ import { DefaultSelectOptionGroup } from './DefaultSelectOptionGroup'; import type { SelectDropdownProps, SelectOption, SelectOptionCustomUI, SelectType } from './Select'; import { defaultAccessibilityRoles, isSelectOptionGroup } from './Select'; +// intentional design decision to set max height to this value +// will cut off an option midway to afford scrolling action +const DEFAULT_SELECT_DROPDOWN_MAX_HEIGHT = 252; + const initialStyle = { opacity: 0, y: 0 }; const animateStyle = { opacity: 1, y: 4 }; @@ -59,6 +63,7 @@ const DefaultSelectDropdownComponent = memo( SelectOptionGroupComponent = DefaultSelectOptionGroup, accessibilityLabel = 'Select dropdown', accessibilityRoles = defaultAccessibilityRoles, + maxHeight = DEFAULT_SELECT_DROPDOWN_MAX_HEIGHT, ...props }: SelectDropdownProps, ref: React.Ref, @@ -264,7 +269,7 @@ const DefaultSelectDropdownComponent = memo( useEffect(() => { if (!controlRef.current) return; const resizeObserver = new ResizeObserver((entries) => { - setContainerWidth(entries[0].contentRect.width); + setContainerWidth(entries[0].target.getBoundingClientRect().width); }); resizeObserver.observe(controlRef.current); return () => resizeObserver.disconnect(); @@ -304,7 +309,7 @@ const DefaultSelectDropdownComponent = memo( borderRadius={400} elevation={2} flexDirection="column" - maxHeight={252} + maxHeight={maxHeight} overflow="auto" > {shouldShowSelectAll && ( diff --git a/packages/web/src/alpha/select/DefaultSelectOption.tsx b/packages/web/src/alpha/select/DefaultSelectOption.tsx index 69ccd05db0..f1b3e75d76 100644 --- a/packages/web/src/alpha/select/DefaultSelectOption.tsx +++ b/packages/web/src/alpha/select/DefaultSelectOption.tsx @@ -42,7 +42,7 @@ const selectOptionCss = css` position: absolute; inset: 0; border-radius: var(--bookendRadius); - border: 2px solid var(--color-bgLinePrimary); + border: var(--borderWidth-200) solid var(--color-bgLinePrimary); } &:first-child::after { @@ -158,7 +158,6 @@ const DefaultSelectOptionComponent = memo( end={end} innerSpacing={selectCellSpacingConfig.innerSpacing} media={media} - minHeight={compact ? 40 : 56} outerSpacing={selectCellSpacingConfig.outerSpacing} priority="end" selected={selected} diff --git a/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx b/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx index 613cf46ada..19a67e6243 100644 --- a/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx +++ b/packages/web/src/alpha/select/DefaultSelectOptionGroup.tsx @@ -2,11 +2,10 @@ import { memo, useCallback, useId, useMemo } from 'react'; import { Checkbox } from '../../controls/Checkbox'; import { Radio } from '../../controls/Radio'; -import { cx } from '../../cx'; import { VStack } from '../../layout'; import { Text } from '../../typography/Text'; -import type { SelectOptionGroupProps, SelectOptionProps, SelectType } from './Select'; +import type { SelectOptionGroupProps, SelectType } from './Select'; const DefaultSelectOptionGroupComponent = memo( ({ diff --git a/packages/web/src/alpha/select/Select.tsx b/packages/web/src/alpha/select/Select.tsx index 91b6fda3b6..4f28a65895 100644 --- a/packages/web/src/alpha/select/Select.tsx +++ b/packages/web/src/alpha/select/Select.tsx @@ -296,7 +296,7 @@ const SelectBase = memo( return ( } + ref={containerRef as React.RefObject} className={cx(classNames?.root, className)} data-testid={testID} style={rootStyles} diff --git a/packages/web/src/alpha/select/types.ts b/packages/web/src/alpha/select/types.ts index 547a6aaea0..850dd99c84 100644 --- a/packages/web/src/alpha/select/types.ts +++ b/packages/web/src/alpha/select/types.ts @@ -2,6 +2,7 @@ import type React from 'react'; import type { SharedAccessibilityProps } from '@coinbase/cds-common'; import type { CellBaseProps } from '../../cells/Cell'; +import type { CellAccessoryProps } from '../../cells/CellAccessory'; import type { InputStackBaseProps } from '../../controls/InputStack'; import type { AriaHasPopupType } from '../../hooks/useA11yControlledVisibility'; import type { BoxDefaultElement, BoxProps } from '../../layout/Box'; @@ -142,7 +143,7 @@ export type SelectOptionGroupProps< /** Accessibility role for options */ accessibilityRole?: string; /** Accessory element to display with options */ - accessory?: React.ReactElement; + accessory?: React.ReactElement; /** Media element to display with options */ media?: React.ReactElement; /** End element to display with options */ @@ -282,6 +283,10 @@ export type SelectDropdownProps< setOpen: (open: boolean | ((open: boolean) => boolean)) => void; /** Label displayed above the dropdown */ label?: React.ReactNode; + /** Maximum height of the dropdown container + * @default 252 + */ + maxHeight?: number; /** Whether the dropdown is disabled */ disabled?: boolean; /** Label for the "Select All" option in multi-select mode */ diff --git a/packages/web/src/animation/LottieStatusAnimation.tsx b/packages/web/src/animation/LottieStatusAnimation.tsx index fba777638e..d3be770f60 100644 --- a/packages/web/src/animation/LottieStatusAnimation.tsx +++ b/packages/web/src/animation/LottieStatusAnimation.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, useMemo, useRef, useState } from 'react'; import { lottieStatusToAccessibilityLabel } from '@coinbase/cds-common/lottie/statusToAccessibilityLabel'; import { useStatusAnimationPoller } from '@coinbase/cds-common/lottie/useStatusAnimationPoller'; -import type { DimensionValue } from '@coinbase/cds-common/types/DimensionStyles'; import type { LottiePlayer } from '@coinbase/cds-common/types/LottiePlayer'; import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; @@ -19,11 +18,11 @@ type LottieStatusAnimationBaseProps = { }; type LottieStatusAnimationPropsWithWidth = { - width: DimensionValue; + width: React.CSSProperties['width']; } & LottieStatusAnimationBaseProps; type LottieStatusAnimationPropsWithHeight = { - height: DimensionValue; + height: React.CSSProperties['height']; } & LottieStatusAnimationBaseProps; export type LottieStatusAnimationProps = ( @@ -41,7 +40,7 @@ export const LottieStatusAnimation = memo( ...otherProps }: LottieStatusAnimationProps) => { const [, forceUpdate] = useState(0); - const lottie = useRef(); + const lottie = useRef(undefined); const handlePolling = useStatusAnimationPoller({ status, diff --git a/packages/web/src/animation/__tests__/useLottieHandlers.test.ts b/packages/web/src/animation/__tests__/useLottieHandlers.test.ts index 7a51897043..d1e1eaf528 100644 --- a/packages/web/src/animation/__tests__/useLottieHandlers.test.ts +++ b/packages/web/src/animation/__tests__/useLottieHandlers.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import { useLottieHandlers } from '../useLottieHandlers'; diff --git a/packages/web/src/animation/__tests__/useLottieListeners.test.ts b/packages/web/src/animation/__tests__/useLottieListeners.test.ts index f157f555a7..5c3983b339 100644 --- a/packages/web/src/animation/__tests__/useLottieListeners.test.ts +++ b/packages/web/src/animation/__tests__/useLottieListeners.test.ts @@ -1,4 +1,4 @@ -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook } from '@testing-library/react'; import type { LottieAnimationRef, LottieListener } from '../types'; import { useLottieListeners } from '../useLottieListeners'; diff --git a/packages/web/src/animation/useLottieLoader.ts b/packages/web/src/animation/useLottieLoader.ts index 3712b256f6..995d7f9137 100644 --- a/packages/web/src/animation/useLottieLoader.ts +++ b/packages/web/src/animation/useLottieLoader.ts @@ -19,7 +19,7 @@ export const useLottieLoader = (null); - const animationRef: LottieAnimationRef = useRef(); + const animationRef: LottieAnimationRef = useRef(undefined); const [, setAnimationLoaded] = useState(false); const preserveAspectRatio = useMemo(() => { diff --git a/packages/web/src/banner/Banner.tsx b/packages/web/src/banner/Banner.tsx index 34f5af8523..6017ae8124 100644 --- a/packages/web/src/banner/Banner.tsx +++ b/packages/web/src/banner/Banner.tsx @@ -19,11 +19,13 @@ import type { import { css } from '@linaria/core'; import { Collapsible } from '../collapsible'; +import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons/Icon'; import { Box, HStack, type HStackDefaultElement, type HStackProps, VStack } from '../layout'; import type { ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; import { Pressable } from '../system/Pressable'; +import type { StylesAndClassNames } from '../types'; import type { LinkDefaultElement, LinkProps } from '../typography/Link'; import { Link } from '../typography/Link'; import { Text } from '../typography/Text'; @@ -38,6 +40,29 @@ export const contentResponsiveConfig: ResponsiveProps['flexDir desktop: 'row', } as const; +/** + * Static class names for Banner component parts. + * Use these selectors to target specific elements with CSS. + */ +export const bannerClassNames = { + /** Persistent outer wrapper around both dismissible and non-dismissible variants. */ + root: 'cds-Banner', + /** Main content container (`HStack`) for banner body. */ + content: 'cds-Banner-content', + /** Start icon wrapper. */ + start: 'cds-Banner-start', + /** Right-side body wrapper containing middle content and actions. */ + body: 'cds-Banner-body', + /** Middle content wrapper containing title/message/label region. */ + middle: 'cds-Banner-middle', + /** Label text element. */ + label: 'cds-Banner-label', + /** Actions row element. */ + actions: 'cds-Banner-actions', + /** Dismiss button wrapper element. */ + dismiss: 'cds-Banner-dismiss', +} as const; + export type BannerBaseProps = SharedProps & { /** Sets the variant of the banner - which is responsible for foreground and background color assignment */ variant: BannerVariant; @@ -88,6 +113,7 @@ export type BannerBaseProps = SharedProps & { }; export type BannerProps = BannerBaseProps & + StylesAndClassNames & Omit, 'children' | 'title'>; export const Banner = memo( @@ -120,6 +146,8 @@ export const Banner = memo( marginStart, marginEnd, width = '100%', + classNames, + styles, ...props } = mergedProps; const [isCollapsed, setIsCollapsed] = useState(false); @@ -199,117 +227,143 @@ export const Banner = memo( ); const content = ( - - + + + - {/** Start */} - - - + {/** Middle */} - {/** Middle */} - - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof children === 'string' ? ( - - {children} - - ) : ( - children - )} - - {typeof label === 'string' ? ( - - {label} + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {typeof children === 'string' ? ( + + {children} ) : ( - label + children )} - {/** Actions */} - {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( - - {clonedPrimaryAction} - {clonedSecondaryAction} - + {label} + + ) : ( + label )} - {/** Dismissable action */} - {showDismiss && ( - - - - - + {/** Actions */} + {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( + + {clonedPrimaryAction} + {clonedSecondaryAction} + )} - - {styleVariant === 'global' && !showDismiss && borderBox} - + + {/** Dismissable action */} + {showDismiss && ( + + + + + + )} + ); - return showDismiss ? ( - - - {content} - + return ( + + {showDismiss ? ( + + {content} + + ) : ( + content + )} {styleVariant === 'global' && borderBox} - ) : ( - content ); }), ); diff --git a/packages/web/src/banner/__tests__/Banner.test.tsx b/packages/web/src/banner/__tests__/Banner.test.tsx index 3329eb1de1..b2faaa55fd 100644 --- a/packages/web/src/banner/__tests__/Banner.test.tsx +++ b/packages/web/src/banner/__tests__/Banner.test.tsx @@ -97,6 +97,56 @@ describe('Banner', () => { expect(screen.getByTestId(TEST_ID)).toHaveStyle(customCss); }); + it('applies classNames/styles root and content slots', () => { + render( + + + Banner Content + + , + ); + + expect(screen.getByTestId(TEST_ID).className).toContain('test-banner-content'); + expect(screen.getByTestId(TEST_ID).className).toContain('cds-Banner-content'); + expect(screen.getByTestId(TEST_ID).parentElement?.className).toContain('test-banner-root'); + expect(screen.getByTestId(TEST_ID).parentElement?.className).toContain('cds-Banner'); + }); + + it('keeps a stable top-level wrapper regardless of dismiss state', () => { + const { container, rerender } = render( + + + Banner Content + + , + ); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + + rerender( + + + Banner Content + + , + ); + + expect(container.firstElementChild?.tagName).toBe('DIV'); + }); + it('renders warning banner correctly', () => { render( diff --git a/packages/web/src/buttons/AvatarButton.tsx b/packages/web/src/buttons/AvatarButton.tsx index d9869a09c5..c99eb998ce 100644 --- a/packages/web/src/buttons/AvatarButton.tsx +++ b/packages/web/src/buttons/AvatarButton.tsx @@ -1,5 +1,4 @@ -import React, { forwardRef, memo, useMemo } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; +import React, { forwardRef, memo } from 'react'; import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; @@ -12,11 +11,100 @@ import type { ButtonBaseProps } from './Button'; export const avatarButtonDefaultElement = 'button'; +type DeprecatedAvatarButtonBorderProps = { + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomLeftRadius?: PressableBaseProps['borderBottomLeftRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomRightRadius?: PressableBaseProps['borderBottomRightRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopLeftRadius?: PressableBaseProps['borderTopLeftRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopRightRadius?: PressableBaseProps['borderTopRightRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderRadius?: PressableBaseProps['borderRadius']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderWidth?: PressableBaseProps['borderWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopWidth?: PressableBaseProps['borderTopWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderEndWidth?: PressableBaseProps['borderEndWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderBottomWidth?: PressableBaseProps['borderBottomWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderStartWidth?: PressableBaseProps['borderStartWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + bordered?: PressableBaseProps['bordered']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedBottom?: PressableBaseProps['borderedBottom']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedEnd?: PressableBaseProps['borderedEnd']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedHorizontal?: PressableBaseProps['borderedHorizontal']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedStart?: PressableBaseProps['borderedStart']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedTop?: PressableBaseProps['borderedTop']; + /** + * @deprecated Border props on `AvatarButton` have no effect. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderedVertical?: PressableBaseProps['borderedVertical']; +}; + export type AvatarButtonDefaultElement = typeof avatarButtonDefaultElement; export type AvatarButtonBaseProps = Polymorphic.ExtendableProps< Omit, - Pick & + DeprecatedAvatarButtonBorderProps & + Pick & Pick< AvatarBaseProps, 'alt' | 'src' | 'colorScheme' | 'shape' | 'borderColor' | 'name' | 'selected' @@ -37,8 +125,6 @@ const baseCss = css` display: flex; align-items: center; justify-content: center; - width: var(--interactable-height); - height: var(--interactable-height); min-width: unset; `; @@ -58,35 +144,31 @@ export const AvatarButton: AvatarButtonComponent = memo( compact, colorScheme, shape, + borderColor, selected, name, ...props } = mergedProps; const Component = (as ?? avatarButtonDefaultElement) satisfies React.ElementType; - const height = compact ? interactableHeight.compact : interactableHeight.regular; - const styles = useMemo( - () => ({ '--interactable-height': `${height}px` }) as React.CSSProperties, - [height], - ); - return ( diff --git a/packages/web/src/buttons/Button.tsx b/packages/web/src/buttons/Button.tsx index 01a23c4bdf..d3983a21c8 100644 --- a/packages/web/src/buttons/Button.tsx +++ b/packages/web/src/buttons/Button.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, memo } from 'react'; +import React, { forwardRef, memo, useMemo } from 'react'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; import type { ButtonVariant, @@ -11,6 +11,8 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; +import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; import { Text } from '../typography/Text'; @@ -18,8 +20,6 @@ import { ProgressCircle } from '../visualizations'; const COMPONENT_STATIC_CLASSNAME = 'cds-Button'; -const DEFAULT_MIN_WIDTH = 100; - /** @deprecated Use progressCircleSize instead. This will be removed in a future major release. * @deprecationExpectedRemoval v10 */ @@ -96,18 +96,8 @@ const middleNodeCss = css` position: relative; `; -const flushSpaceCss = css` +const flushCss = css` min-width: unset; - margin-inline-start: var(--space-2); - margin-inline-end: var(--space-2); -`; - -const flushStartCss = css` - margin-inline-start: calc(var(--space-2) * -1); -`; - -const flushEndCss = css` - margin-inline-end: calc(var(--space-2) * -1); `; export const buttonDefaultElement = 'button'; @@ -209,18 +199,19 @@ export const Button: ButtonComponent = memo( background, color, className, - // TO DO: get rid of this height and interactableHeight (mobile and web both) - height = compact ? 40 : 56, borderColor, - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border borderRadius = compact ? 700 : 900, accessibilityLabel, padding, paddingX = padding ?? (compact ? 2 : 4), + paddingY = padding ?? (compact ? 1 : 2), margin = 0, - minWidth = compact ? 'auto' : DEFAULT_MIN_WIDTH, + minWidth = 'auto', + style, ...props } = mergedProps; + const theme = useTheme(); const Component = (as ?? buttonDefaultElement) satisfies React.ElementType; const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon ?? endIcon); @@ -232,6 +223,19 @@ export const Button: ButtonComponent = memo( const backgroundValue = background ?? variantStyle.background; const borderColorValue = borderColor ?? variantStyle.borderColor; + const resolvedPaddingX = useResolveResponsiveProp(paddingX); + + const pressableStyle = useMemo(() => { + if (!flush || !resolvedPaddingX) return style; + const paddingPx = theme.space[resolvedPaddingX]; + return { + ...style, + ...(flush === 'start' + ? { marginInlineStart: -paddingPx, marginInlineEnd: paddingPx } + : { marginInlineStart: paddingPx, marginInlineEnd: -paddingPx }), + }; + }, [flush, resolvedPaddingX, theme.space, style]); + return ( diff --git a/packages/web/src/buttons/IconButton.tsx b/packages/web/src/buttons/IconButton.tsx index 81f54021db..2670f70f7e 100644 --- a/packages/web/src/buttons/IconButton.tsx +++ b/packages/web/src/buttons/IconButton.tsx @@ -6,6 +6,7 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; @@ -49,18 +50,9 @@ type IconButtonComponent = ( Polymorphic.ReactReturn) & Polymorphic.ReactNamed; -const flushSpaceCss = css` - min-width: unset; - padding-inline-start: var(--space-2); - padding-inline-end: var(--space-2); -`; - -const flushStartCss = css` - margin-inline-start: calc(var(--space-2) * -1); -`; - -const flushEndCss = css` - margin-inline-end: calc(var(--space-2) * -1); +const baseCss = css` + width: fit-content; + height: fit-content; `; export const IconButton: IconButtonComponent = memo( @@ -79,13 +71,12 @@ export const IconButton: IconButtonComponent = memo( color, borderColor, borderRadius = 1000, - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border alignItems = 'center', justifyContent = 'center', - // TO DO: fix this when removing interactableHeight - height = compact ? 40 : 56, - width = compact ? 40 : 56, className, + style, + padding = compact ? 1.5 : 2, name, iconSize = compact ? 's' : 'm', active, @@ -100,6 +91,20 @@ export const IconButton: IconButtonComponent = memo( const theme = useTheme(); const iconSizeValue = theme.iconSize[iconSize]; + const spinnerSize = iconSizeValue / 10; + + const resolvedPadding = useResolveResponsiveProp(padding); + + const pressableStyle = useMemo(() => { + if (!flush || !resolvedPadding) return undefined; + const negativeMargin = -theme.space[resolvedPadding]; + return { + ...style, + ...(flush === 'start' + ? { marginInlineStart: negativeMargin } + : { marginInlineEnd: negativeMargin }), + }; + }, [flush, resolvedPadding, theme.space, style]); const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -119,23 +124,17 @@ export const IconButton: IconButtonComponent = memo( borderColor={borderColorValue} borderRadius={borderRadius} borderWidth={borderWidth} - className={cx( - COMPONENT_STATIC_CLASSNAME, - flush && flushSpaceCss, - flush === 'start' && flushStartCss, - flush === 'end' && flushEndCss, - className, - )} + className={cx(COMPONENT_STATIC_CLASSNAME, baseCss, className)} color={colorValue} data-compact={compact} data-flush={flush} data-transparent={transparent} data-variant={variant} - height={height} justifyContent={justifyContent} loading={loading} + padding={padding} + style={pressableStyle} transparentWhileInactive={transparent} - width={width} {...props} > {loading ? ( diff --git a/packages/web/src/buttons/IconCounterButton.tsx b/packages/web/src/buttons/IconCounterButton.tsx index 782d5f9cdf..b6819e911e 100644 --- a/packages/web/src/buttons/IconCounterButton.tsx +++ b/packages/web/src/buttons/IconCounterButton.tsx @@ -4,6 +4,7 @@ import type { IconSize, ValidateProps } from '@coinbase/cds-common/types'; import { formatCount } from '@coinbase/cds-common/utils/formatCount'; import type { IconName } from '@coinbase/cds-icons'; +import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; @@ -26,14 +27,32 @@ export type IconCounterButtonBaseProps = { count?: number; /** Color of the icon */ color?: ThemeVars.Color; - /** @danger This is a migration escape hatch. It is not intended to be used normally. */ + /** + * @deprecated Use `styles.icon`, `classNames.icon`, or `color` to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string; /** Background color of the overlay (element being interacted with). */ background?: ThemeVars.Color; }; export type IconCounterButtonProps = IconCounterButtonBaseProps & - Omit, 'background'>; + Omit, 'background'> & { + /** Custom inline styles for individual elements of the IconCounterButton component */ + styles?: { + /** Root Pressable element */ + root?: React.CSSProperties; + /** Icon element rendered when `icon` is an icon name */ + icon?: React.CSSProperties; + }; + /** Custom class names for individual elements of the IconCounterButton component */ + classNames?: { + /** Root Pressable element */ + root?: string; + /** Icon element rendered when `icon` is an icon name */ + icon?: string; + }; + }; export const IconCounterButton = memo( forwardRef(function IconCounterButton( @@ -50,12 +69,18 @@ export const IconCounterButton = memo( color = 'fg', dangerouslySetColor, background = 'transparent', + styles, + classNames, + className, + style, ...props } = mergedProps; return ( > @@ -65,10 +90,12 @@ export const IconCounterButton = memo( {typeof icon === 'string' ? ( ) : ( icon diff --git a/packages/web/src/buttons/Tile.tsx b/packages/web/src/buttons/Tile.tsx index d25a0eba1d..cbb60114b4 100644 --- a/packages/web/src/buttons/Tile.tsx +++ b/packages/web/src/buttons/Tile.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useMemo, useState } from 'react'; +import React, { type JSX, memo, useCallback, useMemo, useState } from 'react'; import { css } from '@linaria/core'; import { DotCount } from '../dots/DotCount'; diff --git a/packages/web/src/buttons/__stories__/Button.stories.tsx b/packages/web/src/buttons/__stories__/Button.stories.tsx index 64e8262280..1b4e09fb93 100644 --- a/packages/web/src/buttons/__stories__/Button.stories.tsx +++ b/packages/web/src/buttons/__stories__/Button.stories.tsx @@ -11,14 +11,15 @@ export default { }; const buttonStories: Omit[] = [ - { variant: 'foregroundMuted' }, { variant: 'secondary' }, { variant: 'tertiary' }, { variant: 'positive' }, { variant: 'negative' }, + { variant: 'inverse' }, { variant: 'secondary', transparent: true }, { variant: 'positive', transparent: true }, { variant: 'negative', transparent: true }, + { variant: 'inverse', transparent: true }, { block: true }, { compact: true }, { compact: true, block: true }, diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index ecf888ac70..f4b7849a96 100644 --- a/packages/web/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconButton.stories.tsx @@ -9,6 +9,12 @@ const iconName = 'arrowsHorizontal'; const accessibilityLabel = 'Horizontal arrows'; const variants = [ + { + component: (props?: Partial) => ( + + ), + title: 'Non-compact', + }, { component: (props?: Partial) => ( @@ -35,15 +41,15 @@ const variants = [ }, { component: (props?: Partial) => ( - + ), - title: 'ForegroundMuted', + title: 'Primary flush start', }, { component: (props?: Partial) => ( - + ), - title: 'ForegroundMuted transparent', + title: 'Primary flush end', }, ]; @@ -126,7 +132,6 @@ const IconButtonSheet = ({ startIndex, endIndex }: { startIndex: number; endInde - ))} diff --git a/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx b/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx index 838f298bd8..40f245b387 100644 --- a/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconCounterButton.stories.tsx @@ -29,7 +29,11 @@ export const IconCounterButtonExample = () => { - + diff --git a/packages/web/src/buttons/__tests__/AvatarButton.test.tsx b/packages/web/src/buttons/__tests__/AvatarButton.test.tsx index db9ebb6fbc..f77ed746f0 100644 --- a/packages/web/src/buttons/__tests__/AvatarButton.test.tsx +++ b/packages/web/src/buttons/__tests__/AvatarButton.test.tsx @@ -62,4 +62,14 @@ describe('AvatarButton', () => { expect(screen.getByRole('button')).not.toHaveAttribute('onClick'); }); + + it('accepts deprecated border props without changing rendering', () => { + render( + + + , + ); + + expect(screen.getByRole('button')).toBeDefined(); + }); }); diff --git a/packages/web/src/cards/Card.tsx b/packages/web/src/cards/Card.tsx index 43f676b497..47237bd75a 100644 --- a/packages/web/src/cards/Card.tsx +++ b/packages/web/src/cards/Card.tsx @@ -17,6 +17,10 @@ export type CardBaseProps = Pick & onClick?: MouseEventHandler; }; +/** + * @deprecated Use `ContentCard`, `MediaCard`, `MessagingCard`, or `DataCard` based on your use case. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardProps = CardBaseProps & Omit, 'onClick' | 'onKeyDown' | 'onKeyUp' | 'background'>; @@ -69,7 +73,6 @@ export const Card = memo(function Card({ ), [background, borderRadius, children, elevation, height, linkable, pin, props, testID, width], ); - if (isAnchor) { return ( = { */ export const CardMedia = memo(function CardMedia({ placement = 'end', ...props }: CardMediaProps) { if (props.type === 'spotSquare') { - return ( - - ); + return ; } if (props.type === 'pictogram') { - return ( - - ); + return ; } if (props.type === 'image') { diff --git a/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx b/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx index a3433d8ead..a3c34ca167 100644 --- a/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx +++ b/packages/web/src/cards/ContentCard/__stories__/ContentCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { Button, IconButton, IconCounterButton } from '../../../buttons'; @@ -491,5 +491,12 @@ export default { }; ProductCarousel.parameters = { - a11y: { config: { rules: [{ id: 'scrollable-region-focusable', enabled: false }] } }, + a11y: { + config: { rules: [{ id: 'scrollable-region-focusable', enabled: false }] }, + options: { + rules: { + 'target-size': { enabled: false }, + }, + }, + }, }; diff --git a/packages/web/src/cards/LikeButton.tsx b/packages/web/src/cards/LikeButton.tsx index c717a705a2..7198603b6c 100644 --- a/packages/web/src/cards/LikeButton.tsx +++ b/packages/web/src/cards/LikeButton.tsx @@ -1,9 +1,9 @@ -import React, { memo } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; +import { memo, useMemo } from 'react'; import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -16,7 +16,7 @@ export type LikeButtonBaseProps = Pick< SharedProps & { liked?: boolean; count?: number; - /** Reduce the inner padding within the button itself. */ + /** Use the compact variant. */ compact?: boolean; /** Ensure the button aligns flush on the left or right. * This prop will translate the entire button left/right, @@ -29,14 +29,27 @@ export type LikeButtonProps = LikeButtonBaseProps & PressableProps ({ lineHeight: `${theme.iconSize[iconSize]}px` }), + [theme.iconSize, iconSize], + ); + return ( - + {count > 0 ? ( - + {count} ) : null} diff --git a/packages/web/src/cards/UpsellCard.tsx b/packages/web/src/cards/UpsellCard.tsx index 256bfb7927..6a1df1a5cc 100644 --- a/packages/web/src/cards/UpsellCard.tsx +++ b/packages/web/src/cards/UpsellCard.tsx @@ -8,13 +8,14 @@ import type { } from '@coinbase/cds-common/types'; import { Button, IconButton } from '../buttons'; -import { HStack, VStack } from '../layout'; +import { HStack, type HStackDefaultElement, type HStackProps, VStack } from '../layout'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system'; import { Text } from '../typography/Text'; export type UpsellCardBaseProps = SharedProps & Pick & - Pick & { + Pick & + Pick, 'className' | 'style'> & { /** Callback fired when the action button is pressed */ onActionPress?: PressableProps['onClick']; /** Callback fired when the dismiss button is pressed */ @@ -37,7 +38,8 @@ export type UpsellCardBaseProps = SharedProps & */ background?: ThemeVars.Color; /** - * @danger This is a migration escape hatch. It is not intended to be used normally. + * @deprecated Use `style`, `className`, or `background` to customize card background. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ dangerouslySetBackground?: string; }; @@ -86,6 +88,8 @@ export const UpsellCard = memo( accessibilityLabel, width = upsellCardDefaultWidth, onClick, + className, + style, }: UpsellCardProps) => { const content = ( diff --git a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx index 288cacf183..4573917c43 100644 --- a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx +++ b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx @@ -40,8 +40,8 @@ figma.connect( return ( ); diff --git a/packages/web/src/cards/__stories__/Card.stories.tsx b/packages/web/src/cards/__stories__/Card.stories.tsx index 7052ee96f1..bf1fdc359f 100644 --- a/packages/web/src/cards/__stories__/Card.stories.tsx +++ b/packages/web/src/cards/__stories__/Card.stories.tsx @@ -6,6 +6,7 @@ import { featureEntryCards } from '@coinbase/cds-common/internal/data/featureEnt import { feedImages } from '@coinbase/cds-common/internal/data/feedImages'; import { loremIpsum } from '@coinbase/cds-common/internal/data/loremIpsum'; import { baseConfig, storyBuilder } from '@coinbase/cds-common/internal/utils/storyBuilder'; +import type { Meta, StoryObj } from '@storybook/react'; import { Button } from '../../buttons'; import { Box, VStack } from '../../layout'; @@ -122,7 +123,9 @@ const feedCards = [ } as const, ]; -export const FeedCard = ({ ...props }: FeedCardProps) => { +type Story = StoryObj; + +const FeedCardRender = ({ ...props }: FeedCardProps) => { return ( { ); }; -FeedCard.bind({}); -FeedCard.args = baseConfig.args; -FeedCard.argTypes = baseConfig.argTypes; -FeedCard.parameters = { - ...baseConfig.parameters, - ...cardParameters, +export const FeedCard: Story = { + render: (args) => , + args: baseConfig.args, + argTypes: baseConfig.argTypes, + parameters: { + ...baseConfig.parameters, + ...cardParameters, + }, }; -export const FeedCards = () => { +const FeedCardsRender = () => { return ( {feedCards.map(({ like: getLikeProps, ...item }) => ( @@ -152,10 +157,15 @@ export const FeedCards = () => { ); }; -FeedCards.bind({}); -FeedCards.args = FeedCard.args; -FeedCards.parameters = FeedCard.parameters; -FeedCards.argTypes = FeedCard.argTypes; +export const FeedCards: Story = { + render: () => , + args: baseConfig.args, + parameters: { + ...baseConfig.parameters, + ...cardParameters, + }, + argTypes: baseConfig.argTypes, +}; // below is copied from cardBuilder.tsx const sharedWrapperProps = { @@ -283,7 +293,9 @@ export { PressableColoredCards, }; -export default { +const meta: Meta = { title: 'Components/Cards', - component: FeedCard, + component: FeedCardComponent, }; + +export default meta; diff --git a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx index 5cc34f36db..57be356370 100644 --- a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx +++ b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { subheadIconSignMap } from '@coinbase/cds-common/tokens/sparkline'; diff --git a/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx b/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx index 1b58d1985f..e83bc87a79 100644 --- a/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx +++ b/packages/web/src/cards/__stories__/FloatingAssetCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { ethBackground, floatingAssetCardCustomImage, diff --git a/packages/web/src/cards/__stories__/MediaCard.stories.tsx b/packages/web/src/cards/__stories__/MediaCard.stories.tsx index 6f08c754bf..41fb408459 100644 --- a/packages/web/src/cards/__stories__/MediaCard.stories.tsx +++ b/packages/web/src/cards/__stories__/MediaCard.stories.tsx @@ -1,11 +1,11 @@ -import React, { useRef } from 'react'; +import React, { type JSX, useRef } from 'react'; import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { Carousel } from '../../carousel/Carousel'; import { CarouselItem } from '../../carousel/CarouselItem'; import { VStack } from '../../layout/VStack'; import { RemoteImage } from '../../media/RemoteImage'; -import { TextHeadline, TextLabel2, TextTitle3 } from '../../typography'; +import { Text } from '../../typography'; import { MediaCard } from '../MediaCard'; const exampleProps = { @@ -123,14 +123,22 @@ export const TextContent = (): JSX.Element => { /> + Custom description with bold text and italic text - + } media={exampleMedia} - subtitle={Custom Subtitle} + subtitle={ + + Custom Subtitle + + } thumbnail={exampleThumbnail} - title={Custom Title} + title={ + + Custom Title + + } width={320} /> diff --git a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx index bc8d5d01cb..a613bb6004 100644 --- a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx +++ b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { type JSX, useRef, useState } from 'react'; import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets'; import { Button } from '../../buttons/Button'; diff --git a/packages/web/src/cards/__stories__/UpsellCard.stories.tsx b/packages/web/src/cards/__stories__/UpsellCard.stories.tsx index b43f005296..6634595a70 100644 --- a/packages/web/src/cards/__stories__/UpsellCard.stories.tsx +++ b/packages/web/src/cards/__stories__/UpsellCard.stories.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type JSX } from 'react'; import { coinbaseOneLogo } from '@coinbase/cds-common/internal/data/assets'; import { Button } from '../../buttons'; @@ -68,12 +68,12 @@ export const CustomTextNodes = (): JSX.Element => { Sign up } - dangerouslySetBackground="rgb(var(--blue80))" description={ Start your free 30 day trial of Coinbase One } + style={{ backgroundColor: 'rgb(var(--blue80))' }} title={ Coinbase One @@ -84,7 +84,7 @@ export const CustomTextNodes = (): JSX.Element => { }; export const CustomBackground = (): JSX.Element => { - return ; + return ; }; export const CustomWidth = (): JSX.Element => ; diff --git a/packages/web/src/cards/__tests__/UpsellCard.test.tsx b/packages/web/src/cards/__tests__/UpsellCard.test.tsx index c369a6b221..edf6f9e13a 100644 --- a/packages/web/src/cards/__tests__/UpsellCard.test.tsx +++ b/packages/web/src/cards/__tests__/UpsellCard.test.tsx @@ -46,10 +46,10 @@ describe('UpsellCard', () => { expect(screen.getByTestId('media')).toBeInTheDocument(); }); - it('renders dangerouslySetBackground', () => { + it('renders custom background via style prop', () => { render( - + , ); expect(screen.getByTestId(exampleProps.testID as string)).toHaveStyle({ diff --git a/packages/web/src/carousel/Carousel.tsx b/packages/web/src/carousel/Carousel.tsx index c47a5c7619..4737f92035 100644 --- a/packages/web/src/carousel/Carousel.tsx +++ b/packages/web/src/carousel/Carousel.tsx @@ -69,7 +69,7 @@ const animationConfig: Transition = { mass: 4, }; -export type CarouselItemRenderChildren = React.FC<{ isVisible: boolean }>; +export type CarouselItemRenderChildren = (args: { isVisible: boolean }) => React.ReactNode; export type CarouselItemBaseProps = Omit & { /** @@ -163,10 +163,10 @@ export type CarouselPaginationComponentBaseProps = { paginationAccessibilityLabel?: string | ((pageIndex: number) => string); /** * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead + * When omitted, the default pagination component renders the current dot-style design. + * @default 'dot' + * @deprecated `pill` is deprecated. Prefer the default dot pagination or provide a custom `PaginationComponent`. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ variant?: 'pill' | 'dot'; }; @@ -235,6 +235,11 @@ export type CarouselBaseProps = SharedProps & * Hides the pagination indicators (dots/bars showing current page). */ hidePagination?: boolean; + /** + * @deprecated Use the default dot pagination, or provide a custom `PaginationComponent` if you need custom visuals. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + paginationVariant?: CarouselPaginationComponentBaseProps['variant']; /** * Custom component to render navigation arrows. * @default DefaultCarouselNavigation @@ -312,14 +317,6 @@ export type CarouselBaseProps = SharedProps & * @default 3000 (3 seconds) */ autoplayInterval?: number; - /** - * Visual variant for the pagination indicators. - * - 'pill': All indicators are pill-shaped (default) - * - 'dot': Inactive indicators are small dots, active indicator expands to a pill - * @default 'pill' - * @note 'pill' variant is deprecated, use 'dot' instead - */ - paginationVariant?: CarouselPaginationComponentBaseProps['variant']; }; export type CarouselProps = Omit, 'title'> & @@ -717,6 +714,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -737,7 +735,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props } = mergedProps; const animationApi = useAnimation(); @@ -1323,7 +1320,12 @@ export const Carousel = memo( {(title || !hideNavigation) && ( {typeof title === 'string' ? ( - + {title} ) : ( @@ -1379,32 +1381,35 @@ export const Carousel = memo( {pageChangeAccessibilityLabel(activePageIndex, totalPages)} )} + {/* // TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility */} )} > {childrenWithClones} diff --git a/packages/web/src/carousel/DefaultCarouselPagination.tsx b/packages/web/src/carousel/DefaultCarouselPagination.tsx index 2d804f69ff..a0dcc63eae 100644 --- a/packages/web/src/carousel/DefaultCarouselPagination.tsx +++ b/packages/web/src/carousel/DefaultCarouselPagination.tsx @@ -76,6 +76,7 @@ type PaginationIndicatorProps = PressableProps<'button'> & { const PaginationPill = memo(function PaginationPill({ isActive, + className, ...props }: PaginationIndicatorProps) { return ( @@ -83,6 +84,8 @@ const PaginationPill = memo(function PaginationPill({ aria-current={isActive ? 'true' : undefined} background={isActive ? 'bgPrimary' : 'bgLine'} borderColor="transparent" + borderWidth={0} + className={cx(pillCss, className)} data-active={isActive} {...props} /> @@ -180,7 +183,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination style, styles, testID = 'carousel-pagination', - variant = 'pill', + variant = 'dot', }: DefaultCarouselPaginationProps) { const isDot = variant === 'dot'; @@ -195,9 +198,11 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination return ( {totalPages > 0 ? ( Array.from({ length: totalPages }, (_, index) => @@ -215,7 +220,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination onClickPage?.(index)} style={styles?.dot} @@ -229,6 +234,7 @@ export const DefaultCarouselPagination = memo(function DefaultCarouselPagination aria-hidden="true" background="bgLine" borderColor="transparent" + borderWidth={0} className={cx(isDot ? dotCss : pillCss, classNames?.dot)} style={{ opacity: 0, diff --git a/packages/web/src/carousel/__figma__/Carousel.figma.tsx b/packages/web/src/carousel/__figma__/Carousel.figma.tsx index cb248f5434..a7609a0044 100644 --- a/packages/web/src/carousel/__figma__/Carousel.figma.tsx +++ b/packages/web/src/carousel/__figma__/Carousel.figma.tsx @@ -19,7 +19,7 @@ figma.connect( }), }, example: ({ title, hidePagination }) => ( - + {/* Item content */} {/* Item content */} {/* Item content */} diff --git a/packages/web/src/carousel/__stories__/Carousel.stories.tsx b/packages/web/src/carousel/__stories__/Carousel.stories.tsx index 8c5b433c92..effe60e7c0 100644 --- a/packages/web/src/carousel/__stories__/Carousel.stories.tsx +++ b/packages/web/src/carousel/__stories__/Carousel.stories.tsx @@ -165,7 +165,7 @@ const SquareAssetCard = ({ const BasicExamples = () => ( - + {sampleItems.map((item, index) => ( ( - + {sampleItems.map((item, index) => ( ( loop NavigationComponent={SeeAllComponent} drag="free" - paginationVariant="dot" snapMode="item" styles={overflowStyles} title="Square Items Carousel" @@ -222,7 +215,6 @@ const BasicExamples = () => ( ( {({ isVisible }) => } - + {sampleItems.slice(0, 4).map((item, index) => ( {item} @@ -300,14 +287,14 @@ const CustomComponentsExample = () => { disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} - variant="foregroundMuted" + variant="secondary" /> @@ -437,7 +424,7 @@ const CustomStylesExample = () => { zIndex: 1, }, }} - variant="foregroundMuted" + variant="secondary" /> )} styles={{ @@ -582,13 +569,7 @@ const AnimatedPaginationExample = () => { const AutoplayExample = () => ( - + {Object.values(assets).map((asset) => ( {({ isVisible }) => ( @@ -608,13 +589,7 @@ const AutoplayExample = () => ( const LoopingExamples = () => ( - + {sampleItems.map((item, index) => ( ( autoplay loop drag="snap" - paginationVariant="dot" snapMode="item" styles={overflowStyles} title="Looping with Autoplay - Snap Item" @@ -651,7 +625,6 @@ const LoopingExamples = () => ( { render(); - expect(mockNavigation).toHaveBeenCalledWith( + expect(mockNavigation).toHaveBeenCalled(); + expect(mockNavigation.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ onGoNext: expect.any(Function), onGoPrevious: expect.any(Function), disableGoNext: expect.any(Boolean), disableGoPrevious: expect.any(Boolean), }), - {}, ); }); + + it('does not pass a pagination variant by default', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render(); + + await waitFor(() => { + expect( + mockPagination.mock.calls.some((call) => { + const props = call[0]; + return props !== undefined && props.variant === undefined; + }), + ).toBe(true); + }); + }); + + it('forwards deprecated paginationVariant to custom pagination components', async () => { + const mockPagination = jest.fn((props: { variant?: 'pill' | 'dot' }) => null); + + render( + , + ); + + await waitFor(() => { + expect(mockPagination.mock.calls.some((call) => call[0]?.variant === 'pill')).toBe(true); + }); + }); }); describe('Accessibility', () => { diff --git a/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx b/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx index 5798e87193..fd4dc9ab10 100644 --- a/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx +++ b/packages/web/src/carousel/__tests__/DefaultCarouselPagination.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; +import { domMax, LazyMotion } from 'framer-motion'; import { DefaultThemeProvider } from '../../utils/test'; import { CarouselAutoplayContext } from '../CarouselContext'; @@ -9,17 +10,9 @@ import { DefaultCarouselPagination } from '../DefaultCarouselPagination'; jest.mock('framer-motion', () => { const realFramerMotion = jest.requireActual('framer-motion'); - const createMockMotionValue = (initialValue: number) => ({ - get: jest.fn(() => initialValue), - set: jest.fn(), - on: jest.fn(() => () => {}), - onChange: jest.fn(() => () => {}), - clearListeners: jest.fn(), - }); - return { ...realFramerMotion, - motion: realFramerMotion.motion, + motion: realFramerMotion.m, useTransform: (value: { get: () => number }, transformer: (v: number) => string) => { const transformedValue = transformer(value.get()); return transformedValue; @@ -46,18 +39,34 @@ const mockAutoplayContext = { const renderPagination = (props: Partial>) => render( - - - + + + + + , ); describe('DefaultCarouselPagination', () => { + describe('variant', () => { + it('defaults to the dot variant', () => { + renderPagination({ totalPages: 3 }); + + expect(screen.getByTestId('carousel-pagination')).toHaveAttribute('data-variant', 'dot'); + }); + + it('switches to the pill variant when requested', () => { + renderPagination({ totalPages: 3, variant: 'pill' }); + + expect(screen.getByTestId('carousel-pagination')).toHaveAttribute('data-variant', 'pill'); + }); + }); + describe('paginationAccessibilityLabel', () => { it('uses default function that includes page number when not provided', () => { renderPagination({ totalPages: 3 }); diff --git a/packages/web/src/cells/Cell.tsx b/packages/web/src/cells/Cell.tsx index a25c37e12a..8e34a6b70a 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -207,7 +207,24 @@ export const Cell: CellComponent = memo( accessory, accessoryNode, alignItems = 'center', + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, borderRadius = 200, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, children, style, styles, @@ -262,11 +279,56 @@ export const Cell: CellComponent = memo( const isButton = Boolean(onClick ?? onKeyDown ?? onKeyUp); const linkable = isAnchor || isButton; const contentTruncationStyle = cx(baseCss, shouldTruncate && truncationCss); + // Border props must be applied to the internal Pressable wrapper for correct visual rendering. + // The outer Box was only meant to create padding outside the Pressable area; this behavior + // will be removed in https://linear.app/coinbase/issue/CDS-1512/remove-legacy-normal-spacing-variant-from-listcell. + const borderProps = useMemo( + () => ({ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + }), + [ + bordered, + borderedBottom, + borderedEnd, + borderedHorizontal, + borderedStart, + borderedTop, + borderedVertical, + borderBottomLeftRadius, + borderBottomRightRadius, + borderBottomWidth, + borderColor, + borderEndWidth, + borderRadius, + borderStartWidth, + borderTopLeftRadius, + borderTopRightRadius, + borderTopWidth, + borderWidth, + ], + ); const content = useMemo(() => { // props for the entire inner container that wraps the top content // (media, children, intermediary, detail, accessory) and the bottom content const contentContainerProps = { - borderRadius, + ...borderProps, className: cx(contentClassName, classNames?.contentContainer), testID, ...(selected ? { background } : {}), @@ -368,7 +430,7 @@ export const Cell: CellComponent = memo( ); }, [ - borderRadius, + borderProps, contentClassName, classNames?.contentContainer, classNames?.topContent, @@ -417,7 +479,7 @@ export const Cell: CellComponent = memo( accessibilityLabel, accessibilityLabelledBy, background: 'bg' as const, - borderRadius, + ...borderProps, className: cx(pressCss, insetFocusRingCss, classNames?.pressable), disabled, marginX: innerSpacingMarginX, @@ -447,7 +509,7 @@ export const Cell: CellComponent = memo( accessibilityHint, accessibilityLabel, accessibilityLabelledBy, - borderRadius, + borderProps, classNames?.pressable, disabled, innerSpacingMarginX, diff --git a/packages/web/src/cells/CellAccessory.tsx b/packages/web/src/cells/CellAccessory.tsx index 3f29cb877f..cf6d1dbfd2 100644 --- a/packages/web/src/cells/CellAccessory.tsx +++ b/packages/web/src/cells/CellAccessory.tsx @@ -51,7 +51,7 @@ export const CellAccessory = memo( } return ( - + {icon} ); diff --git a/packages/web/src/cells/ContentCellFallback.tsx b/packages/web/src/cells/ContentCellFallback.tsx index ce1c5c95be..a1ef19c32a 100644 --- a/packages/web/src/cells/ContentCellFallback.tsx +++ b/packages/web/src/cells/ContentCellFallback.tsx @@ -55,6 +55,10 @@ const fullWidthStyle = { width: '100%', display: 'block' } as const; const floatStyle = { float: 'right', width: '30%' } as const; +/** + * @deprecated Use `ListCellFallback` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const ContentCellFallback = memo(function ContentCellFallback({ accessory, accessoryNode, diff --git a/packages/web/src/cells/ListCell.tsx b/packages/web/src/cells/ListCell.tsx index 9f89407485..012122a651 100644 --- a/packages/web/src/cells/ListCell.tsx +++ b/packages/web/src/cells/ListCell.tsx @@ -30,6 +30,7 @@ export const condensedInnerSpacing = { paddingY: 1, marginX: 0, } as const satisfies CellSpacing; + // no padding outside of the pressable area export const condensedOuterSpacing = { paddingX: 0, @@ -239,16 +240,20 @@ export const ListCell: ListCellComponent = memo( style, subtitle, subtitleNode, + minHeight: minHeightProp, ...props } = mergedProps; const Component = (as ?? listCellDefaultElement) satisfies React.ElementType; + // we need to maintain fixed min-heights for the different cell style variants until they are dropped in a breaking change + // see CDS-1620 const minHeight = - spacingVariant === 'compact' + minHeightProp ?? + (spacingVariant === 'compact' ? compactListHeight : spacingVariant === 'normal' ? listHeight - : undefined; + : undefined); const accessoryType = selected && !disableSelectionAccessory ? 'selected' : accessory; diff --git a/packages/web/src/cells/MediaFallback.tsx b/packages/web/src/cells/MediaFallback.tsx index 414be4ad6c..bf0db24e46 100644 --- a/packages/web/src/cells/MediaFallback.tsx +++ b/packages/web/src/cells/MediaFallback.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import { memo } from 'react'; import { imageSize, mediaSize } from '@coinbase/cds-common/tokens/cell'; import { Fallback, type fallbackDefaultElement, type FallbackProps } from '../layout/Fallback'; @@ -14,8 +14,8 @@ export const MediaFallback = memo(function MediaFallback({ ...fallbackProps }: MediaFallbackProps) { if (type === 'image') { - return ; + return ; } - return ; + return ; }); diff --git a/packages/web/src/cells/__stories__/ListCell.stories.tsx b/packages/web/src/cells/__stories__/ListCell.stories.tsx index 81f8284b84..c014afd5ec 100644 --- a/packages/web/src/cells/__stories__/ListCell.stories.tsx +++ b/packages/web/src/cells/__stories__/ListCell.stories.tsx @@ -887,48 +887,93 @@ const WithHelperText = () => ( ); -const SpacingVariant = () => ( - - {/* Preferred (new design) */} - } - media={} - onClick={onClickConsole} - spacingVariant="condensed" - subdetail="+1.23%" - title="Condensed" - variant="positive" - /> +const BorderCustomization = () => { + const [isCondensed, setIsCondensed] = useState(true); + const spacingVariant = isCondensed ? 'condensed' : 'normal'; - {/* Deprecated options kept for backward compatibility */} - } - media={} - onClick={onClickConsole} - spacingVariant="compact" - subdetail="+1.23%" - title="Compact" - variant="positive" - /> - } - media={} - onClick={onClickConsole} - spacingVariant="normal" - subdetail="+1.23%" - title="Normal" - variant="positive" - /> - -); + return ( + + setIsCondensed(event.currentTarget.checked)} + > + Spacing variant: {spacingVariant} + + + + + + + + + + + ); +}; const CondensedListCell = () => { return ( @@ -1238,7 +1283,236 @@ const UseCaseShowcase = () => { ); }; +/** + * This story shows all 3 spacing variants side by side for easy comparison. + * Each column represents a different variant, and each row shows the same content configuration. + * + * This is useful for: + * - Comparing visual differences between variants + * - Understanding the impact of removing fixed min-height values + * - Testing before/after changes to spacing or height behavior + * + * Current min-height values: + * - normal: 80px + * - compact (deprecated): 40px + * - condensed: undefined (no min-height) + */ +const SpacingVariantsComparison = () => { + const spacingVariants = ['normal', 'compact', 'condensed'] as const; + + const renderCell = ( + spacingVariant: 'normal' | 'compact' | 'condensed', + props: Partial>, + ) => ( + + ); + + return ( + + {/* Header row */} + + {spacingVariants.map((variant) => ( + + {variant} + + {variant === 'normal' + ? 'min-height: 80px' + : variant === 'compact' + ? 'min-height: 40px' + : 'min-height: none'} + + + ))} + + + {/* Row 1: Title only - shows min-height impact most clearly */} + + + Title only (min-height impact most visible) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { title: 'Title' })} + + ))} + + + + {/* Row 2: Title + Detail */} + + + Title + Detail + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + detail: '$12,345.00', + subdetail: '+1.23%', + variant: 'positive', + })} + + ))} + + + + {/* Row 3: Title + Description */} + + + Title + Description + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description text here', + })} + + ))} + + + + {/* Row 4: Full content - Title + Description + Detail + Subdetail */} + + + Full content (Title + Description + Detail + Subdetail) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + variant: 'positive', + })} + + ))} + + + + {/* Row 5: With Media */} + + + With Media (Avatar) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + variant: 'positive', + })} + + ))} + + + + {/* Row 6: With Media + Accessory */} + + + With Media + Accessory + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + accessory: 'arrow', + variant: 'positive', + })} + + ))} + + + + {/* Row 7: Pressable */} + + + Pressable (onClick) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'Title', + description: 'Description', + detail: '$12,345.00', + subdetail: '+1.23%', + media: , + accessory: 'arrow', + onClick: onClickConsole, + variant: 'positive', + })} + + ))} + + + + {/* Row 8: Long title - tests text wrapping at different heights */} + + + Long title (tests numberOfLines behavior) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { + title: 'This is a very long title that should wrap to multiple lines', + detail: '$12,345.00', + })} + + ))} + + + + {/* Row 9: Mixed list simulation - shows how lists look with varying content */} + + + Mixed content list (shows height inconsistency without min-height) + + + {spacingVariants.map((variant) => ( + + {renderCell(variant, { title: 'Short' })} + {renderCell(variant, { + title: 'With description', + description: 'Has more content', + })} + {renderCell(variant, { title: 'Short again' })} + {renderCell(variant, { + title: 'Full content', + description: 'Description here', + detail: '$100.00', + subdetail: '+5%', + variant: 'positive', + })} + + ))} + + + + ); +}; + export { + BorderCustomization, CompactContentDeprecated, CompactPressableContentDeprecated, CondensedListCell, @@ -1248,7 +1522,7 @@ export { LongContent, PressableContent, PriorityContent, - SpacingVariant, + SpacingVariantsComparison, UseCaseShowcase, WithAccessory, WithActions, diff --git a/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx b/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx index c38ff0c649..24ca54c248 100644 --- a/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx +++ b/packages/web/src/cells/__tests__/ContentCellFallback.test.tsx @@ -38,7 +38,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 0), width: 50, }, - {}, + undefined, ); }); @@ -55,7 +55,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 1), width: 45, }, - {}, + undefined, ); }); @@ -73,7 +73,7 @@ describe('ContentCellFallback', () => { paddingTop: 0.5, width: 35, }, - {}, + undefined, ); }); @@ -91,7 +91,7 @@ describe('ContentCellFallback', () => { rectWidthVariant: getRectWidthVariant(1, 3), width: 65, }, - {}, + undefined, ); }); }); diff --git a/packages/web/src/chips/ChipProps.ts b/packages/web/src/chips/ChipProps.ts index 55e7bb7488..30294b8355 100644 --- a/packages/web/src/chips/ChipProps.ts +++ b/packages/web/src/chips/ChipProps.ts @@ -1,8 +1,4 @@ -import type { - DimensionValue, - SharedAccessibilityProps, - SharedProps, -} from '@coinbase/cds-common/types'; +import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import type { PressableBaseProps, diff --git a/packages/web/src/coachmark/Coachmark.tsx b/packages/web/src/coachmark/Coachmark.tsx index 53d1ec2021..457325349d 100644 --- a/packages/web/src/coachmark/Coachmark.tsx +++ b/packages/web/src/coachmark/Coachmark.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, memo } from 'react'; -import { type DimensionValue, type SharedProps } from '@coinbase/cds-common'; +import { type SharedProps } from '@coinbase/cds-common'; import { IconButton } from '../buttons/IconButton'; import { useComponentConfig } from '../hooks/useComponentConfig'; @@ -43,7 +43,7 @@ export type CoachmarkBaseProps = SharedProps & /** * Desired width of the Coachmark with respect to max width of windowWidth - spacing2 * 2 */ - width?: DimensionValue; + width?: React.CSSProperties['width']; /** * a11y label of the close button */ @@ -71,7 +71,6 @@ export const Coachmark = memo( return ( {media} {!!onClose && ( diff --git a/packages/web/src/collapsible/Collapsible.tsx b/packages/web/src/collapsible/Collapsible.tsx index ea0edf2202..d9e0184eb0 100644 --- a/packages/web/src/collapsible/Collapsible.tsx +++ b/packages/web/src/collapsible/Collapsible.tsx @@ -109,16 +109,19 @@ export const Collapsible = memo( }, [visibility, motionStyle]); return ( + // TODO: Remove type assertion after upgrading framer-motion to v11+ for React 19 compatibility )} > ` wrapper element. */ + label: 'cds-Control-label', + /** Interactable icon wrapper element. */ + icon: 'cds-Control-icon', + /** Native input element. */ + input: 'cds-Control-input', +} as const; const pointerCss = css` &:not(:disabled), @@ -63,7 +76,14 @@ export type ControlBaseProps = FilteredHTMLAttribut Partial< Pick< InteractableBaseProps, - 'background' | 'borderColor' | 'borderRadius' | 'borderWidth' | 'color' | 'elevation' + | 'background' + | 'borderColor' + | 'borderRadius' + | 'borderWidth' + | 'color' + | 'elevation' + | 'className' + | 'style' > > & { /** Label for the control option. */ @@ -86,10 +106,11 @@ export type ControlBaseProps = FilteredHTMLAttribut labelStyle?: React.CSSProperties; }; -export type ControlProps = ControlBaseProps & { - label?: React.ReactNode; - children: React.ReactNode; -}; +export type ControlProps = ControlBaseProps & + StylesAndClassNames & { + label?: React.ReactNode; + children: React.ReactNode; + }; const ControlWithRef = forwardRef(function ControlWithRef( _props: ControlProps, @@ -116,6 +137,10 @@ const ControlWithRef = forwardRef(function ControlWithRef(); + const internalInputRef = useRef(undefined); const inputRef = useMergeRefs(ref, internalInputRef); const iconElement = useMemo( @@ -140,10 +165,10 @@ const ControlWithRef = forwardRef(function ControlWithRef {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */} @@ -153,12 +178,13 @@ const ControlWithRef = forwardRef(function ControlWithRef @@ -212,10 +242,29 @@ const ControlWithRef = forwardRef(function ControlWithRef ); - }, [label, iconElement, inputId, labelStyle, color, disabled, readOnly, labelId]); + }, [ + label, + iconElement, + inputId, + labelStyle, + color, + disabled, + readOnly, + labelId, + classNames?.label, + styles?.label, + ]); // If no label is provided, consumer should wrap the checkbox with diff --git a/packages/web/src/controls/InputIcon.tsx b/packages/web/src/controls/InputIcon.tsx index 101d285ccd..38998df44a 100644 --- a/packages/web/src/controls/InputIcon.tsx +++ b/packages/web/src/controls/InputIcon.tsx @@ -39,10 +39,10 @@ export const InputIcon = memo( return ( ); diff --git a/packages/web/src/controls/InputIconButton.tsx b/packages/web/src/controls/InputIconButton.tsx index be03a3713d..58e2562c10 100644 --- a/packages/web/src/controls/InputIconButton.tsx +++ b/packages/web/src/controls/InputIconButton.tsx @@ -15,7 +15,7 @@ export const variantTransformMap: Record = { negative: 'primary', foreground: 'primary', primary: 'primary', - foregroundMuted: 'foregroundMuted', + foregroundMuted: 'secondary', secondary: 'secondary', }; diff --git a/packages/web/src/controls/InputStack.tsx b/packages/web/src/controls/InputStack.tsx index 3081d0c944..2610e6cc79 100644 --- a/packages/web/src/controls/InputStack.tsx +++ b/packages/web/src/controls/InputStack.tsx @@ -148,7 +148,27 @@ export type InputStackProps = Omit< BoxProps, 'width' | 'height' | 'borderRadius' > & - InputStackBaseProps; + InputStackBaseProps & { + classNames?: { + /** Root container element */ + root?: string; + /** + * Input horizontal container. + * Contains the input node, inner label and start/end nodes. + */ + inputContainer?: string; + /** Interactable input element */ + input?: string; + }; + styles?: { + /** Root container element */ + root?: React.CSSProperties; + /** Input horizontal container element */ + inputContainer?: React.CSSProperties; + /** Interactable input element */ + input?: React.CSSProperties; + }; + }; export const InputStack = memo( forwardRef((_props, ref) => { @@ -175,6 +195,10 @@ export const InputStack = memo( labelVariant = 'outside', blendStyles, inputBackground = 'bg', + className, + style, + classNames, + styles, ...props } = mergedProps; const focusedVariant = useMemo( @@ -229,10 +253,14 @@ export const InputStack = memo( }; }, [borderColorUnfocused, borderColorFocused, focusedBorderWidth, inputBorderRadius]); + const rootStyles = useMemo(() => ({ style, ...styles?.root }), [style, styles?.root]); + return ( {labelNode} : labelNode)} {!!prependNode && <>{prependNode}} -
+
{!!focused && !!enableColorSurge && ( diff --git a/packages/web/src/controls/NativeInput.tsx b/packages/web/src/controls/NativeInput.tsx index 85231ba795..e3707d749d 100644 --- a/packages/web/src/controls/NativeInput.tsx +++ b/packages/web/src/controls/NativeInput.tsx @@ -80,6 +80,10 @@ const compactContainerPaddingCss = css` `; export type NativeInputBaseProps = BoxBaseProps & { + /** + * Decreases the padding within the input element + * @default false + */ compact?: boolean; /** Custom container spacing if needed. This will add to the existing spacing */ containerSpacing?: string; diff --git a/packages/web/src/controls/Radio.tsx b/packages/web/src/controls/Radio.tsx index c57ef7cf0e..0cdb67a5c5 100644 --- a/packages/web/src/controls/Radio.tsx +++ b/packages/web/src/controls/Radio.tsx @@ -18,11 +18,11 @@ import { Control, type ControlBaseProps } from './Control'; const DotSvg = ({ color = 'black', - width = 20, + width, dotSize = (2 * width) / 3, }: { color?: React.CSSProperties['color']; - width?: number; + width: number; dotSize?: number; }) => { return ( @@ -46,9 +46,9 @@ const baseCss = css` } &:focus-visible { outline-style: solid; - outline-width: 2px; + outline-width: var(--borderWidth-200); outline-color: var(--color-bgPrimary); - outline-offset: 2px; + outline-offset: var(--borderWidth-200); } `; diff --git a/packages/web/src/controls/SearchInput.tsx b/packages/web/src/controls/SearchInput.tsx index 64c7f66f78..f849a4ac9b 100644 --- a/packages/web/src/controls/SearchInput.tsx +++ b/packages/web/src/controls/SearchInput.tsx @@ -9,10 +9,14 @@ import { InputIcon } from './InputIcon'; import { InputIconButton } from './InputIconButton'; import { TextInput, type TextInputBaseProps, type TextInputProps } from './TextInput'; +/** + * @deprecated Use local constants or the `compact` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const scales = { regular: 56, compact: 40, -}; +} as const; export type SearchInputBaseProps = Pick< TextInputBaseProps, diff --git a/packages/web/src/controls/SegmentedControl.tsx b/packages/web/src/controls/SegmentedControl.tsx index 2e52dc0691..44214baf6b 100644 --- a/packages/web/src/controls/SegmentedControl.tsx +++ b/packages/web/src/controls/SegmentedControl.tsx @@ -1,6 +1,5 @@ -import React, { forwardRef, Fragment, memo, useCallback, useId, useState } from 'react'; +import React, { forwardRef, Fragment, memo, useCallback, useId, useMemo, useState } from 'react'; import type { ChangeEvent, ForwardedRef } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { IconName, IconSize, @@ -42,7 +41,6 @@ const containerCss = css` `; const labelCss = css` - height: ${interactableHeight.regular}px; padding-inline-start: ${checkmarkSize}px; padding-inline-end: ${checkmarkSize}px; @@ -229,6 +227,7 @@ function SegmentedControlInternal( export type SelectOptionProps = SelectOptionBaseProps; +/** + * @deprecated This component is deprecated along with old Select component. Please use the new Select alpha component instead. If you are using this component outside of Select, we recommend replacing it with ListCell. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const SelectOption = memo((_props: SelectOptionProps) => { const mergedProps = useComponentConfig('SelectOption', _props); const { diff --git a/packages/web/src/controls/Switch.tsx b/packages/web/src/controls/Switch.tsx index 2d429a6726..ab980ff525 100644 --- a/packages/web/src/controls/Switch.tsx +++ b/packages/web/src/controls/Switch.tsx @@ -4,14 +4,29 @@ import { switchTransitionConfig } from '@coinbase/cds-common/motion/switch'; import { css } from '@linaria/core'; import { m as motion } from 'framer-motion'; +import { cx } from '../cx'; import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout/Box'; import { convertTransition } from '../motion/utils'; +import type { StylesAndClassNames } from '../types'; import { Control, type ControlBaseProps } from './Control'; -const COMPONENT_STATIC_CLASSNAME = 'cds-Switch'; +/** + * Static class names for Switch component parts. + * Use these selectors to target specific elements with CSS. + */ +export const switchClassNames = { + /** Persistent outer wrapper across all variants. */ + root: 'cds-Switch', + /** Underlying `Control` wrapper element. */ + control: 'cds-Switch-control', + /** Track wrapper element. */ + track: 'cds-Switch-track', + /** Thumb wrapper element. */ + thumb: 'cds-Switch-thumb', +} as const; const trackCss = css` width: var(--controlSize-switchWidth); @@ -45,7 +60,18 @@ export type SwitchBaseProps = ControlBaseProps & { controlColor?: ThemeVars.Color; }; -export type SwitchProps = SwitchBaseProps; +export type SwitchProps = SwitchBaseProps & + StylesAndClassNames & { + /** + * Label content rendered next to the switch control. + * + * @example + * ```tsx + * Dark mode + * ``` + */ + children?: React.ReactNode; + }; const MotionBox = motion(Box); @@ -72,6 +98,10 @@ const SwitchWithRef = forwardRef( borderRadius = 1000, borderWidth, value, + className, + style, + classNames, + styles, ...props } = mergedProps; const { activeColorScheme } = useTheme(); @@ -81,9 +111,11 @@ const SwitchWithRef = forwardRef( ref={ref} borderRadius={1000} checked={checked} + className={cx(switchClassNames.control, classNames?.control)} disabled={disabled} label={children} role="switch" + style={{ ...style, ...styles?.control }} type="checkbox" value={value} {...props} @@ -94,19 +126,21 @@ const SwitchWithRef = forwardRef( borderColor={borderColor} borderRadius={borderRadius} borderWidth={borderWidth} - className={trackCss} + className={cx(trackCss, switchClassNames.track, classNames?.track)} data-filled={checked} justifyContent="flex-start" + style={styles?.track} testID="switch-track" > ( ); - return children ? ( + return ( {switchNode} - ) : ( - switchNode ); }, ); diff --git a/packages/web/src/controls/TextInput.tsx b/packages/web/src/controls/TextInput.tsx index d5ccb43d68..fa1ae7a16c 100644 --- a/packages/web/src/controls/TextInput.tsx +++ b/packages/web/src/controls/TextInput.tsx @@ -8,9 +8,9 @@ import React, { useState, } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import { usePrefixedId } from '@coinbase/cds-common/hooks/usePrefixedId'; import type { InputVariant, SharedInputProps } from '@coinbase/cds-common/types/InputBaseProps'; +import { mergeReactElementRef, mergeRefs } from '@coinbase/cds-common/utils/mergeRefs'; import { css } from '@linaria/core'; import { cx } from '../cx'; @@ -82,12 +82,15 @@ export type TextInputBaseProps = NativeInputBaseProps & | 'inputBackground' > & { /** - * Customize the element which the input area will be rendered as. Adds ability to render the input area - * as a `