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 new file mode 100644 index 0000000000..790e017302 --- /dev/null +++ b/.claude/skills/deprecate-cds-api/SKILL.md @@ -0,0 +1,151 @@ +--- +name: deprecate-cds-api +description: | + Deprecates a CDS component, hook, or other exported symbol with consistent JSDoc, version tags, + and docsite metadata across every public export path (web, mobile, common, visualization), not only + the original package. Use whenever the user asks to deprecate a CDS component or API, mark something + as deprecated, add @deprecated / @deprecationExpectedRemoval, or update deprecation warnings in + apps/docs metadata under components or hooks (webMetadata.json / mobileMetadata.json / metadata.json). + Also use when replacing a component or hook and sunsetting the old one. Always finish by running + `yarn nx run :lint` on modified packages so `internal/deprecated-jsdoc-has-removal-version` passes. +allowed-tools: Read, Grep, Glob, StrReplace, Bash(yarn nx run:*) +argument-hint: ' — replacement — [@deprecationExpectedRemoval major e.g. v10] — [optional notes]' +--- + +# Deprecate CDS public API + +Automate the standard CDS deprecation workflow for symbols exported from `packages/web`, `packages/mobile`, or `packages/common`. + +## Inputs to confirm first + +1. **What is being deprecated?** Component name, hook, prop, or other exported symbol. +2. **What should consumers use instead?** The replacement must be named in JSDoc and in docs `warning` text. +3. **Which major should `@deprecationExpectedRemoval` use?** (e.g. `v9`, `v10`.) **Ask the user to confirm** if they have not already stated it. If they want a default, **suggest** the next major from the relevant `package.json` (see Step 2) and confirm they accept it before editing. + +--- + +## Step 0 — Discover every public export (all packages) + +**Deprecate the symbol everywhere it is publicly reachable**, not only where it is first implemented. + +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). + +Do **not** skip a package because the symbol is “originally” defined elsewhere—if consumers can import it from that package, it must be deprecated there too. + +--- + +## Step 1 — JSDoc on the deprecated symbol + +Add or extend JSDoc immediately above the deprecated export (component, function, type alias, const, interface field, etc.). + +Use the **standard JSDoc tag `@deprecated`** (not `@deprecate`). + +**Required shape:** + +```ts +/** + * …existing description if any… + * + * @deprecated . This will be removed in a future major release. + * @deprecationExpectedRemoval v + */ +``` + +Rules: + +- The `@deprecated` line must end with exactly: `This will be removed in a future major release.` (same sentence as the rest of the deprecation message, as in existing CDS examples). +- `@deprecationExpectedRemoval` must match `v` + version (e.g. `v9` or `v9.0.0`; full semver is allowed by ESLint). + +The repo’s ESLint rule **`internal/deprecated-jsdoc-has-removal-version`** (`libs/eslint-plugin-internal`) enforces the prose ending and the presence of `@deprecationExpectedRemoval`; **lint must pass** after edits (see **Step 6**). + +--- + +## Step 2 — Removal version for `@deprecationExpectedRemoval` + +The tag must satisfy `@deprecationExpectedRemoval v…` as enforced by ESLint (e.g. `v10` or `v10.0.0`). + +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`**). +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`**. + +--- + +## Step 3 — Consistency across export surfaces + +- Use the **same** `@deprecated` guidance and **`@deprecationExpectedRemoval v`** value everywhere the symbol is exported (adjust wording only if a platform’s API genuinely differs). +- **Components** and **hooks** that exist as separate web and mobile implementations: deprecate **both** when both packages export the symbol. +- **Shared** symbols in `packages/common` that are **also** re-exported from web or mobile: follow Step 0 — ensure deprecation is visible on **every** public import path (common barrels **and** web/mobile re-exports if applicable). +- **Visualization** packages: same rules whenever those packages export the symbol. + +--- + +## Step 4 — Docsite metadata (`apps/docs/docs`) + +Only when the symbol has **existing** docs under the docs app. **Do not** add new doc folders unless the docs workflow already expects them. + +### Components (`apps/docs/docs/components/`) + +1. Locate the folder, e.g. `apps/docs/docs/components///`. +2. If present, edit **`webMetadata.json`** and/or **`mobileMetadata.json`** (some components have both; some only one). +3. Add or update the top-level **`warning`** string: + +```json +"warning": "This component is deprecated. Please use {replacement} instead." +``` + +Examples: + +- `"Please use Tabs instead."` +- `"Please use MediaCard instead."` + +Match the tone of existing deprecations when the replacement is not a single component name (e.g. “Use indeterminate ProgressCircle for loading indicators instead.”) — still keep the opening: **This component is deprecated.** + +### Hooks (`apps/docs/docs/hooks/`) + +1. Locate the hook folder, e.g. `apps/docs/docs/hooks//`. +2. Hooks may use **`webMetadata.json`** and **`mobileMetadata.json`**, or a single shared **`metadata.json`** — update whichever file(s) exist for that hook. +3. Add or update the top-level **`warning`** string using **hook** wording: + +```json +"warning": "This hook is deprecated. Please use {replacement} instead." +``` + +Use the same `{replacement}` phrasing as in JSDoc. If the replacement is not a single hook name, adapt the sentence but keep the opening: **This hook is deprecated.** + +--- + +## Step 5 — Verification checklist + +- [ ] Every **public export path** across packages that expose the symbol has been found (Step 0) and carries deprecation (implementation and re-exports as needed). +- [ ] `@deprecated` includes replacement guidance and the exact closing sentence about future major removal. +- [ ] **`@deprecationExpectedRemoval v`** matches the **confirmed** removal major (Step 2), not an unverified default. +- [ ] **Web + mobile** implementations and metadata (when applicable) are updated; nothing skipped because the symbol was “only” defined in common or another package. +- [ ] `warning` in metadata matches the replacement story: **this component is deprecated** for component docs, **this hook is deprecated** for hook docs (`apps/docs/docs/hooks/`). +- [ ] **`yarn nx run :lint`** has been run for every touched project (**Step 6**) and passes. + +--- + +## Step 6 — Run ESLint (required) + +After all edits, **run the `lint` target** on **every Nx project** that contains changed source files so **`internal/deprecated-jsdoc-has-removal-version`** (and the rest of the package lint config) passes. + +Use the workspace convention: + +```bash +yarn nx run :lint +``` + +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). + +--- + +## Reference examples in-repo + +- JSDoc: search for `@deprecationExpectedRemoval` under `packages/web/src` (e.g. `TabNavigation.tsx`, `Spinner.tsx`). +- ESLint: `libs/eslint-plugin-internal` — rule **`deprecated-jsdoc-has-removal-version`** (exposed as **`internal/deprecated-jsdoc-has-removal-version`** in the root `eslint.config.mjs`). +- Metadata: search for `"warning": "This component is deprecated` under `apps/docs/docs/components`; hook docs live under `apps/docs/docs/hooks/` (use **This hook is deprecated** for hook `warning` text). 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd1b790daa..96b502b939 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=mobile-app,test-expo --target=build --base=$NX_BASE --head=$NX_HEAD depcheck: name: Depcheck diff --git a/.github/workflows/figma.yml b/.github/workflows/figma.yml index 2c8bd3fde8..140bb8ba87 100644 --- a/.github/workflows/figma.yml +++ b/.github/workflows/figma.yml @@ -133,11 +133,11 @@ jobs: - name: Prepare Pages directory run: find temp/ -name "figma-audit-*.html" -exec cp {} temp/index.html \; - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 - name: Upload audit report - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: temp/ - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/guard-debug-workflow.yml b/.github/workflows/guard-debug-workflow.yml index 6c3c03c6f6..55bc04babc 100644 --- a/.github/workflows/guard-debug-workflow.yml +++ b/.github/workflows/guard-debug-workflow.yml @@ -20,11 +20,20 @@ on: # Manual trigger for testing workflow_dispatch: +permissions: + contents: read + jobs: test-local: runs-on: [small, default-config] steps: - - uses: actions/checkout@v4 + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + # Test the published action # - name: New CDS Action diff --git a/.github/workflows/illustrations-icons-checklist.yml b/.github/workflows/illustrations-icons-checklist.yml index 4eaa6af6f9..1e10a96bca 100644 --- a/.github/workflows/illustrations-icons-checklist.yml +++ b/.github/workflows/illustrations-icons-checklist.yml @@ -12,8 +12,13 @@ jobs: enforce: runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Verify checklist when illustrations/icons are modified - uses: actions/github-script@v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const { owner, repo } = context.repo; diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2ace162197..903d5717dd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -7,6 +7,9 @@ concurrency: group: PR-${{github.event.pull_request.number}} cancel-in-progress: true +permissions: + contents: read + jobs: labeler: name: 'Pull Request Labeler' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9b1522b844..74ca91226f 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -24,6 +24,9 @@ on: paths: - 'packages/*/package.json' +permissions: + contents: read + jobs: # Determine which packages to build/publish detect-changes: diff --git a/.github/workflows/visreg-mobile.yml b/.github/workflows/visreg-mobile.yml index 0c4ae2956e..ab3f4afd15 100644 --- a/.github/workflows/visreg-mobile.yml +++ b/.github/workflows/visreg-mobile.yml @@ -62,7 +62,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: | @@ -74,7 +74,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 @@ -84,7 +84,7 @@ jobs: - name: Upload Maestro test report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: maestro-report-ios path: | @@ -190,7 +190,7 @@ jobs: # - name: Upload Maestro debug artifacts # if: always() - # uses: actions/upload-artifact@v4 + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 # with: # name: maestro-debug-android # path: /tmp/maestro-debug/ @@ -211,6 +211,11 @@ jobs: if: always() && github.event_name == 'pull_request' && needs.ios.outputs.percy_url != '' runs-on: ubuntu-latest steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + - name: Post Percy link on PR env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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..a1c9fbd201 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,8 +55,6 @@ 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 diff --git a/apps/docs/ai-doc-generator/concatenate-docs.cjs b/apps/docs/ai-doc-generator/concatenate-docs.cjs new file mode 100644 index 0000000000..b88b824dcc --- /dev/null +++ b/apps/docs/ai-doc-generator/concatenate-docs.cjs @@ -0,0 +1,244 @@ +/** + * Concatenate all generated LLM docs into single comprehensive files + * + * Usage: node concatenate-docs.cjs [outputPath] + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { globSync } = require('glob'); + +const DEFAULT_OUTPUT_PATH = path.resolve(__dirname, '../dist/llms'); +const SLACKBOT_DOCS_DIR = 'slackbot-docs'; + +/** + * Read and concatenate all docs for a platform + */ +const concatenatePlatformDocs = (platform, outputPath) => { + const platformPath = path.join(outputPath, platform); + + if (!fs.existsSync(platformPath)) { + console.log(`Warning: ${platformPath} does not exist. Run generator.cjs first.`); + return; + } + + // First pass: collect all sections and their items for TOC + const tocSections = []; + + // Collect Getting Started items + const gettingStartedPath = path.join(platformPath, 'getting-started'); + if (fs.existsSync(gettingStartedPath)) { + const gettingStartedFiles = globSync('*.txt', { cwd: gettingStartedPath }); + const items = gettingStartedFiles.map((file) => { + const docName = path.basename(file, '.txt'); + return docName.charAt(0).toUpperCase() + docName.slice(1); + }); + + if (items.length > 0) { + tocSections.push({ title: 'Getting Started', items }); + } + } + + // Collect Components + const componentsPath = path.join(platformPath, 'components'); + if (fs.existsSync(componentsPath)) { + const componentFiles = globSync('*.txt', { cwd: componentsPath }).sort(); + const items = componentFiles.map((file) => path.basename(file, '.txt')); + + if (items.length > 0) { + tocSections.push({ title: 'Components', items }); + } + } + + // Collect Hooks + const hooksPath = path.join(platformPath, 'hooks'); + if (fs.existsSync(hooksPath)) { + const hookFiles = globSync('*.txt', { cwd: hooksPath }); + const items = hookFiles.map((file) => path.basename(file, '.txt')); + + if (items.length > 0) { + tocSections.push({ title: 'Hooks', items }); + } + } + + // Build header with TOC + let content = `# Coinbase Design System (CDS) - ${platform === 'web' ? 'Web' : 'React Native'} Documentation + +This is a comprehensive guide to all CDS components and features for ${platform === 'web' ? 'web' : 'React Native'} applications. + +**Generated from:** https://cds.coinbase.com +**Platform:** ${platform === 'web' ? 'Web (React)' : 'React Native'} +**Total Sections:** ${tocSections.length} + +--- + +# Table of Contents + +`; + + // Generate TOC + for (const section of tocSections) { + content += `## ${section.title}\n\n`; + for (const item of section.items) { + content += `- ${item}\n`; + } + content += '\n'; + } + + content += `---\n\n`; + + // Now add all the actual content + // Add Getting Started section + const docsDir = path.resolve(__dirname, '../docs'); + + if (fs.existsSync(gettingStartedPath)) { + content += `# Getting Started\n\n`; + + const gettingStartedFiles = globSync('*.txt', { cwd: gettingStartedPath }); + + for (const file of gettingStartedFiles) { + const filePath = path.join(gettingStartedPath, file); + const docContent = fs.readFileSync(filePath, 'utf-8'); + const docName = path.basename(file, '.txt'); + + content += `## ${docName.charAt(0).toUpperCase() + docName.slice(1)}\n\n`; + content += docContent; + content += '\n\n---\n\n'; + } + } + + // Add Components section + if (fs.existsSync(componentsPath)) { + content += `# Components\n\n`; + + const componentFiles = globSync('*.txt', { cwd: componentsPath }).sort(); + const categoriesDirs = globSync('components/*', { cwd: docsDir }); + + for (const file of componentFiles) { + const filePath = path.join(componentsPath, file); + const docContent = fs.readFileSync(filePath, 'utf-8'); + const componentName = path.basename(file, '.txt'); + + // Find the category for this component + let categoryName = ''; + for (const categoryDir of categoriesDirs) { + const componentPath = path.join(docsDir, categoryDir, componentName); + if (fs.existsSync(componentPath)) { + categoryName = path.basename(categoryDir); + break; + } + } + + content += `## ${componentName}\n\n`; + content += docContent; + content += '\n\n---\n\n'; + } + } + + // Add Hooks section + if (fs.existsSync(hooksPath)) { + content += `# Hooks\n\n`; + + const hookFiles = globSync('*.txt', { cwd: hooksPath }); + + for (const file of hookFiles) { + const filePath = path.join(hooksPath, file); + const docContent = fs.readFileSync(filePath, 'utf-8'); + const hookName = path.basename(file, '.txt'); + + content += `## ${hookName}\n\n`; + content += docContent; + content += '\n\n---\n\n'; + } + } + + // Add footer with stats + const stats = { + gettingStarted: fs.existsSync(gettingStartedPath) + ? globSync('*.txt', { cwd: gettingStartedPath }).length + : 0, + components: fs.existsSync(componentsPath) + ? globSync('*.txt', { cwd: componentsPath }).length + : 0, + hooks: fs.existsSync(hooksPath) ? globSync('*.txt', { cwd: hooksPath }).length : 0, + }; + + content += `--- + +# End of Documentation + +**Platform:** ${platform === 'web' ? 'Web (React)' : 'React Native'} + +**Contents:** +- ${stats.gettingStarted} Getting Started guides +- ${stats.components} Components +- ${stats.hooks} Hooks + +**Total Sections:** ${stats.gettingStarted + stats.components + stats.hooks} + +**Links:** +- Latest docs: https://cds.coinbase.com +- Interactive examples: https://cds-storybook.coinbase.com +- GitHub: https://github.com/coinbase/cds + +--- + +*This documentation is generated automatically from the CDS docs site.* +*For the most up-to-date information, visit https://cds.coinbase.com* +`; + + // Write concatenated file to slackbot-docs subdirectory + const slackbotDocsPath = path.join(outputPath, SLACKBOT_DOCS_DIR); + fs.mkdirSync(slackbotDocsPath, { recursive: true }); + + const outputFile = path.join(slackbotDocsPath, `${platform}-complete.md`); + fs.writeFileSync(outputFile, content); + + console.log(`✅ Generated ${platform}-complete.md`); + console.log(` - Size: ${(content.length / 1024).toFixed(2)} KB`); + console.log(` - Getting Started: ${stats.gettingStarted}`); + console.log(` - Components: ${stats.components}`); + console.log(` - Hooks: ${stats.hooks}`); + console.log(` - Path: ${outputFile}`); + + return outputFile; +}; + +/** + * Main function + */ +const main = () => { + console.log('📚 Concatenating CDS documentation for Google Drive/Glean...\n'); + + const outputPath = path.resolve(process.cwd(), process.argv[2] || DEFAULT_OUTPUT_PATH); + + if (!fs.existsSync(outputPath)) { + console.error(`❌ Error: Output path does not exist: ${outputPath}`); + console.error('Please run generator.cjs first to generate the individual docs.'); + process.exit(1); + } + + const platforms = ['web', 'mobile']; + const generatedFiles = []; + + for (const platform of platforms) { + const filePath = concatenatePlatformDocs(platform, outputPath); + if (filePath) { + generatedFiles.push(filePath); + } + } + + if (generatedFiles.length === 0) { + console.error( + '\n❌ No files were generated. Make sure dist/llms/{web,mobile} directories exist.', + ); + process.exit(1); + } + + console.log('\n✅ Done! Files ready for upload:\n'); + generatedFiles.forEach((file) => { + console.log(` 📄 ${path.basename(file)}`); + }); +}; + +main(); diff --git a/apps/docs/ai-doc-generator/generate-site-directory.cjs b/apps/docs/ai-doc-generator/generate-site-directory.cjs new file mode 100644 index 0000000000..c5dc7ae248 --- /dev/null +++ b/apps/docs/ai-doc-generator/generate-site-directory.cjs @@ -0,0 +1,254 @@ +/** + * Generate a directory file with links to the live CDS docs site + * + * Usage: node generate-site-directory.cjs [outputPath] + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { globSync } = require('glob'); + +const DEFAULT_OUTPUT_PATH = path.resolve(__dirname, '../dist/llms'); +const SLACKBOT_DOCS_DIR = 'slackbot-docs'; +const BASE_URL = 'https://cds.coinbase.com'; + +/** + * Get metadata for a doc to extract description + */ +const getMetadata = (dirPath, platform) => { + // Try platform-specific metadata first + const platformMetadataFile = path.join(dirPath, `${platform}Metadata.json`); + if (fs.existsSync(platformMetadataFile)) { + return JSON.parse(fs.readFileSync(platformMetadataFile, 'utf-8')); + } + + // Fall back to shared metadata + const sharedMetadataFile = path.join(dirPath, 'metadata.json'); + if (fs.existsSync(sharedMetadataFile)) { + return JSON.parse(fs.readFileSync(sharedMetadataFile, 'utf-8')); + } + + return null; +}; + +/** + * Check if a component supports multiple platforms + * A component is multi-platform if it has both web and mobile metadata files + */ +const isMultiPlatform = (dirPath) => { + const hasWebMetadata = fs.existsSync(path.join(dirPath, 'webMetadata.json')); + const hasMobileMetadata = fs.existsSync(path.join(dirPath, 'mobileMetadata.json')); + return hasWebMetadata && hasMobileMetadata; +}; + +/** + * Generate docs site URL from relative path + * @param {string} docPath - Path to the doc + * @param {string} platform - 'web' or 'mobile' + * @param {string} docsDir - Base docs directory + * @returns {string} - Full URL + */ +const generateDocsUrl = (docPath, platform, docsDir) => { + const relativePath = path.relative(docsDir, docPath); + // Remove .mdx extension if present (for standalone files) + const urlPath = relativePath + .replace(/\.mdx$/, '') + .split(path.sep) + .join('/'); + let url = `${BASE_URL}/${urlPath}/`; + + if (platform === 'mobile' && isMultiPlatform(docPath)) { + url += '?platform=mobile'; + } + + return url; +}; + +/** + * Generate directory for a platform + */ +const generatePlatformDirectory = (platform, outputPath) => { + const docsDir = path.resolve(__dirname, '../docs'); + + let content = `# CDS ${platform === 'web' ? 'Web' : 'React Native'} Components Directory + +Quick reference to all CDS components and their documentation on the live site. + +**Base URL:** ${BASE_URL} +**Platform:** ${platform === 'web' ? 'Web (React)' : 'React Native'} + +--- + +`; + + // Add Getting Started section + content += `## Getting Started\n\n`; + + const gettingStartedDocs = globSync('getting-started/*', { cwd: docsDir }); + for (const docPath of gettingStartedDocs) { + const fullPath = path.join(docsDir, docPath); + const name = path.basename(docPath, '.mdx'); + + let description = ''; + + // Try to get description from metadata + const metadata = getMetadata(fullPath, platform); + if (metadata?.description) { + description = metadata.description; + } else if (fs.statSync(fullPath).isFile()) { + // For standalone MDX files, try to extract description from ContentHeader + const fileContent = fs.readFileSync(fullPath, 'utf-8'); + const descMatch = fileContent.match(/description=["']([^"']+)["']/); + if (descMatch) { + description = descMatch[1]; + } + } + + const url = generateDocsUrl(fullPath, platform, docsDir); + + content += `- [${name}](${url})`; + if (description) { + content += `: ${description}`; + } + content += '\n'; + } + + content += '\n---\n\n'; + + // Add Components section grouped by category + content += `## Components\n\n`; + + const categoriesDirs = globSync('components/*', { cwd: docsDir }); + const componentsByCategory = new Map(); + + // Group components by category + for (const categoryDir of categoriesDirs) { + const categoryName = path.basename(categoryDir); + const components = globSync(`${categoryDir}/*/`, { cwd: docsDir }); + + const categoryComponents = []; + + for (const componentPath of components) { + const fullPath = path.join(docsDir, componentPath); + const componentName = path.basename(componentPath); + const metadata = getMetadata(fullPath, platform); + + // Skip if no metadata for this platform + if (!metadata) continue; + + const description = metadata?.description || ''; + const url = generateDocsUrl(fullPath, platform, docsDir); + + categoryComponents.push({ + name: componentName, + url, + description, + }); + } + + if (categoryComponents.length > 0) { + componentsByCategory.set(categoryName, categoryComponents); + } + } + + // Output by category + for (const [categoryName, components] of componentsByCategory) { + content += `### ${categoryName.charAt(0).toUpperCase() + categoryName.slice(1)}\n\n`; + + for (const component of components) { + content += `- [${component.name}](${component.url})`; + if (component.description) { + content += `: ${component.description}`; + } + content += '\n'; + } + + content += '\n'; + } + + content += '---\n\n'; + + // Add Hooks section + const hooksExist = fs.existsSync(path.join(docsDir, 'hooks')); + if (hooksExist) { + content += `## Hooks\n\n`; + + const hooks = globSync('hooks/*', { cwd: docsDir }); + for (const hookPath of hooks) { + const fullPath = path.join(docsDir, hookPath); + const name = path.basename(hookPath); + const metadata = getMetadata(fullPath, platform); + const description = metadata?.description || ''; + + const url = generateDocsUrl(fullPath, platform, docsDir); + + content += `- [${name}](${url})`; + if (description) { + content += `: ${description}`; + } + content += '\n'; + } + + content += '\n---\n\n'; + } + + // Add footer + content += `## Additional Resources + +- **Documentation Home:** ${BASE_URL} +- **Storybook:** https://cds-storybook.coinbase.com +- **GitHub:** https://github.com/coinbase/cds +- **LLM API Endpoints:** + - Routes index: ${BASE_URL}/llms/${platform}/routes.txt + - Component docs: ${BASE_URL}/llms/${platform}/components/{ComponentName}.txt + +--- + +*Last generated: ${new Date().toISOString()}* +`; + + // Write to slackbot-docs directory + const slackbotDocsPath = path.join(outputPath, SLACKBOT_DOCS_DIR); + fs.mkdirSync(slackbotDocsPath, { recursive: true }); + + const outputFile = path.join(slackbotDocsPath, `${platform}-directory.md`); + fs.writeFileSync(outputFile, content); + + const componentCount = Array.from(componentsByCategory.values()).reduce( + (sum, comps) => sum + comps.length, + 0, + ); + + console.log(`✅ Generated ${platform}-directory.md`); + console.log(` - Categories: ${componentsByCategory.size}`); + console.log(` - Components: ${componentCount}`); + console.log(` - Path: ${outputFile}`); + + return outputFile; +}; + +/** + * Main function + */ +const main = () => { + console.log('🔗 Generating CDS site directory with live links...\n'); + + const outputPath = path.resolve(process.cwd(), process.argv[2] || DEFAULT_OUTPUT_PATH); + + const platforms = ['web', 'mobile']; + const generatedFiles = []; + + for (const platform of platforms) { + const filePath = generatePlatformDirectory(platform, outputPath); + if (filePath) { + generatedFiles.push(filePath); + } + } + + console.log('\n✅ Done! Directory files generated:\n'); + generatedFiles.forEach((file) => { + console.log(` 📄 ${path.basename(file)}`); + }); +}; + +main(); diff --git a/apps/docs/ai-doc-generator/generateDoc.cjs b/apps/docs/ai-doc-generator/generateDoc.cjs index c3870f3fa5..e9388e520a 100644 --- a/apps/docs/ai-doc-generator/generateDoc.cjs +++ b/apps/docs/ai-doc-generator/generateDoc.cjs @@ -68,6 +68,41 @@ const getMetadata = (dirPath, platform) => { return null; }; +/** + * Check if a component supports multiple platforms + */ +const isMultiPlatform = (dirPath) => { + const hasWebMetadata = fs.existsSync(path.join(dirPath, 'webMetadata.json')); + const hasMobileMetadata = fs.existsSync(path.join(dirPath, 'mobileMetadata.json')); + return hasWebMetadata && hasMobileMetadata; +}; + +/** + * Generate the docs site URL for a component/guide + * @param {string} docPath - Path to the doc directory + * @param {string} platform - 'web' or 'mobile' + * @param {string} docsDir - Base docs directory + * @returns {string} - Full URL to the live docs + */ +const generateDocsUrl = (docPath, platform, docsDir) => { + const BASE_URL = 'https://cds.coinbase.com'; + const relativePath = path.relative(docsDir, docPath); + + // Remove .mdx extension if present (for standalone files) and convert to URL path + const urlPath = relativePath + .replace(/\.mdx$/, '') + .split(path.sep) + .join('/'); + let url = `${BASE_URL}/${urlPath}/`; + + // Add platform query param for mobile multi-platform docs + if (platform === 'mobile' && isMultiPlatform(docPath)) { + url += '?platform=mobile'; + } + + return url; +}; + /** * Get props table content for components * @param {string} dirPath - Component directory @@ -233,7 +268,12 @@ const generateDoc = (platform, docPath, options = {}) => { // Handle standalone files (e.g., introduction.mdx, playground.mdx) if (!fs.statSync(docPath).isDirectory()) { - return fs.readFileSync(docPath, 'utf-8'); + const docsDir = path.resolve(__dirname, '../docs'); + const content = fs.readFileSync(docPath, 'utf-8'); + const docsUrl = generateDocsUrl(docPath, platform, docsDir); + + // Add live docs URL at the top + return `**📖 Live documentation:** ${docsUrl}\n\n${content}`; } // For directories, check what type of doc this is by what files exist @@ -245,9 +285,16 @@ const generateDoc = (platform, docPath, options = {}) => { return null; } + // Get the docs directory for URL generation + const docsDir = path.resolve(__dirname, '../docs'); + // Build the document sections let content = `# ${name}\n\n`; + // Add live docs URL + const docsUrl = generateDocsUrl(docPath, platform, docsDir); + content += `**📖 Live documentation:** ${docsUrl}\n\n`; + if (metadata.description) { content += `${metadata.description}\n\n`; } 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 + + @@ -56,7 +66,34 @@ Use transparent buttons for supplementary actions with lower prominence. The con ### Loading -Use the `loading` prop to indicate an action is in progress. The button becomes non-interactive and displays a spinner while preserving its width. +Use the `loading` prop to indicate an action is in progress. The button becomes non-interactive and displays a loading indicator (indeterminate [ProgressCircle](/components/feedback/ProgressCircle)) while preserving its width. + +#### Loading by variant + +Loading works with all variants and transparent. The label is hidden and the progress circle is shown in the button’s accent color. + +```jsx + + + + + + + + +``` + +#### Basic loading ```jsx diff --git a/apps/docs/docs/components/inputs/Button/_webExamples.mdx b/apps/docs/docs/components/inputs/Button/_webExamples.mdx index 29e60288d2..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 + @@ -59,7 +66,36 @@ Use transparent buttons for supplementary actions with lower prominence. The con ### Loading -Use the `loading` prop to indicate an action is in progress. The button becomes non-interactive and displays a spinner while preserving its width. +Use the `loading` prop to indicate an action is in progress. The button becomes non-interactive and displays a loading indicator (indeterminate [ProgressCircle](/components/feedback/ProgressCircle)) while preserving its width. + +#### Loading by variant + +Loading works with all variants and transparent. The label is hidden and the progress circle is shown in the button’s accent color. + +```jsx live + + + + + + + + +``` + +#### Interactive loading + +Toggle loading state to see the transition. Use for async actions like save or submit. ```jsx live function LoadingExample() { 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 e9e1a685f6..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,21 +70,71 @@ Use the `transparent` prop to remove the background until the user interacts wit transparent onClick={console.log} /> + +``` + +## States + +### Loading + +Use the `loading` prop when an action is in progress. The button becomes non-interactive and shows an indeterminate [ProgressCircle](/components/feedback/ProgressCircle) instead of the icon. The circle size follows the button’s `iconSize`. + +#### Loading by variant + +Loading works with all variants, transparent, and compact. Provide `accessibilityLabel` so screen readers announce the loading state (e.g. "Loading"). + +```jsx live + + + + + + + ``` -## States - -### Loading +#### Interactive loading -Use the `loading` prop to show a spinner when an action is in progress. The button becomes non-interactive and displays a loading spinner instead of the icon. +Toggle loading to simulate an async action. The button’s `accessibilityLabel` can reflect the state (e.g. "Submit form" vs "Processing submission"). ```jsx live function LoadingExample() { @@ -144,13 +188,6 @@ Use the `disabled` prop to prevent interaction and show a disabled visual state. disabled onClick={console.log} /> - ``` @@ -200,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 322496548a..749f4f45f4 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -15,7 +15,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 851ba23c75..07c03025ec 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/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/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/project.json b/apps/docs/project.json index 3cbc945cd4..856e15b93d 100644 --- a/apps/docs/project.json +++ b/apps/docs/project.json @@ -99,6 +99,21 @@ "cwd": "{workspaceRoot}" } }, + "concatenate-llm-docs": { + "command": "node {projectRoot}/ai-doc-generator/concatenate-docs.cjs", + "dependsOn": [ + "build-llm-docs" + ], + "options": { + "cwd": "{workspaceRoot}" + } + }, + "generate-site-directory": { + "command": "node {projectRoot}/ai-doc-generator/generate-site-directory.cjs", + "options": { + "cwd": "{workspaceRoot}" + } + }, "peer-dependencies": { "command": "tsx {projectRoot}/utils/generateComponentPeerDeps.ts", "options": { 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 0aac8b70a8..eb934a7264 100644 --- a/apps/docs/src/theme/ReactLiveScope/index.tsx +++ b/apps/docs/src/theme/ReactLiveScope/index.tsx @@ -87,8 +87,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 = { @@ -121,8 +121,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/package.json b/apps/mobile-app/package.json index 30263a96ad..7b8ba20b6b 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -16,7 +16,6 @@ "@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", @@ -25,42 +24,39 @@ "@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/native": "6.1.17", + "@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", + "@shopify/react-native-skia": "2.2.12", + "expo": "54.0.32", + "expo-application": "~7.0.8", + "expo-asset": "12.0.12", + "expo-build-properties": "1.0.10", + "expo-clipboard": "8.0.8", + "expo-dev-client": "6.0.20", + "expo-font": "14.0.11", + "expo-linking": "~8.0.11", + "expo-quick-actions": "6.0.1", + "expo-splash-screen": "31.0.13", + "expo-status-bar": "3.0.9", + "expo-system-ui": "~6.0.9", "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", + "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-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-screens": "4.16.0", + "react-native-svg": "15.12.1", + "react-native-worklets": "0.5.2" }, "devDependencies": { - "@babel/core": "^7.28.0", - "@expo/config": "~9.0.0", - "@expo/config-types": "~51.0.2", - "@types/react": "^18.3.12", + "@expo/config-types": "54.0.10", + "@types/react": "19.1.2", "babel-plugin-transform-inline-environment-variables": "^0.4.4", "detox": "^20.14.8", "jest": "^29.7.0", diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index a071d79cfa..d80ead566f 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -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, }, { @@ -336,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', @@ -346,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, }, { @@ -512,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, }, { @@ -553,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, }, { @@ -574,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, }, { @@ -618,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/src/App.tsx b/apps/mobile-app/src/App.tsx index 901d982d8b..578224ae56 100644 --- a/apps/mobile-app/src/App.tsx +++ b/apps/mobile-app/src/App.tsx @@ -7,7 +7,7 @@ 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 { ChartBridgeProvider } from '@coinbase/cds-mobile/visualizations/chart'; import { Playground } from '@coinbase/ui-mobile-playground'; import { CommonActions, NavigationContainer } from '@react-navigation/native'; import * as Linking from 'expo-linking'; diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index a071d79cfa..d80ead566f 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/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, }, { @@ -336,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', @@ -346,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, }, { @@ -512,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, }, { @@ -553,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, }, { @@ -574,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, }, { @@ -618,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/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 e7c181609d..f967636a03 100644 --- a/apps/storybook/scripts/shouldRunVisreg.mjs +++ b/apps/storybook/scripts/shouldRunVisreg.mjs @@ -1,6 +1,6 @@ import { spawnSync } from 'node:child_process'; -const WEB_PACKAGES = ['common', 'web', 'web-visualization', 'icons', 'illustrations']; +const WEB_PACKAGES = ['common', 'web', 'icons', 'illustrations']; const RELEVANT_ROOTS = [ 'apps/storybook', ...WEB_PACKAGES.map((packageName) => `packages/${packageName}`), diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index fc206dcf66..b5e636188a 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -24,9 +24,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/test-expo/App.tsx b/apps/test-expo/App.tsx new file mode 100644 index 0000000000..9ba7f3ebc8 --- /dev/null +++ b/apps/test-expo/App.tsx @@ -0,0 +1,81 @@ +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'; +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/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 './src/hooks/useFonts'; +import { Playground } from './src/playground'; +import { routes as codegenRoutes } from './src/routes'; + +const linking = { + prefixes: [Linking.createURL('/')], + getStateFromPath: (path: string) => ({ + routes: [{ name: path.replace(/^\//, '') }], + }), + // Reset the navigation stack on every deep link so that any modals or overlays + // open on the previous screen are fully unmounted before the new route mounts. + // Without this, React Navigation's default getActionFromState dispatches a + // `navigate` (push) action, leaving the previous screen mounted and its modal + // state intact. + // The home screen (DebugExamples) is always prepended so there is always a + // route to go back to, keeping the back button visible. + getActionFromState: (state: { routes: { name: string }[] }) => + CommonActions.reset({ index: 1, routes: [{ name: 'DebugExamples' }, ...state.routes] }), +}; + +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 App = memo(() => { + const [fontsLoaded] = useFonts(); + const [colorScheme, setColorScheme] = useState('light'); + + React.useEffect(() => { + if (fontsLoaded) { + SplashScreen.hideAsync(); + } + }, [fontsLoaded]); + + if (!fontsLoaded) { + return null; + } + + return ( + + + + + + + + + + + ); +}); + +export default App; 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/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/test-expo/polyfills/intl.ts b/apps/test-expo/polyfills/intl.ts new file mode 100644 index 0000000000..1e5e037308 --- /dev/null +++ b/apps/test-expo/polyfills/intl.ts @@ -0,0 +1,6 @@ +import '@formatjs/intl-getcanonicallocales/polyfill'; +import '@formatjs/intl-locale/polyfill'; +import '@formatjs/intl-pluralrules/polyfill'; +import '@formatjs/intl-numberformat/polyfill'; +import '@formatjs/intl-pluralrules/locale-data/en'; +import '@formatjs/intl-numberformat/locale-data/en'; diff --git a/apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz b/apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz new file mode 100644 index 0000000000..f2ee0339c6 Binary files /dev/null and b/apps/test-expo/prebuilds/ios-debug/testexpo.tar.gz differ diff --git a/apps/test-expo/prebuilds/ios-release/testexpo.tar.gz b/apps/test-expo/prebuilds/ios-release/testexpo.tar.gz new file mode 100644 index 0000000000..6ae611f077 Binary files /dev/null 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/apps/test-expo/src/__generated__/iconSvgMap.ts b/apps/test-expo/src/__generated__/iconSvgMap.ts new file mode 100644 index 0000000000..14cedc3187 --- /dev/null +++ b/apps/test-expo/src/__generated__/iconSvgMap.ts @@ -0,0 +1,3214 @@ +/** + * 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 + */ + +export const svgMap: Record = { + 'account-12-active': { content: "" }, + 'account-12-inactive': { content: "" }, + 'account-16-active': { content: "" }, + 'account-16-inactive': { content: "" }, + 'account-24-active': { content: "" }, + 'account-24-inactive': { content: "" }, + 'activity-12-active': { content: "" }, + 'activity-12-inactive': { content: "" }, + 'activity-16-active': { content: "" }, + 'activity-16-inactive': { content: "" }, + 'activity-24-active': { content: "" }, + 'activity-24-inactive': { content: "" }, + 'add-12-active': { content: "" }, + 'add-12-inactive': { content: "" }, + 'add-16-active': { content: "" }, + 'add-16-inactive': { content: "" }, + 'add-24-active': { content: "" }, + 'add-24-inactive': { content: "" }, + 'addPeople-12-active': { content: "" }, + 'addPeople-12-inactive': { content: "" }, + 'addPeople-16-active': { content: "" }, + 'addPeople-16-inactive': { content: "" }, + 'addPeople-24-active': { content: "" }, + 'addPeople-24-inactive': { content: "" }, + 'advancedMarketSelector-12-active': { content: "" }, + 'advancedMarketSelector-12-inactive': { content: "" }, + 'advancedMarketSelector-16-active': { content: "" }, + 'advancedMarketSelector-16-inactive': { content: "" }, + 'advancedMarketSelector-24-active': { content: "" }, + 'advancedMarketSelector-24-inactive': { content: "" }, + 'advancedTradeProduct-12-active': { content: "" }, + 'advancedTradeProduct-12-inactive': { content: "" }, + 'advancedTradeProduct-16-active': { content: "" }, + 'advancedTradeProduct-16-inactive': { content: "" }, + 'advancedTradeProduct-24-active': { content: "" }, + 'advancedTradeProduct-24-inactive': { content: "" }, + 'affiliates-12-active': { content: "" }, + 'affiliates-12-inactive': { content: "" }, + 'affiliates-16-active': { content: "" }, + 'affiliates-16-inactive': { content: "" }, + 'affiliates-24-active': { content: "" }, + 'affiliates-24-inactive': { content: "" }, + 'airdrop-12-active': { content: "" }, + 'airdrop-12-inactive': { content: "" }, + 'airdrop-16-active': { content: "" }, + 'airdrop-16-inactive': { content: "" }, + 'airdrop-24-active': { content: "" }, + 'airdrop-24-inactive': { content: "" }, + 'airdropAlt-12-active': { content: "" }, + 'airdropAlt-12-inactive': { content: "" }, + 'airdropAlt-16-active': { content: "" }, + 'airdropAlt-16-inactive': { content: "" }, + 'airdropAlt-24-active': { content: "" }, + 'airdropAlt-24-inactive': { content: "" }, + 'airdropCoins-12-active': { content: "" }, + 'airdropCoins-12-inactive': { content: "" }, + 'airdropCoins-16-active': { content: "" }, + 'airdropCoins-16-inactive': { content: "" }, + 'airdropCoins-24-active': { content: "" }, + 'airdropCoins-24-inactive': { content: "" }, + 'airdropParachute-12-active': { content: "" }, + 'airdropParachute-12-inactive': { content: "" }, + 'airdropParachute-16-active': { content: "" }, + 'airdropParachute-16-inactive': { content: "" }, + 'airdropParachute-24-active': { content: "" }, + 'airdropParachute-24-inactive': { content: "" }, + 'alien-12-active': { content: "" }, + 'alien-12-inactive': { content: "" }, + 'alien-16-active': { content: "" }, + 'alien-16-inactive': { content: "" }, + 'alien-24-active': { content: "" }, + 'alien-24-inactive': { content: "" }, + 'allocation-12-active': { content: "" }, + 'allocation-12-inactive': { content: "" }, + 'allocation-16-active': { content: "" }, + 'allocation-16-inactive': { content: "" }, + 'allocation-24-active': { content: "" }, + 'allocation-24-inactive': { content: "" }, + 'allTimeHigh-12-active': { content: "" }, + 'allTimeHigh-12-inactive': { content: "" }, + 'allTimeHigh-16-active': { content: "" }, + 'allTimeHigh-16-inactive': { content: "" }, + 'allTimeHigh-24-active': { content: "" }, + 'allTimeHigh-24-inactive': { content: "" }, + 'annotation-12-active': { content: "" }, + 'annotation-12-inactive': { content: "" }, + 'annotation-16-active': { content: "" }, + 'annotation-16-inactive': { content: "" }, + 'annotation-24-active': { content: "" }, + 'annotation-24-inactive': { content: "" }, + 'api-12-active': { content: "" }, + 'api-12-inactive': { content: "" }, + 'api-16-active': { content: "" }, + 'api-16-inactive': { content: "" }, + 'api-24-active': { content: "" }, + 'api-24-inactive': { content: "" }, + 'apiPlug-12-active': { content: "" }, + 'apiPlug-12-inactive': { content: "" }, + 'apiPlug-16-active': { content: "" }, + 'apiPlug-16-inactive': { content: "" }, + 'apiPlug-24-active': { content: "" }, + 'apiPlug-24-inactive': { content: "" }, + 'apothecary-12-active': { content: "" }, + 'apothecary-12-inactive': { content: "" }, + 'apothecary-16-active': { content: "" }, + 'apothecary-16-inactive': { content: "" }, + 'apothecary-24-active': { content: "" }, + 'apothecary-24-inactive': { content: "" }, + 'apple-12-active': { content: "" }, + 'apple-12-inactive': { content: "" }, + 'apple-16-active': { content: "" }, + 'apple-16-inactive': { content: "" }, + 'apple-24-active': { content: "" }, + 'apple-24-inactive': { content: "" }, + 'appleLogo-12-active': { content: "" }, + 'appleLogo-12-inactive': { content: "" }, + 'appleLogo-16-active': { content: "" }, + 'appleLogo-16-inactive': { content: "" }, + 'appleLogo-24-active': { content: "" }, + 'appleLogo-24-inactive': { content: "" }, + 'application-12-active': { content: "" }, + 'application-12-inactive': { content: "" }, + 'application-16-active': { content: "" }, + 'application-16-inactive': { content: "" }, + 'application-24-active': { content: "" }, + 'application-24-inactive': { content: "" }, + 'appSwitcher-12-active': { content: "" }, + 'appSwitcher-12-inactive': { content: "" }, + 'appSwitcher-16-active': { content: "" }, + 'appSwitcher-16-inactive': { content: "" }, + 'appSwitcher-24-active': { content: "" }, + 'appSwitcher-24-inactive': { content: "" }, + 'arrowDown-12-active': { content: "" }, + 'arrowDown-12-inactive': { content: "" }, + 'arrowDown-16-active': { content: "" }, + 'arrowDown-16-inactive': { content: "" }, + 'arrowDown-24-active': { content: "" }, + 'arrowDown-24-inactive': { content: "" }, + 'arrowLeft-12-active': { content: "" }, + 'arrowLeft-12-inactive': { content: "" }, + 'arrowLeft-16-active': { content: "" }, + 'arrowLeft-16-inactive': { content: "" }, + 'arrowLeft-24-active': { content: "" }, + 'arrowLeft-24-inactive': { content: "" }, + 'arrowRight-12-active': { content: "" }, + 'arrowRight-12-inactive': { content: "" }, + 'arrowRight-16-active': { content: "" }, + 'arrowRight-16-inactive': { content: "" }, + 'arrowRight-24-active': { content: "" }, + 'arrowRight-24-inactive': { content: "" }, + 'arrowsHorizontal-12-active': { content: "" }, + 'arrowsHorizontal-12-inactive': { content: "" }, + 'arrowsHorizontal-16-active': { content: "" }, + 'arrowsHorizontal-16-inactive': { content: "" }, + 'arrowsHorizontal-24-active': { content: "" }, + 'arrowsHorizontal-24-inactive': { content: "" }, + 'arrowsUpDown-12-active': { content: "" }, + 'arrowsUpDown-12-inactive': { content: "" }, + 'arrowsUpDown-16-active': { content: "" }, + 'arrowsUpDown-16-inactive': { content: "" }, + 'arrowsUpDown-24-active': { content: "" }, + 'arrowsUpDown-24-inactive': { content: "" }, + 'arrowsVertical-12-active': { content: "" }, + 'arrowsVertical-12-inactive': { content: "" }, + 'arrowsVertical-16-active': { content: "" }, + 'arrowsVertical-16-inactive': { content: "" }, + 'arrowsVertical-24-active': { content: "" }, + 'arrowsVertical-24-inactive': { content: "" }, + 'arrowUp-12-active': { content: "" }, + 'arrowUp-12-inactive': { content: "" }, + 'arrowUp-16-active': { content: "" }, + 'arrowUp-16-inactive': { content: "" }, + 'arrowUp-24-active': { content: "" }, + 'arrowUp-24-inactive': { content: "" }, + 'artwork-12-active': { content: "" }, + 'artwork-12-inactive': { content: "" }, + 'artwork-16-active': { content: "" }, + 'artwork-16-inactive': { content: "" }, + 'artwork-24-active': { content: "" }, + 'artwork-24-inactive': { content: "" }, + 'assetHubProduct-12-active': { content: "" }, + 'assetHubProduct-12-inactive': { content: "" }, + 'assetHubProduct-16-active': { content: "" }, + 'assetHubProduct-16-inactive': { content: "" }, + 'assetHubProduct-24-active': { content: "" }, + 'assetHubProduct-24-inactive': { content: "" }, + 'assetManagementProduct-12-active': { content: "" }, + 'assetManagementProduct-12-inactive': { content: "" }, + 'assetManagementProduct-16-active': { content: "" }, + 'assetManagementProduct-16-inactive': { content: "" }, + 'assetManagementProduct-24-active': { content: "" }, + 'assetManagementProduct-24-inactive': { content: "" }, + 'astronautHelmet-12-active': { content: "" }, + 'astronautHelmet-12-inactive': { content: "" }, + 'astronautHelmet-16-active': { content: "" }, + 'astronautHelmet-16-inactive': { content: "" }, + 'astronautHelmet-24-active': { content: "" }, + 'astronautHelmet-24-inactive': { content: "" }, + 'atomScience-12-active': { content: "" }, + 'atomScience-12-inactive': { content: "" }, + 'atomScience-16-active': { content: "" }, + 'atomScience-16-inactive': { content: "" }, + 'atomScience-24-active': { content: "" }, + 'atomScience-24-inactive': { content: "" }, + 'atSign-12-active': { content: "" }, + 'atSign-12-inactive': { content: "" }, + 'atSign-16-active': { content: "" }, + 'atSign-16-inactive': { content: "" }, + 'atSign-24-active': { content: "" }, + 'atSign-24-inactive': { content: "" }, + 'auto-12-active': { content: "" }, + 'auto-12-inactive': { content: "" }, + 'auto-16-active': { content: "" }, + 'auto-16-inactive': { content: "" }, + 'auto-24-active': { content: "" }, + 'auto-24-inactive': { content: "" }, + 'autoCar-12-active': { content: "" }, + 'autoCar-12-inactive': { content: "" }, + 'autoCar-16-active': { content: "" }, + 'autoCar-16-inactive': { content: "" }, + 'autoCar-24-active': { content: "" }, + 'autoCar-24-inactive': { content: "" }, + 'avatar-12-active': { content: "" }, + 'avatar-12-inactive': { content: "" }, + 'avatar-16-active': { content: "" }, + 'avatar-16-inactive': { content: "" }, + 'avatar-24-active': { content: "" }, + 'avatar-24-inactive': { content: "" }, + 'average-12-active': { content: "" }, + 'average-12-inactive': { content: "" }, + 'average-16-active': { content: "" }, + 'average-16-inactive': { content: "" }, + 'average-24-active': { content: "" }, + 'average-24-inactive': { content: "" }, + 'backArrow-12-active': { content: "" }, + 'backArrow-12-inactive': { content: "" }, + 'backArrow-16-active': { content: "" }, + 'backArrow-16-inactive': { content: "" }, + 'backArrow-24-active': { content: "" }, + 'backArrow-24-inactive': { content: "" }, + 'ballot-12-active': { content: "" }, + 'ballot-12-inactive': { content: "" }, + 'ballot-16-active': { content: "" }, + 'ballot-16-inactive': { content: "" }, + 'ballot-24-active': { content: "" }, + 'ballot-24-inactive': { content: "" }, + 'ballotbox-12-active': { content: "" }, + 'ballotbox-12-inactive': { content: "" }, + 'ballotbox-16-active': { content: "" }, + 'ballotbox-16-inactive': { content: "" }, + 'ballotbox-24-active': { content: "" }, + 'ballotbox-24-inactive': { content: "" }, + 'bandage-12-active': { content: "" }, + 'bandage-12-inactive': { content: "" }, + 'bandage-16-active': { content: "" }, + 'bandage-16-inactive': { content: "" }, + 'bandage-24-active': { content: "" }, + 'bandage-24-inactive': { content: "" }, + 'bank-12-active': { content: "" }, + 'bank-12-inactive': { content: "" }, + 'bank-16-active': { content: "" }, + 'bank-16-inactive': { content: "" }, + 'bank-24-active': { content: "" }, + 'bank-24-inactive': { content: "" }, + 'barChartSimple-12-active': { content: "" }, + 'barChartSimple-12-inactive': { content: "" }, + 'barChartSimple-16-active': { content: "" }, + 'barChartSimple-16-inactive': { content: "" }, + 'barChartSimple-24-active': { content: "" }, + 'barChartSimple-24-inactive': { content: "" }, + 'barChartWindow-12-active': { content: "" }, + 'barChartWindow-12-inactive': { content: "" }, + 'barChartWindow-16-active': { content: "" }, + 'barChartWindow-16-inactive': { content: "" }, + 'barChartWindow-24-active': { content: "" }, + 'barChartWindow-24-inactive': { content: "" }, + 'base-12-active': { content: "" }, + 'base-12-inactive': { content: "" }, + 'base-16-active': { content: "" }, + 'base-16-inactive': { content: "" }, + 'base-24-active': { content: "" }, + 'base-24-inactive': { content: "" }, + 'baseApps-12-active': { content: "" }, + 'baseApps-12-inactive': { content: "" }, + 'baseApps-16-active': { content: "" }, + 'baseApps-16-inactive': { content: "" }, + 'baseApps-24-active': { content: "" }, + 'baseApps-24-inactive': { content: "" }, + 'baseball-12-active': { content: "" }, + 'baseball-12-inactive': { content: "" }, + 'baseball-16-active': { content: "" }, + 'baseball-16-inactive': { content: "" }, + 'baseball-24-active': { content: "" }, + 'baseball-24-inactive': { content: "" }, + 'baseFeed-12-active': { content: "" }, + 'baseFeed-12-inactive': { content: "" }, + 'baseFeed-16-active': { content: "" }, + 'baseFeed-16-inactive': { content: "" }, + 'baseFeed-24-active': { content: "" }, + 'baseFeed-24-inactive': { content: "" }, + 'baseNotification-12-active': { content: "" }, + 'baseNotification-12-inactive': { content: "" }, + 'baseNotification-16-active': { content: "" }, + 'baseNotification-16-inactive': { content: "" }, + 'baseNotification-24-active': { content: "" }, + 'baseNotification-24-inactive': { content: "" }, + 'baseQuickBuy-12-active': { content: "" }, + 'baseQuickBuy-12-inactive': { content: "" }, + 'baseQuickBuy-16-active': { content: "" }, + 'baseQuickBuy-16-inactive': { content: "" }, + 'baseQuickBuy-24-active': { content: "" }, + 'baseQuickBuy-24-inactive': { content: "" }, + 'baseSquare-12-active': { content: "" }, + 'baseSquare-12-inactive': { content: "" }, + 'baseSquare-16-active': { content: "" }, + 'baseSquare-16-inactive': { content: "" }, + 'baseSquare-24-active': { content: "" }, + 'baseSquare-24-inactive': { content: "" }, + 'baseTransact-12-active': { content: "" }, + 'baseTransact-12-inactive': { content: "" }, + 'baseTransact-16-active': { content: "" }, + 'baseTransact-16-inactive': { content: "" }, + 'baseTransact-24-active': { content: "" }, + 'baseTransact-24-inactive': { content: "" }, + 'baseVerification-12-active': { content: "" }, + 'baseVerification-12-inactive': { content: "" }, + 'baseVerification-16-active': { content: "" }, + 'baseVerification-16-inactive': { content: "" }, + 'baseVerification-24-active': { content: "" }, + 'baseVerification-24-inactive': { content: "" }, + 'baseWallet-12-active': { content: "" }, + 'baseWallet-12-inactive': { content: "" }, + 'baseWallet-16-active': { content: "" }, + 'baseWallet-16-inactive': { content: "" }, + 'baseWallet-24-active': { content: "" }, + 'baseWallet-24-inactive': { content: "" }, + 'basketball-12-active': { content: "" }, + 'basketball-12-inactive': { content: "" }, + 'basketball-16-active': { content: "" }, + 'basketball-16-inactive': { content: "" }, + 'basketball-24-active': { content: "" }, + 'basketball-24-inactive': { content: "" }, + 'beaker-12-active': { content: "" }, + 'beaker-12-inactive': { content: "" }, + 'beaker-16-active': { content: "" }, + 'beaker-16-inactive': { content: "" }, + 'beaker-24-active': { content: "" }, + 'beaker-24-inactive': { content: "" }, + 'beginningArrow-12-active': { content: "" }, + 'beginningArrow-12-inactive': { content: "" }, + 'beginningArrow-16-active': { content: "" }, + 'beginningArrow-16-inactive': { content: "" }, + 'beginningArrow-24-active': { content: "" }, + 'beginningArrow-24-inactive': { content: "" }, + 'bell-12-active': { content: "" }, + 'bell-12-inactive': { content: "" }, + 'bell-16-active': { content: "" }, + 'bell-16-inactive': { content: "" }, + 'bell-24-active': { content: "" }, + 'bell-24-inactive': { content: "" }, + 'bellCheck-12-active': { content: "" }, + 'bellCheck-12-inactive': { content: "" }, + 'bellCheck-16-active': { content: "" }, + 'bellCheck-16-inactive': { content: "" }, + 'bellCheck-24-active': { content: "" }, + 'bellCheck-24-inactive': { content: "" }, + 'bellPlus-12-active': { content: "" }, + 'bellPlus-12-inactive': { content: "" }, + 'bellPlus-16-active': { content: "" }, + 'bellPlus-16-inactive': { content: "" }, + 'bellPlus-24-active': { content: "" }, + 'bellPlus-24-inactive': { content: "" }, + 'birthcertificate-12-active': { content: "" }, + 'birthcertificate-12-inactive': { content: "" }, + 'birthcertificate-16-active': { content: "" }, + 'birthcertificate-16-inactive': { content: "" }, + 'birthcertificate-24-active': { content: "" }, + 'birthcertificate-24-inactive': { content: "" }, + 'block-12-active': { content: "" }, + 'block-12-inactive': { content: "" }, + 'block-16-active': { content: "" }, + 'block-16-inactive': { content: "" }, + 'block-24-active': { content: "" }, + 'block-24-inactive': { content: "" }, + 'blockchain-12-active': { content: "" }, + 'blockchain-12-inactive': { content: "" }, + 'blockchain-16-active': { content: "" }, + 'blockchain-16-inactive': { content: "" }, + 'blockchain-24-active': { content: "" }, + 'blockchain-24-inactive': { content: "" }, + 'blog-12-active': { content: "" }, + 'blog-12-inactive': { content: "" }, + 'blog-16-active': { content: "" }, + 'blog-16-inactive': { content: "" }, + 'blog-24-active': { content: "" }, + 'blog-24-inactive': { content: "" }, + 'book-12-active': { content: "" }, + 'book-12-inactive': { content: "" }, + 'book-16-active': { content: "" }, + 'book-16-inactive': { content: "" }, + 'book-24-active': { content: "" }, + 'book-24-inactive': { content: "" }, + 'bookmark-12-active': { content: "" }, + 'bookmark-12-inactive': { content: "" }, + 'bookmark-16-active': { content: "" }, + 'bookmark-16-inactive': { content: "" }, + 'bookmark-24-active': { content: "" }, + 'bookmark-24-inactive': { content: "" }, + 'borrowProduct-12-active': { content: "" }, + 'borrowProduct-12-inactive': { content: "" }, + 'borrowProduct-16-active': { content: "" }, + 'borrowProduct-16-inactive': { content: "" }, + 'borrowProduct-24-active': { content: "" }, + 'borrowProduct-24-inactive': { content: "" }, + 'boxing-12-active': { content: "" }, + 'boxing-12-inactive': { content: "" }, + 'boxing-16-active': { content: "" }, + 'boxing-16-inactive': { content: "" }, + 'boxing-24-active': { content: "" }, + 'boxing-24-inactive': { content: "" }, + 'bridging-12-active': { content: "" }, + 'bridging-12-inactive': { content: "" }, + 'bridging-16-active': { content: "" }, + 'bridging-16-inactive': { content: "" }, + 'bridging-24-active': { content: "" }, + 'bridging-24-inactive': { content: "" }, + 'briefcase-12-active': { content: "" }, + 'briefcase-12-inactive': { content: "" }, + 'briefcase-16-active': { content: "" }, + 'briefcase-16-inactive': { content: "" }, + 'briefcase-24-active': { content: "" }, + 'briefcase-24-inactive': { content: "" }, + 'briefcaseAlt-12-active': { content: "" }, + 'briefcaseAlt-12-inactive': { content: "" }, + 'briefcaseAlt-16-active': { content: "" }, + 'briefcaseAlt-16-inactive': { content: "" }, + 'briefcaseAlt-24-active': { content: "" }, + 'briefcaseAlt-24-inactive': { content: "" }, + 'browser-12-active': { content: "" }, + 'browser-12-inactive': { content: "" }, + 'browser-16-active': { content: "" }, + 'browser-16-inactive': { content: "" }, + 'browser-24-active': { content: "" }, + 'browser-24-inactive': { content: "" }, + 'bug-12-active': { content: "" }, + 'bug-12-inactive': { content: "" }, + 'bug-16-active': { content: "" }, + 'bug-16-inactive': { content: "" }, + 'bug-24-active': { content: "" }, + 'bug-24-inactive': { content: "" }, + 'building-12-active': { content: "" }, + 'building-12-inactive': { content: "" }, + 'building-16-active': { content: "" }, + 'building-16-inactive': { content: "" }, + 'building-24-active': { content: "" }, + 'building-24-inactive': { content: "" }, + 'calculator-12-active': { content: "" }, + 'calculator-12-inactive': { content: "" }, + 'calculator-16-active': { content: "" }, + 'calculator-16-inactive': { content: "" }, + 'calculator-24-active': { content: "" }, + 'calculator-24-inactive': { content: "" }, + 'calendar-12-active': { content: "" }, + 'calendar-12-inactive': { content: "" }, + 'calendar-16-active': { content: "" }, + 'calendar-16-inactive': { content: "" }, + 'calendar-24-active': { content: "" }, + 'calendar-24-inactive': { content: "" }, + 'calendarBlank-12-active': { content: "" }, + 'calendarBlank-12-inactive': { content: "" }, + 'calendarBlank-16-active': { content: "" }, + 'calendarBlank-16-inactive': { content: "" }, + 'calendarBlank-24-active': { content: "" }, + 'calendarBlank-24-inactive': { content: "" }, + 'calendarDates-12-active': { content: "" }, + 'calendarDates-12-inactive': { content: "" }, + 'calendarDates-16-active': { content: "" }, + 'calendarDates-16-inactive': { content: "" }, + 'calendarDates-24-active': { content: "" }, + 'calendarDates-24-inactive': { content: "" }, + 'calendarEmpty-12-active': { content: "" }, + 'calendarEmpty-12-inactive': { content: "" }, + 'calendarEmpty-16-active': { content: "" }, + 'calendarEmpty-16-inactive': { content: "" }, + 'calendarEmpty-24-active': { content: "" }, + 'calendarEmpty-24-inactive': { content: "" }, + 'calendarHeart-12-active': { content: "" }, + 'calendarHeart-12-inactive': { content: "" }, + 'calendarHeart-16-active': { content: "" }, + 'calendarHeart-16-inactive': { content: "" }, + 'calendarHeart-24-active': { content: "" }, + 'calendarHeart-24-inactive': { content: "" }, + 'calendarMoney-12-active': { content: "" }, + 'calendarMoney-12-inactive': { content: "" }, + 'calendarMoney-16-active': { content: "" }, + 'calendarMoney-16-inactive': { content: "" }, + 'calendarMoney-24-active': { content: "" }, + 'calendarMoney-24-inactive': { content: "" }, + 'calendarStar-12-active': { content: "" }, + 'calendarStar-12-inactive': { content: "" }, + 'calendarStar-16-active': { content: "" }, + 'calendarStar-16-inactive': { content: "" }, + 'calendarStar-24-active': { content: "" }, + 'calendarStar-24-inactive': { content: "" }, + 'camera-12-active': { content: "" }, + 'camera-12-inactive': { content: "" }, + 'camera-16-active': { content: "" }, + 'camera-16-inactive': { content: "" }, + 'camera-24-active': { content: "" }, + 'camera-24-inactive': { content: "" }, + 'candlesticks-12-active': { content: "" }, + 'candlesticks-12-inactive': { content: "" }, + 'candlesticks-16-active': { content: "" }, + 'candlesticks-16-inactive': { content: "" }, + 'candlesticks-24-active': { content: "" }, + 'candlesticks-24-inactive': { content: "" }, + 'car-12-active': { content: "" }, + 'car-12-inactive': { content: "" }, + 'car-16-active': { content: "" }, + 'car-16-inactive': { content: "" }, + 'car-24-active': { content: "" }, + 'car-24-inactive': { content: "" }, + 'card-12-active': { content: "" }, + 'card-12-inactive': { content: "" }, + 'card-16-active': { content: "" }, + 'card-16-inactive': { content: "" }, + 'card-24-active': { content: "" }, + 'card-24-inactive': { content: "" }, + 'caret-12-active': { content: "" }, + 'caret-12-inactive': { content: "" }, + 'caret-16-active': { content: "" }, + 'caret-16-inactive': { content: "" }, + 'caret-24-active': { content: "" }, + 'caret-24-inactive': { content: "" }, + 'caretDown-12-active': { content: "" }, + 'caretDown-12-inactive': { content: "" }, + 'caretDown-16-active': { content: "" }, + 'caretDown-16-inactive': { content: "" }, + 'caretDown-24-active': { content: "" }, + 'caretDown-24-inactive': { content: "" }, + 'caretLeft-12-active': { content: "" }, + 'caretLeft-12-inactive': { content: "" }, + 'caretLeft-16-active': { content: "" }, + 'caretLeft-16-inactive': { content: "" }, + 'caretLeft-24-active': { content: "" }, + 'caretLeft-24-inactive': { content: "" }, + 'caretRight-12-active': { content: "" }, + 'caretRight-12-inactive': { content: "" }, + 'caretRight-16-active': { content: "" }, + 'caretRight-16-inactive': { content: "" }, + 'caretRight-24-active': { content: "" }, + 'caretRight-24-inactive': { content: "" }, + 'caretUp-12-active': { content: "" }, + 'caretUp-12-inactive': { content: "" }, + 'caretUp-16-active': { content: "" }, + 'caretUp-16-inactive': { content: "" }, + 'caretUp-24-active': { content: "" }, + 'caretUp-24-inactive': { content: "" }, + 'cash-12-active': { content: "" }, + 'cash-12-inactive': { content: "" }, + 'cash-16-active': { content: "" }, + 'cash-16-inactive': { content: "" }, + 'cash-24-active': { content: "" }, + 'cash-24-inactive': { content: "" }, + 'cashAustralianDollar-12-active': { content: "" }, + 'cashAustralianDollar-12-inactive': { content: "" }, + 'cashAustralianDollar-16-active': { content: "" }, + 'cashAustralianDollar-16-inactive': { content: "" }, + 'cashAustralianDollar-24-active': { content: "" }, + 'cashAustralianDollar-24-inactive': { content: "" }, + 'cashBrazilianReal-12-active': { content: "" }, + 'cashBrazilianReal-12-inactive': { content: "" }, + 'cashBrazilianReal-16-active': { content: "" }, + 'cashBrazilianReal-16-inactive': { content: "" }, + 'cashBrazilianReal-24-active': { content: "" }, + 'cashBrazilianReal-24-inactive': { content: "" }, + 'cashBrazillianReal-12-active': { content: "" }, + 'cashBrazillianReal-12-inactive': { content: "" }, + 'cashBrazillianReal-16-active': { content: "" }, + 'cashBrazillianReal-16-inactive': { content: "" }, + 'cashBrazillianReal-24-active': { content: "" }, + 'cashBrazillianReal-24-inactive': { content: "" }, + 'cashCanadianDollar-12-active': { content: "" }, + 'cashCanadianDollar-12-inactive': { content: "" }, + 'cashCanadianDollar-16-active': { content: "" }, + 'cashCanadianDollar-16-inactive': { content: "" }, + 'cashCanadianDollar-24-active': { content: "" }, + 'cashCanadianDollar-24-inactive': { content: "" }, + 'cashCoins-12-active': { content: "" }, + 'cashCoins-12-inactive': { content: "" }, + 'cashCoins-16-active': { content: "" }, + 'cashCoins-16-inactive': { content: "" }, + 'cashCoins-24-active': { content: "" }, + 'cashCoins-24-inactive': { content: "" }, + 'cashEUR-12-active': { content: "" }, + 'cashEUR-12-inactive': { content: "" }, + 'cashEUR-16-active': { content: "" }, + 'cashEUR-16-inactive': { content: "" }, + 'cashEUR-24-active': { content: "" }, + 'cashEUR-24-inactive': { content: "" }, + 'cashGBP-12-active': { content: "" }, + 'cashGBP-12-inactive': { content: "" }, + 'cashGBP-16-active': { content: "" }, + 'cashGBP-16-inactive': { content: "" }, + 'cashGBP-24-active': { content: "" }, + 'cashGBP-24-inactive': { content: "" }, + 'cashIndonesianRupiah-12-active': { content: "" }, + 'cashIndonesianRupiah-12-inactive': { content: "" }, + 'cashIndonesianRupiah-16-active': { content: "" }, + 'cashIndonesianRupiah-16-inactive': { content: "" }, + 'cashIndonesianRupiah-24-active': { content: "" }, + 'cashIndonesianRupiah-24-inactive': { content: "" }, + 'cashJPY-12-active': { content: "" }, + 'cashJPY-12-inactive': { content: "" }, + 'cashJPY-16-active': { content: "" }, + 'cashJPY-16-inactive': { content: "" }, + 'cashJPY-24-active': { content: "" }, + 'cashJPY-24-inactive': { content: "" }, + 'cashPhilippinePeso-12-active': { content: "" }, + 'cashPhilippinePeso-12-inactive': { content: "" }, + 'cashPhilippinePeso-16-active': { content: "" }, + 'cashPhilippinePeso-16-inactive': { content: "" }, + 'cashPhilippinePeso-24-active': { content: "" }, + 'cashPhilippinePeso-24-inactive': { content: "" }, + 'cashPolishZloty-12-active': { content: "" }, + 'cashPolishZloty-12-inactive': { content: "" }, + 'cashPolishZloty-16-active': { content: "" }, + 'cashPolishZloty-16-inactive': { content: "" }, + 'cashPolishZloty-24-active': { content: "" }, + 'cashPolishZloty-24-inactive': { content: "" }, + 'cashRupee-12-active': { content: "" }, + 'cashRupee-12-inactive': { content: "" }, + 'cashRupee-16-active': { content: "" }, + 'cashRupee-16-inactive': { content: "" }, + 'cashRupee-24-active': { content: "" }, + 'cashRupee-24-inactive': { content: "" }, + 'cashSingaporeDollar-12-active': { content: "" }, + 'cashSingaporeDollar-12-inactive': { content: "" }, + 'cashSingaporeDollar-16-active': { content: "" }, + 'cashSingaporeDollar-16-inactive': { content: "" }, + 'cashSingaporeDollar-24-active': { content: "" }, + 'cashSingaporeDollar-24-inactive': { content: "" }, + 'cashSwissFranc-12-active': { content: "" }, + 'cashSwissFranc-12-inactive': { content: "" }, + 'cashSwissFranc-16-active': { content: "" }, + 'cashSwissFranc-16-inactive': { content: "" }, + 'cashSwissFranc-24-active': { content: "" }, + 'cashSwissFranc-24-inactive': { content: "" }, + 'cashThaiBaht-12-active': { content: "" }, + 'cashThaiBaht-12-inactive': { content: "" }, + 'cashThaiBaht-16-active': { content: "" }, + 'cashThaiBaht-16-inactive': { content: "" }, + 'cashThaiBaht-24-active': { content: "" }, + 'cashThaiBaht-24-inactive': { content: "" }, + 'cashTurkishLira-12-active': { content: "" }, + 'cashTurkishLira-12-inactive': { content: "" }, + 'cashTurkishLira-16-active': { content: "" }, + 'cashTurkishLira-16-inactive': { content: "" }, + 'cashTurkishLira-24-active': { content: "" }, + 'cashTurkishLira-24-inactive': { content: "" }, + 'cashUaeDirham-12-active': { content: "" }, + 'cashUaeDirham-12-inactive': { content: "" }, + 'cashUaeDirham-16-active': { content: "" }, + 'cashUaeDirham-16-inactive': { content: "" }, + 'cashUaeDirham-24-active': { content: "" }, + 'cashUaeDirham-24-inactive': { content: "" }, + 'cashUSD-12-active': { content: "" }, + 'cashUSD-12-inactive': { content: "" }, + 'cashUSD-16-active': { content: "" }, + 'cashUSD-16-inactive': { content: "" }, + 'cashUSD-24-active': { content: "" }, + 'cashUSD-24-inactive': { content: "" }, + 'cashVietnameseDong-12-active': { content: "" }, + 'cashVietnameseDong-12-inactive': { content: "" }, + 'cashVietnameseDong-16-active': { content: "" }, + 'cashVietnameseDong-16-inactive': { content: "" }, + 'cashVietnameseDong-24-active': { content: "" }, + 'cashVietnameseDong-24-inactive': { content: "" }, + 'chainLink-12-active': { content: "" }, + 'chainLink-12-inactive': { content: "" }, + 'chainLink-16-active': { content: "" }, + 'chainLink-16-inactive': { content: "" }, + 'chainLink-24-active': { content: "" }, + 'chainLink-24-inactive': { content: "" }, + 'chartBar-12-active': { content: "" }, + 'chartBar-12-inactive': { content: "" }, + 'chartBar-16-active': { content: "" }, + 'chartBar-16-inactive': { content: "" }, + 'chartBar-24-active': { content: "" }, + 'chartBar-24-inactive': { content: "" }, + 'chartCandles-12-active': { content: "" }, + 'chartCandles-12-inactive': { content: "" }, + 'chartCandles-16-active': { content: "" }, + 'chartCandles-16-inactive': { content: "" }, + 'chartCandles-24-active': { content: "" }, + 'chartCandles-24-inactive': { content: "" }, + 'chartLine-12-active': { content: "" }, + 'chartLine-12-inactive': { content: "" }, + 'chartLine-16-active': { content: "" }, + 'chartLine-16-inactive': { content: "" }, + 'chartLine-24-active': { content: "" }, + 'chartLine-24-inactive': { content: "" }, + 'chartPie-12-active': { content: "" }, + 'chartPie-12-inactive': { content: "" }, + 'chartPie-16-active': { content: "" }, + 'chartPie-16-inactive': { content: "" }, + 'chartPie-24-active': { content: "" }, + 'chartPie-24-inactive': { content: "" }, + 'chartPieCircle-12-active': { content: "" }, + 'chartPieCircle-12-inactive': { content: "" }, + 'chartPieCircle-16-active': { content: "" }, + 'chartPieCircle-16-inactive': { content: "" }, + 'chartPieCircle-24-active': { content: "" }, + 'chartPieCircle-24-inactive': { content: "" }, + 'chartVolume-12-active': { content: "" }, + 'chartVolume-12-inactive': { content: "" }, + 'chartVolume-16-active': { content: "" }, + 'chartVolume-16-inactive': { content: "" }, + 'chartVolume-24-active': { content: "" }, + 'chartVolume-24-inactive': { content: "" }, + 'chatBotAgent-12-active': { content: "" }, + 'chatBotAgent-12-inactive': { content: "" }, + 'chatBotAgent-16-active': { content: "" }, + 'chatBotAgent-16-inactive': { content: "" }, + 'chatBotAgent-24-active': { content: "" }, + 'chatBotAgent-24-inactive': { content: "" }, + 'chatBubble-12-active': { content: "" }, + 'chatBubble-12-inactive': { content: "" }, + 'chatBubble-16-active': { content: "" }, + 'chatBubble-16-inactive': { content: "" }, + 'chatBubble-24-active': { content: "" }, + 'chatBubble-24-inactive': { content: "" }, + 'chatRequests-12-active': { content: "" }, + 'chatRequests-12-inactive': { content: "" }, + 'chatRequests-16-active': { content: "" }, + 'chatRequests-16-inactive': { content: "" }, + 'chatRequests-24-active': { content: "" }, + 'chatRequests-24-inactive': { content: "" }, + 'checkboxChecked-12-active': { content: "" }, + 'checkboxChecked-12-inactive': { content: "" }, + 'checkboxChecked-16-active': { content: "" }, + 'checkboxChecked-16-inactive': { content: "" }, + 'checkboxChecked-24-active': { content: "" }, + 'checkboxChecked-24-inactive': { content: "" }, + 'checkboxEmpty-12-active': { content: "" }, + 'checkboxEmpty-12-inactive': { content: "" }, + 'checkboxEmpty-16-active': { content: "" }, + 'checkboxEmpty-16-inactive': { content: "" }, + 'checkboxEmpty-24-active': { content: "" }, + 'checkboxEmpty-24-inactive': { content: "" }, + 'checkmark-12-active': { content: "" }, + 'checkmark-12-inactive': { content: "" }, + 'checkmark-16-active': { content: "" }, + 'checkmark-16-inactive': { content: "" }, + 'checkmark-24-active': { content: "" }, + 'checkmark-24-inactive': { content: "" }, + 'chess-12-active': { content: "" }, + 'chess-12-inactive': { content: "" }, + 'chess-16-active': { content: "" }, + 'chess-16-inactive': { content: "" }, + 'chess-24-active': { content: "" }, + 'chess-24-inactive': { content: "" }, + 'circleCheckmark-12-active': { content: "" }, + 'circleCheckmark-12-inactive': { content: "" }, + 'circleCheckmark-16-active': { content: "" }, + 'circleCheckmark-16-inactive': { content: "" }, + 'circleCheckmark-24-active': { content: "" }, + 'circleCheckmark-24-inactive': { content: "" }, + 'circleCross-12-active': { content: "" }, + 'circleCross-12-inactive': { content: "" }, + 'circleCross-16-active': { content: "" }, + 'circleCross-16-inactive': { content: "" }, + 'circleCross-24-active': { content: "" }, + 'circleCross-24-inactive': { content: "" }, + 'circulatingSupply-12-active': { content: "" }, + 'circulatingSupply-12-inactive': { content: "" }, + 'circulatingSupply-16-active': { content: "" }, + 'circulatingSupply-16-inactive': { content: "" }, + 'circulatingSupply-24-active': { content: "" }, + 'circulatingSupply-24-inactive': { content: "" }, + 'city-12-active': { content: "" }, + 'city-12-inactive': { content: "" }, + 'city-16-active': { content: "" }, + 'city-16-inactive': { content: "" }, + 'city-24-active': { content: "" }, + 'city-24-inactive': { content: "" }, + 'clipboard-12-active': { content: "" }, + 'clipboard-12-inactive': { content: "" }, + 'clipboard-16-active': { content: "" }, + 'clipboard-16-inactive': { content: "" }, + 'clipboard-24-active': { content: "" }, + 'clipboard-24-inactive': { content: "" }, + 'clock-12-active': { content: "" }, + 'clock-12-inactive': { content: "" }, + 'clock-16-active': { content: "" }, + 'clock-16-inactive': { content: "" }, + 'clock-24-active': { content: "" }, + 'clock-24-inactive': { content: "" }, + 'clockOutline-12-active': { content: "" }, + 'clockOutline-12-inactive': { content: "" }, + 'clockOutline-16-active': { content: "" }, + 'clockOutline-16-inactive': { content: "" }, + 'clockOutline-24-active': { content: "" }, + 'clockOutline-24-inactive': { content: "" }, + 'close-12-active': { content: "" }, + 'close-12-inactive': { content: "" }, + 'close-16-active': { content: "" }, + 'close-16-inactive': { content: "" }, + 'close-24-active': { content: "" }, + 'close-24-inactive': { content: "" }, + 'closeCaption-12-active': { content: "" }, + 'closeCaption-12-inactive': { content: "" }, + 'closeCaption-16-active': { content: "" }, + 'closeCaption-16-inactive': { content: "" }, + 'closeCaption-24-active': { content: "" }, + 'closeCaption-24-inactive': { content: "" }, + 'clothing-12-active': { content: "" }, + 'clothing-12-inactive': { content: "" }, + 'clothing-16-active': { content: "" }, + 'clothing-16-inactive': { content: "" }, + 'clothing-24-active': { content: "" }, + 'clothing-24-inactive': { content: "" }, + 'cloud-12-active': { content: "" }, + 'cloud-12-inactive': { content: "" }, + 'cloud-16-active': { content: "" }, + 'cloud-16-inactive': { content: "" }, + 'cloud-24-active': { content: "" }, + 'cloud-24-inactive': { content: "" }, + 'cloudPartial-12-active': { content: "" }, + 'cloudPartial-12-inactive': { content: "" }, + 'cloudPartial-16-active': { content: "" }, + 'cloudPartial-16-inactive': { content: "" }, + 'cloudPartial-24-active': { content: "" }, + 'cloudPartial-24-inactive': { content: "" }, + 'cloudProduct-12-active': { content: "" }, + 'cloudProduct-12-inactive': { content: "" }, + 'cloudProduct-16-active': { content: "" }, + 'cloudProduct-16-inactive': { content: "" }, + 'cloudProduct-24-active': { content: "" }, + 'cloudProduct-24-inactive': { content: "" }, + 'cluster-12-active': { content: "" }, + 'cluster-12-inactive': { content: "" }, + 'cluster-16-active': { content: "" }, + 'cluster-16-inactive': { content: "" }, + 'cluster-24-active': { content: "" }, + 'cluster-24-inactive': { content: "" }, + 'coinbase-12-active': { content: "" }, + 'coinbase-12-inactive': { content: "" }, + 'coinbase-16-active': { content: "" }, + 'coinbase-16-inactive': { content: "" }, + 'coinbase-24-active': { content: "" }, + 'coinbase-24-inactive': { content: "" }, + 'coinbaseCardProduct-12-active': { content: "" }, + 'coinbaseCardProduct-12-inactive': { content: "" }, + 'coinbaseCardProduct-16-active': { content: "" }, + 'coinbaseCardProduct-16-inactive': { content: "" }, + 'coinbaseCardProduct-24-active': { content: "" }, + 'coinbaseCardProduct-24-inactive': { content: "" }, + 'coinbaseOne-12-active': { content: "" }, + 'coinbaseOne-12-inactive': { content: "" }, + 'coinbaseOne-16-active': { content: "" }, + 'coinbaseOne-16-inactive': { content: "" }, + 'coinbaseOne-24-active': { content: "" }, + 'coinbaseOne-24-inactive': { content: "" }, + 'coinbaseOneCard-12-active': { content: "" }, + 'coinbaseOneCard-12-inactive': { content: "" }, + 'coinbaseOneCard-16-active': { content: "" }, + 'coinbaseOneCard-16-inactive': { content: "" }, + 'coinbaseOneCard-24-active': { content: "" }, + 'coinbaseOneCard-24-inactive': { content: "" }, + 'coinbaseOneLogo-12-active': { content: "" }, + 'coinbaseOneLogo-12-inactive': { content: "" }, + 'coinbaseOneLogo-16-active': { content: "" }, + 'coinbaseOneLogo-16-inactive': { content: "" }, + 'coinbaseOneLogo-24-active': { content: "" }, + 'coinbaseOneLogo-24-inactive': { content: "" }, + 'coinbaseRewards-12-active': { content: "" }, + 'coinbaseRewards-12-inactive': { content: "" }, + 'coinbaseRewards-16-active': { content: "" }, + 'coinbaseRewards-16-inactive': { content: "" }, + 'coinbaseRewards-24-active': { content: "" }, + 'coinbaseRewards-24-inactive': { content: "" }, + 'coinsCrypto-12-active': { content: "" }, + 'coinsCrypto-12-inactive': { content: "" }, + 'coinsCrypto-16-active': { content: "" }, + 'coinsCrypto-16-inactive': { content: "" }, + 'coinsCrypto-24-active': { content: "" }, + 'coinsCrypto-24-inactive': { content: "" }, + 'collapse-12-active': { content: "" }, + 'collapse-12-inactive': { content: "" }, + 'collapse-16-active': { content: "" }, + 'collapse-16-inactive': { content: "" }, + 'collapse-24-active': { content: "" }, + 'collapse-24-inactive': { content: "" }, + 'collectibles-12-active': { content: "" }, + 'collectibles-12-inactive': { content: "" }, + 'collectibles-16-active': { content: "" }, + 'collectibles-16-inactive': { content: "" }, + 'collectibles-24-active': { content: "" }, + 'collectibles-24-inactive': { content: "" }, + 'collection-12-active': { content: "" }, + 'collection-12-inactive': { content: "" }, + 'collection-16-active': { content: "" }, + 'collection-16-inactive': { content: "" }, + 'collection-24-active': { content: "" }, + 'collection-24-inactive': { content: "" }, + 'comment-12-active': { content: "" }, + 'comment-12-inactive': { content: "" }, + 'comment-16-active': { content: "" }, + 'comment-16-inactive': { content: "" }, + 'comment-24-active': { content: "" }, + 'comment-24-inactive': { content: "" }, + 'commentPlus-12-active': { content: "" }, + 'commentPlus-12-inactive': { content: "" }, + 'commentPlus-16-active': { content: "" }, + 'commentPlus-16-inactive': { content: "" }, + 'commentPlus-24-active': { content: "" }, + 'commentPlus-24-inactive': { content: "" }, + 'commerceProduct-12-active': { content: "" }, + 'commerceProduct-12-inactive': { content: "" }, + 'commerceProduct-16-active': { content: "" }, + 'commerceProduct-16-inactive': { content: "" }, + 'commerceProduct-24-active': { content: "" }, + 'commerceProduct-24-inactive': { content: "" }, + 'compass-12-active': { content: "" }, + 'compass-12-inactive': { content: "" }, + 'compass-16-active': { content: "" }, + 'compass-16-inactive': { content: "" }, + 'compass-24-active': { content: "" }, + 'compass-24-inactive': { content: "" }, + 'complianceProduct-12-active': { content: "" }, + 'complianceProduct-12-inactive': { content: "" }, + 'complianceProduct-16-active': { content: "" }, + 'complianceProduct-16-inactive': { content: "" }, + 'complianceProduct-24-active': { content: "" }, + 'complianceProduct-24-inactive': { content: "" }, + 'compose-12-active': { content: "" }, + 'compose-12-inactive': { content: "" }, + 'compose-16-active': { content: "" }, + 'compose-16-inactive': { content: "" }, + 'compose-24-active': { content: "" }, + 'compose-24-inactive': { content: "" }, + 'computerChip-12-active': { content: "" }, + 'computerChip-12-inactive': { content: "" }, + 'computerChip-16-active': { content: "" }, + 'computerChip-16-inactive': { content: "" }, + 'computerChip-24-active': { content: "" }, + 'computerChip-24-inactive': { content: "" }, + 'concierge-12-active': { content: "" }, + 'concierge-12-inactive': { content: "" }, + 'concierge-16-active': { content: "" }, + 'concierge-16-inactive': { content: "" }, + 'concierge-24-active': { content: "" }, + 'concierge-24-inactive': { content: "" }, + 'conciergeBell-12-active': { content: "" }, + 'conciergeBell-12-inactive': { content: "" }, + 'conciergeBell-16-active': { content: "" }, + 'conciergeBell-16-inactive': { content: "" }, + 'conciergeBell-24-active': { content: "" }, + 'conciergeBell-24-inactive': { content: "" }, + 'config-12-active': { content: "" }, + 'config-12-inactive': { content: "" }, + 'config-16-active': { content: "" }, + 'config-16-inactive': { content: "" }, + 'config-24-active': { content: "" }, + 'config-24-inactive': { content: "" }, + 'continuous-12-active': { content: "" }, + 'continuous-12-inactive': { content: "" }, + 'continuous-16-active': { content: "" }, + 'continuous-16-inactive': { content: "" }, + 'continuous-24-active': { content: "" }, + 'continuous-24-inactive': { content: "" }, + 'convert-12-active': { content: "" }, + 'convert-12-inactive': { content: "" }, + 'convert-16-active': { content: "" }, + 'convert-16-inactive': { content: "" }, + 'convert-24-active': { content: "" }, + 'convert-24-inactive': { content: "" }, + 'copy-12-active': { content: "" }, + 'copy-12-inactive': { content: "" }, + 'copy-16-active': { content: "" }, + 'copy-16-inactive': { content: "" }, + 'copy-24-active': { content: "" }, + 'copy-24-inactive': { content: "" }, + 'corporation-12-active': { content: "" }, + 'corporation-12-inactive': { content: "" }, + 'corporation-16-active': { content: "" }, + 'corporation-16-inactive': { content: "" }, + 'corporation-24-active': { content: "" }, + 'corporation-24-inactive': { content: "" }, + 'creatorCoin-12-active': { content: "" }, + 'creatorCoin-12-inactive': { content: "" }, + 'creatorCoin-16-active': { content: "" }, + 'creatorCoin-16-inactive': { content: "" }, + 'creatorCoin-24-active': { content: "" }, + 'creatorCoin-24-inactive': { content: "" }, + 'cricket-12-active': { content: "" }, + 'cricket-12-inactive': { content: "" }, + 'cricket-16-active': { content: "" }, + 'cricket-16-inactive': { content: "" }, + 'cricket-24-active': { content: "" }, + 'cricket-24-inactive': { content: "" }, + 'cross-12-active': { content: "" }, + 'cross-12-inactive': { content: "" }, + 'cross-16-active': { content: "" }, + 'cross-16-inactive': { content: "" }, + 'cross-24-active': { content: "" }, + 'cross-24-inactive': { content: "" }, + 'crossTrade-12-active': { content: "" }, + 'crossTrade-12-inactive': { content: "" }, + 'crossTrade-16-active': { content: "" }, + 'crossTrade-16-inactive': { content: "" }, + 'crossTrade-24-active': { content: "" }, + 'crossTrade-24-inactive': { content: "" }, + 'crypto-12-active': { content: "" }, + 'crypto-12-inactive': { content: "" }, + 'crypto-16-active': { content: "" }, + 'crypto-16-inactive': { content: "" }, + 'crypto-24-active': { content: "" }, + 'crypto-24-inactive': { content: "" }, + 'cryptobasics-12-active': { content: "" }, + 'cryptobasics-12-inactive': { content: "" }, + 'cryptobasics-16-active': { content: "" }, + 'cryptobasics-16-inactive': { content: "" }, + 'cryptobasics-24-active': { content: "" }, + 'cryptobasics-24-inactive': { content: "" }, + 'crystalBall-12-active': { content: "" }, + 'crystalBall-12-inactive': { content: "" }, + 'crystalBall-16-active': { content: "" }, + 'crystalBall-16-inactive': { content: "" }, + 'crystalBall-24-active': { content: "" }, + 'crystalBall-24-inactive': { content: "" }, + 'crystalBallInsight-12-active': { content: "" }, + 'crystalBallInsight-12-inactive': { content: "" }, + 'crystalBallInsight-16-active': { content: "" }, + 'crystalBallInsight-16-inactive': { content: "" }, + 'crystalBallInsight-24-active': { content: "" }, + 'crystalBallInsight-24-inactive': { content: "" }, + 'currencies-12-active': { content: "" }, + 'currencies-12-inactive': { content: "" }, + 'currencies-16-active': { content: "" }, + 'currencies-16-inactive': { content: "" }, + 'currencies-24-active': { content: "" }, + 'currencies-24-inactive': { content: "" }, + 'custodyProduct-12-active': { content: "" }, + 'custodyProduct-12-inactive': { content: "" }, + 'custodyProduct-16-active': { content: "" }, + 'custodyProduct-16-inactive': { content: "" }, + 'custodyProduct-24-active': { content: "" }, + 'custodyProduct-24-inactive': { content: "" }, + 'dashboard-12-active': { content: "" }, + 'dashboard-12-inactive': { content: "" }, + 'dashboard-16-active': { content: "" }, + 'dashboard-16-inactive': { content: "" }, + 'dashboard-24-active': { content: "" }, + 'dashboard-24-inactive': { content: "" }, + 'dataMarketplaceProduct-12-active': { content: "" }, + 'dataMarketplaceProduct-12-inactive': { content: "" }, + 'dataMarketplaceProduct-16-active': { content: "" }, + 'dataMarketplaceProduct-16-inactive': { content: "" }, + 'dataMarketplaceProduct-24-active': { content: "" }, + 'dataMarketplaceProduct-24-inactive': { content: "" }, + 'dataStack-12-active': { content: "" }, + 'dataStack-12-inactive': { content: "" }, + 'dataStack-16-active': { content: "" }, + 'dataStack-16-inactive': { content: "" }, + 'dataStack-24-active': { content: "" }, + 'dataStack-24-inactive': { content: "" }, + 'defi-12-active': { content: "" }, + 'defi-12-inactive': { content: "" }, + 'defi-16-active': { content: "" }, + 'defi-16-inactive': { content: "" }, + 'defi-24-active': { content: "" }, + 'defi-24-inactive': { content: "" }, + 'delegateProduct-12-active': { content: "" }, + 'delegateProduct-12-inactive': { content: "" }, + 'delegateProduct-16-active': { content: "" }, + 'delegateProduct-16-inactive': { content: "" }, + 'delegateProduct-24-active': { content: "" }, + 'delegateProduct-24-inactive': { content: "" }, + 'deposit-12-active': { content: "" }, + 'deposit-12-inactive': { content: "" }, + 'deposit-16-active': { content: "" }, + 'deposit-16-inactive': { content: "" }, + 'deposit-24-active': { content: "" }, + 'deposit-24-inactive': { content: "" }, + 'derivatives-12-active': { content: "" }, + 'derivatives-12-inactive': { content: "" }, + 'derivatives-16-active': { content: "" }, + 'derivatives-16-inactive': { content: "" }, + 'derivatives-24-active': { content: "" }, + 'derivatives-24-inactive': { content: "" }, + 'derivativesProduct-12-active': { content: "" }, + 'derivativesProduct-12-inactive': { content: "" }, + 'derivativesProduct-16-active': { content: "" }, + 'derivativesProduct-16-inactive': { content: "" }, + 'derivativesProduct-24-active': { content: "" }, + 'derivativesProduct-24-inactive': { content: "" }, + 'derivativesProductNew-12-active': { content: "" }, + 'derivativesProductNew-12-inactive': { content: "" }, + 'derivativesProductNew-16-active': { content: "" }, + 'derivativesProductNew-16-inactive': { content: "" }, + 'derivativesProductNew-24-active': { content: "" }, + 'derivativesProductNew-24-inactive': { content: "" }, + 'developerAPIProduct-12-active': { content: "" }, + 'developerAPIProduct-12-inactive': { content: "" }, + 'developerAPIProduct-16-active': { content: "" }, + 'developerAPIProduct-16-inactive': { content: "" }, + 'developerAPIProduct-24-active': { content: "" }, + 'developerAPIProduct-24-inactive': { content: "" }, + 'developerPlatformProduct-12-active': { content: "" }, + 'developerPlatformProduct-12-inactive': { content: "" }, + 'developerPlatformProduct-16-active': { content: "" }, + 'developerPlatformProduct-16-inactive': { content: "" }, + 'developerPlatformProduct-24-active': { content: "" }, + 'developerPlatformProduct-24-inactive': { content: "" }, + 'dex-12-active': { content: "" }, + 'dex-12-inactive': { content: "" }, + 'dex-16-active': { content: "" }, + 'dex-16-inactive': { content: "" }, + 'dex-24-active': { content: "" }, + 'dex-24-inactive': { content: "" }, + 'diagonalDownArrow-12-active': { content: "" }, + 'diagonalDownArrow-12-inactive': { content: "" }, + 'diagonalDownArrow-16-active': { content: "" }, + 'diagonalDownArrow-16-inactive': { content: "" }, + 'diagonalDownArrow-24-active': { content: "" }, + 'diagonalDownArrow-24-inactive': { content: "" }, + 'diagonalRightArrow-12-active': { content: "" }, + 'diagonalRightArrow-12-inactive': { content: "" }, + 'diagonalRightArrow-16-active': { content: "" }, + 'diagonalRightArrow-16-inactive': { content: "" }, + 'diagonalRightArrow-24-active': { content: "" }, + 'diagonalRightArrow-24-inactive': { content: "" }, + 'diagonalUpArrow-12-active': { content: "" }, + 'diagonalUpArrow-12-inactive': { content: "" }, + 'diagonalUpArrow-16-active': { content: "" }, + 'diagonalUpArrow-16-inactive': { content: "" }, + 'diagonalUpArrow-24-active': { content: "" }, + 'diagonalUpArrow-24-inactive': { content: "" }, + 'diamond-12-active': { content: "" }, + 'diamond-12-inactive': { content: "" }, + 'diamond-16-active': { content: "" }, + 'diamond-16-inactive': { content: "" }, + 'diamond-24-active': { content: "" }, + 'diamond-24-inactive': { content: "" }, + 'diamondIncentives-12-active': { content: "" }, + 'diamondIncentives-12-inactive': { content: "" }, + 'diamondIncentives-16-active': { content: "" }, + 'diamondIncentives-16-inactive': { content: "" }, + 'diamondIncentives-24-active': { content: "" }, + 'diamondIncentives-24-inactive': { content: "" }, + 'dinnerPlate-12-active': { content: "" }, + 'dinnerPlate-12-inactive': { content: "" }, + 'dinnerPlate-16-active': { content: "" }, + 'dinnerPlate-16-inactive': { content: "" }, + 'dinnerPlate-24-active': { content: "" }, + 'dinnerPlate-24-inactive': { content: "" }, + 'directDeposit-12-active': { content: "" }, + 'directDeposit-12-inactive': { content: "" }, + 'directDeposit-16-active': { content: "" }, + 'directDeposit-16-inactive': { content: "" }, + 'directDeposit-24-active': { content: "" }, + 'directDeposit-24-inactive': { content: "" }, + 'directDepositIcon-12-active': { content: "" }, + 'directDepositIcon-12-inactive': { content: "" }, + 'directDepositIcon-16-active': { content: "" }, + 'directDepositIcon-16-inactive': { content: "" }, + 'directDepositIcon-24-active': { content: "" }, + 'directDepositIcon-24-inactive': { content: "" }, + 'disabledPhone-12-active': { content: "" }, + 'disabledPhone-12-inactive': { content: "" }, + 'disabledPhone-16-active': { content: "" }, + 'disabledPhone-16-inactive': { content: "" }, + 'disabledPhone-24-active': { content: "" }, + 'disabledPhone-24-inactive': { content: "" }, + 'discordLogo-12-active': { content: "" }, + 'discordLogo-12-inactive': { content: "" }, + 'discordLogo-16-active': { content: "" }, + 'discordLogo-16-inactive': { content: "" }, + 'discordLogo-24-active': { content: "" }, + 'discordLogo-24-inactive': { content: "" }, + 'distribution-12-active': { content: "" }, + 'distribution-12-inactive': { content: "" }, + 'distribution-16-active': { content: "" }, + 'distribution-16-inactive': { content: "" }, + 'distribution-24-active': { content: "" }, + 'distribution-24-inactive': { content: "" }, + 'document-12-active': { content: "" }, + 'document-12-inactive': { content: "" }, + 'document-16-active': { content: "" }, + 'document-16-inactive': { content: "" }, + 'document-24-active': { content: "" }, + 'document-24-inactive': { content: "" }, + 'documentation-12-active': { content: "" }, + 'documentation-12-inactive': { content: "" }, + 'documentation-16-active': { content: "" }, + 'documentation-16-inactive': { content: "" }, + 'documentation-24-active': { content: "" }, + 'documentation-24-inactive': { content: "" }, + 'dot-12-active': { content: "" }, + 'dot-12-inactive': { content: "" }, + 'dot-16-active': { content: "" }, + 'dot-16-inactive': { content: "" }, + 'dot-24-active': { content: "" }, + 'dot-24-inactive': { content: "" }, + 'doubleChevronRight-12-active': { content: "" }, + 'doubleChevronRight-12-inactive': { content: "" }, + 'doubleChevronRight-16-active': { content: "" }, + 'doubleChevronRight-16-inactive': { content: "" }, + 'doubleChevronRight-24-active': { content: "" }, + 'doubleChevronRight-24-inactive': { content: "" }, + 'downArrow-12-active': { content: "" }, + 'downArrow-12-inactive': { content: "" }, + 'downArrow-16-active': { content: "" }, + 'downArrow-16-inactive': { content: "" }, + 'downArrow-24-active': { content: "" }, + 'downArrow-24-inactive': { content: "" }, + 'download-12-active': { content: "" }, + 'download-12-inactive': { content: "" }, + 'download-16-active': { content: "" }, + 'download-16-inactive': { content: "" }, + 'download-24-active': { content: "" }, + 'download-24-inactive': { content: "" }, + 'drag-12-active': { content: "" }, + 'drag-12-inactive': { content: "" }, + 'drag-16-active': { content: "" }, + 'drag-16-inactive': { content: "" }, + 'drag-24-active': { content: "" }, + 'drag-24-inactive': { content: "" }, + 'drops-12-active': { content: "" }, + 'drops-12-inactive': { content: "" }, + 'drops-16-active': { content: "" }, + 'drops-16-inactive': { content: "" }, + 'drops-24-active': { content: "" }, + 'drops-24-inactive': { content: "" }, + 'earn-12-active': { content: "" }, + 'earn-12-inactive': { content: "" }, + 'earn-16-active': { content: "" }, + 'earn-16-inactive': { content: "" }, + 'earn-24-active': { content: "" }, + 'earn-24-inactive': { content: "" }, + 'earnProduct-12-active': { content: "" }, + 'earnProduct-12-inactive': { content: "" }, + 'earnProduct-16-active': { content: "" }, + 'earnProduct-16-inactive': { content: "" }, + 'earnProduct-24-active': { content: "" }, + 'earnProduct-24-inactive': { content: "" }, + 'earnRewards-12-active': { content: "" }, + 'earnRewards-12-inactive': { content: "" }, + 'earnRewards-16-active': { content: "" }, + 'earnRewards-16-inactive': { content: "" }, + 'earnRewards-24-active': { content: "" }, + 'earnRewards-24-inactive': { content: "" }, + 'earthquake-12-active': { content: "" }, + 'earthquake-12-inactive': { content: "" }, + 'earthquake-16-active': { content: "" }, + 'earthquake-16-inactive': { content: "" }, + 'earthquake-24-active': { content: "" }, + 'earthquake-24-inactive': { content: "" }, + 'educationBook-12-active': { content: "" }, + 'educationBook-12-inactive': { content: "" }, + 'educationBook-16-active': { content: "" }, + 'educationBook-16-inactive': { content: "" }, + 'educationBook-24-active': { content: "" }, + 'educationBook-24-inactive': { content: "" }, + 'educationPencil-12-active': { content: "" }, + 'educationPencil-12-inactive': { content: "" }, + 'educationPencil-16-active': { content: "" }, + 'educationPencil-16-inactive': { content: "" }, + 'educationPencil-24-active': { content: "" }, + 'educationPencil-24-inactive': { content: "" }, + 'email-12-active': { content: "" }, + 'email-12-inactive': { content: "" }, + 'email-16-active': { content: "" }, + 'email-16-inactive': { content: "" }, + 'email-24-active': { content: "" }, + 'email-24-inactive': { content: "" }, + 'endArrow-12-active': { content: "" }, + 'endArrow-12-inactive': { content: "" }, + 'endArrow-16-active': { content: "" }, + 'endArrow-16-inactive': { content: "" }, + 'endArrow-24-active': { content: "" }, + 'endArrow-24-inactive': { content: "" }, + 'entertainment-12-active': { content: "" }, + 'entertainment-12-inactive': { content: "" }, + 'entertainment-16-active': { content: "" }, + 'entertainment-16-inactive': { content: "" }, + 'entertainment-24-active': { content: "" }, + 'entertainment-24-inactive': { content: "" }, + 'error-12-active': { content: "" }, + 'error-12-inactive': { content: "" }, + 'error-16-active': { content: "" }, + 'error-16-inactive': { content: "" }, + 'error-24-active': { content: "" }, + 'error-24-inactive': { content: "" }, + 'ethereum-12-active': { content: "" }, + 'ethereum-12-inactive': { content: "" }, + 'ethereum-16-active': { content: "" }, + 'ethereum-16-inactive': { content: "" }, + 'ethereum-24-active': { content: "" }, + 'ethereum-24-inactive': { content: "" }, + 'eventContracts-12-active': { content: "" }, + 'eventContracts-12-inactive': { content: "" }, + 'eventContracts-16-active': { content: "" }, + 'eventContracts-16-inactive': { content: "" }, + 'eventContracts-24-active': { content: "" }, + 'eventContracts-24-inactive': { content: "" }, + 'exchangeProduct-12-active': { content: "" }, + 'exchangeProduct-12-inactive': { content: "" }, + 'exchangeProduct-16-active': { content: "" }, + 'exchangeProduct-16-inactive': { content: "" }, + 'exchangeProduct-24-active': { content: "" }, + 'exchangeProduct-24-inactive': { content: "" }, + 'exclamationMark-12-active': { content: "" }, + 'exclamationMark-12-inactive': { content: "" }, + 'exclamationMark-16-active': { content: "" }, + 'exclamationMark-16-inactive': { content: "" }, + 'exclamationMark-24-active': { content: "" }, + 'exclamationMark-24-inactive': { content: "" }, + 'expand-12-active': { content: "" }, + 'expand-12-inactive': { content: "" }, + 'expand-16-active': { content: "" }, + 'expand-16-inactive': { content: "" }, + 'expand-24-active': { content: "" }, + 'expand-24-inactive': { content: "" }, + 'expandAddress-12-active': { content: "" }, + 'expandAddress-12-inactive': { content: "" }, + 'expandAddress-16-active': { content: "" }, + 'expandAddress-16-inactive': { content: "" }, + 'expandAddress-24-active': { content: "" }, + 'expandAddress-24-inactive': { content: "" }, + 'expandAll-12-active': { content: "" }, + 'expandAll-12-inactive': { content: "" }, + 'expandAll-16-active': { content: "" }, + 'expandAll-16-inactive': { content: "" }, + 'expandAll-24-active': { content: "" }, + 'expandAll-24-inactive': { content: "" }, + 'externalLink-12-active': { content: "" }, + 'externalLink-12-inactive': { content: "" }, + 'externalLink-16-active': { content: "" }, + 'externalLink-16-inactive': { content: "" }, + 'externalLink-24-active': { content: "" }, + 'externalLink-24-inactive': { content: "" }, + 'eye-12-active': { content: "" }, + 'eye-12-inactive': { content: "" }, + 'eye-16-active': { content: "" }, + 'eye-16-inactive': { content: "" }, + 'eye-24-active': { content: "" }, + 'eye-24-inactive': { content: "" }, + 'faces-12-active': { content: "" }, + 'faces-12-inactive': { content: "" }, + 'faces-16-active': { content: "" }, + 'faces-16-inactive': { content: "" }, + 'faces-24-active': { content: "" }, + 'faces-24-inactive': { content: "" }, + 'faceScan-12-active': { content: "" }, + 'faceScan-12-inactive': { content: "" }, + 'faceScan-16-active': { content: "" }, + 'faceScan-16-inactive': { content: "" }, + 'faceScan-24-active': { content: "" }, + 'faceScan-24-inactive': { content: "" }, + 'factory-12-active': { content: "" }, + 'factory-12-inactive': { content: "" }, + 'factory-16-active': { content: "" }, + 'factory-16-inactive': { content: "" }, + 'factory-24-active': { content: "" }, + 'factory-24-inactive': { content: "" }, + 'faucet-12-active': { content: "" }, + 'faucet-12-inactive': { content: "" }, + 'faucet-16-active': { content: "" }, + 'faucet-16-inactive': { content: "" }, + 'faucet-24-active': { content: "" }, + 'faucet-24-inactive': { content: "" }, + 'fib-12-active': { content: "" }, + 'fib-12-inactive': { content: "" }, + 'fib-16-active': { content: "" }, + 'fib-16-inactive': { content: "" }, + 'fib-24-active': { content: "" }, + 'fib-24-inactive': { content: "" }, + 'filmStrip-12-active': { content: "" }, + 'filmStrip-12-inactive': { content: "" }, + 'filmStrip-16-active': { content: "" }, + 'filmStrip-16-inactive': { content: "" }, + 'filmStrip-24-active': { content: "" }, + 'filmStrip-24-inactive': { content: "" }, + 'filter-12-active': { content: "" }, + 'filter-12-inactive': { content: "" }, + 'filter-16-active': { content: "" }, + 'filter-16-inactive': { content: "" }, + 'filter-24-active': { content: "" }, + 'filter-24-inactive': { content: "" }, + 'fingerprint-12-active': { content: "" }, + 'fingerprint-12-inactive': { content: "" }, + 'fingerprint-16-active': { content: "" }, + 'fingerprint-16-inactive': { content: "" }, + 'fingerprint-24-active': { content: "" }, + 'fingerprint-24-inactive': { content: "" }, + 'flame-12-active': { content: "" }, + 'flame-12-inactive': { content: "" }, + 'flame-16-active': { content: "" }, + 'flame-16-inactive': { content: "" }, + 'flame-24-active': { content: "" }, + 'flame-24-inactive': { content: "" }, + 'folder-12-active': { content: "" }, + 'folder-12-inactive': { content: "" }, + 'folder-16-active': { content: "" }, + 'folder-16-inactive': { content: "" }, + 'folder-24-active': { content: "" }, + 'folder-24-inactive': { content: "" }, + 'folderArrow-12-active': { content: "" }, + 'folderArrow-12-inactive': { content: "" }, + 'folderArrow-16-active': { content: "" }, + 'folderArrow-16-inactive': { content: "" }, + 'folderArrow-24-active': { content: "" }, + 'folderArrow-24-inactive': { content: "" }, + 'folderOpen-12-active': { content: "" }, + 'folderOpen-12-inactive': { content: "" }, + 'folderOpen-16-active': { content: "" }, + 'folderOpen-16-inactive': { content: "" }, + 'folderOpen-24-active': { content: "" }, + 'folderOpen-24-inactive': { content: "" }, + 'followAdd-12-active': { content: "" }, + 'followAdd-12-inactive': { content: "" }, + 'followAdd-16-active': { content: "" }, + 'followAdd-16-inactive': { content: "" }, + 'followAdd-24-active': { content: "" }, + 'followAdd-24-inactive': { content: "" }, + 'following-12-active': { content: "" }, + 'following-12-inactive': { content: "" }, + 'following-16-active': { content: "" }, + 'following-16-inactive': { content: "" }, + 'following-24-active': { content: "" }, + 'following-24-inactive': { content: "" }, + 'football-12-active': { content: "" }, + 'football-12-inactive': { content: "" }, + 'football-16-active': { content: "" }, + 'football-16-inactive': { content: "" }, + 'football-24-active': { content: "" }, + 'football-24-inactive': { content: "" }, + 'fork-12-active': { content: "" }, + 'fork-12-inactive': { content: "" }, + 'fork-16-active': { content: "" }, + 'fork-16-inactive': { content: "" }, + 'fork-24-active': { content: "" }, + 'fork-24-inactive': { content: "" }, + 'forwardArrow-12-active': { content: "" }, + 'forwardArrow-12-inactive': { content: "" }, + 'forwardArrow-16-active': { content: "" }, + 'forwardArrow-16-inactive': { content: "" }, + 'forwardArrow-24-active': { content: "" }, + 'forwardArrow-24-inactive': { content: "" }, + 'fscsProtection-12-active': { content: "" }, + 'fscsProtection-12-inactive': { content: "" }, + 'fscsProtection-16-active': { content: "" }, + 'fscsProtection-16-inactive': { content: "" }, + 'fscsProtection-24-active': { content: "" }, + 'fscsProtection-24-inactive': { content: "" }, + 'gab-12-active': { content: "" }, + 'gab-12-inactive': { content: "" }, + 'gab-16-active': { content: "" }, + 'gab-16-inactive': { content: "" }, + 'gab-24-active': { content: "" }, + 'gab-24-inactive': { content: "" }, + 'games-12-active': { content: "" }, + 'games-12-inactive': { content: "" }, + 'games-16-active': { content: "" }, + 'games-16-inactive': { content: "" }, + 'games-24-active': { content: "" }, + 'games-24-inactive': { content: "" }, + 'gaming-12-active': { content: "" }, + 'gaming-12-inactive': { content: "" }, + 'gaming-16-active': { content: "" }, + 'gaming-16-inactive': { content: "" }, + 'gaming-24-active': { content: "" }, + 'gaming-24-inactive': { content: "" }, + 'gasFees-12-active': { content: "" }, + 'gasFees-12-inactive': { content: "" }, + 'gasFees-16-active': { content: "" }, + 'gasFees-16-inactive': { content: "" }, + 'gasFees-24-active': { content: "" }, + 'gasFees-24-inactive': { content: "" }, + 'gasFeesAlt-12-active': { content: "" }, + 'gasFeesAlt-12-inactive': { content: "" }, + 'gasFeesAlt-16-active': { content: "" }, + 'gasFeesAlt-16-inactive': { content: "" }, + 'gasFeesAlt-24-active': { content: "" }, + 'gasFeesAlt-24-inactive': { content: "" }, + 'gauge-12-active': { content: "" }, + 'gauge-12-inactive': { content: "" }, + 'gauge-16-active': { content: "" }, + 'gauge-16-inactive': { content: "" }, + 'gauge-24-active': { content: "" }, + 'gauge-24-inactive': { content: "" }, + 'gaugeEmpty-12-active': { content: "" }, + 'gaugeEmpty-12-inactive': { content: "" }, + 'gaugeEmpty-16-active': { content: "" }, + 'gaugeEmpty-16-inactive': { content: "" }, + 'gaugeEmpty-24-active': { content: "" }, + 'gaugeEmpty-24-inactive': { content: "" }, + 'gaugeHigh-12-active': { content: "" }, + 'gaugeHigh-12-inactive': { content: "" }, + 'gaugeHigh-16-active': { content: "" }, + 'gaugeHigh-16-inactive': { content: "" }, + 'gaugeHigh-24-active': { content: "" }, + 'gaugeHigh-24-inactive': { content: "" }, + 'gaugeHighLow-12-active': { content: "" }, + 'gaugeHighLow-12-inactive': { content: "" }, + 'gaugeHighLow-16-active': { content: "" }, + 'gaugeHighLow-16-inactive': { content: "" }, + 'gaugeHighLow-24-active': { content: "" }, + 'gaugeHighLow-24-inactive': { content: "" }, + 'gaugeHighMid-12-active': { content: "" }, + 'gaugeHighMid-12-inactive': { content: "" }, + 'gaugeHighMid-16-active': { content: "" }, + 'gaugeHighMid-16-inactive': { content: "" }, + 'gaugeHighMid-24-active': { content: "" }, + 'gaugeHighMid-24-inactive': { content: "" }, + 'gaugeLow-12-active': { content: "" }, + 'gaugeLow-12-inactive': { content: "" }, + 'gaugeLow-16-active': { content: "" }, + 'gaugeLow-16-inactive': { content: "" }, + 'gaugeLow-24-active': { content: "" }, + 'gaugeLow-24-inactive': { content: "" }, + 'gaugeLowHigh-12-active': { content: "" }, + 'gaugeLowHigh-12-inactive': { content: "" }, + 'gaugeLowHigh-16-active': { content: "" }, + 'gaugeLowHigh-16-inactive': { content: "" }, + 'gaugeLowHigh-24-active': { content: "" }, + 'gaugeLowHigh-24-inactive': { content: "" }, + 'gaugeLowMid-12-active': { content: "" }, + 'gaugeLowMid-12-inactive': { content: "" }, + 'gaugeLowMid-16-active': { content: "" }, + 'gaugeLowMid-16-inactive': { content: "" }, + 'gaugeLowMid-24-active': { content: "" }, + 'gaugeLowMid-24-inactive': { content: "" }, + 'gaugeMedium-12-active': { content: "" }, + 'gaugeMedium-12-inactive': { content: "" }, + 'gaugeMedium-16-active': { content: "" }, + 'gaugeMedium-16-inactive': { content: "" }, + 'gaugeMedium-24-active': { content: "" }, + 'gaugeMedium-24-inactive': { content: "" }, + 'gavel-12-active': { content: "" }, + 'gavel-12-inactive': { content: "" }, + 'gavel-16-active': { content: "" }, + 'gavel-16-inactive': { content: "" }, + 'gavel-24-active': { content: "" }, + 'gavel-24-inactive': { content: "" }, + 'gear-12-active': { content: "" }, + 'gear-12-inactive': { content: "" }, + 'gear-16-active': { content: "" }, + 'gear-16-inactive': { content: "" }, + 'gear-24-active': { content: "" }, + 'gear-24-inactive': { content: "" }, + 'generalCharacter-12-active': { content: "" }, + 'generalCharacter-12-inactive': { content: "" }, + 'generalCharacter-16-active': { content: "" }, + 'generalCharacter-16-inactive': { content: "" }, + 'generalCharacter-24-active': { content: "" }, + 'generalCharacter-24-inactive': { content: "" }, + 'ghost-12-active': { content: "" }, + 'ghost-12-inactive': { content: "" }, + 'ghost-16-active': { content: "" }, + 'ghost-16-inactive': { content: "" }, + 'ghost-24-active': { content: "" }, + 'ghost-24-inactive': { content: "" }, + 'gif-12-active': { content: "" }, + 'gif-12-inactive': { content: "" }, + 'gif-16-active': { content: "" }, + 'gif-16-inactive': { content: "" }, + 'gif-24-active': { content: "" }, + 'gif-24-inactive': { content: "" }, + 'giftBox-12-active': { content: "" }, + 'giftBox-12-inactive': { content: "" }, + 'giftBox-16-active': { content: "" }, + 'giftBox-16-inactive': { content: "" }, + 'giftBox-24-active': { content: "" }, + 'giftBox-24-inactive': { content: "" }, + 'giftCard-12-active': { content: "" }, + 'giftCard-12-inactive': { content: "" }, + 'giftCard-16-active': { content: "" }, + 'giftCard-16-inactive': { content: "" }, + 'giftCard-24-active': { content: "" }, + 'giftCard-24-inactive': { content: "" }, + 'gitHubLogo-12-active': { content: "" }, + 'gitHubLogo-12-inactive': { content: "" }, + 'gitHubLogo-16-active': { content: "" }, + 'gitHubLogo-16-inactive': { content: "" }, + 'gitHubLogo-24-active': { content: "" }, + 'gitHubLogo-24-inactive': { content: "" }, + 'globe-12-active': { content: "" }, + 'globe-12-inactive': { content: "" }, + 'globe-16-active': { content: "" }, + 'globe-16-inactive': { content: "" }, + 'globe-24-active': { content: "" }, + 'globe-24-inactive': { content: "" }, + 'golf-12-active': { content: "" }, + 'golf-12-inactive': { content: "" }, + 'golf-16-active': { content: "" }, + 'golf-16-inactive': { content: "" }, + 'golf-24-active': { content: "" }, + 'golf-24-inactive': { content: "" }, + 'googleLogo-12-active': { content: "" }, + 'googleLogo-12-inactive': { content: "" }, + 'googleLogo-16-active': { content: "" }, + 'googleLogo-16-inactive': { content: "" }, + 'googleLogo-24-active': { content: "" }, + 'googleLogo-24-inactive': { content: "" }, + 'greenEnergy-12-active': { content: "" }, + 'greenEnergy-12-inactive': { content: "" }, + 'greenEnergy-16-active': { content: "" }, + 'greenEnergy-16-inactive': { content: "" }, + 'greenEnergy-24-active': { content: "" }, + 'greenEnergy-24-inactive': { content: "" }, + 'grid-12-active': { content: "" }, + 'grid-12-inactive': { content: "" }, + 'grid-16-active': { content: "" }, + 'grid-16-inactive': { content: "" }, + 'grid-24-active': { content: "" }, + 'grid-24-inactive': { content: "" }, + 'group-12-active': { content: "" }, + 'group-12-inactive': { content: "" }, + 'group-16-active': { content: "" }, + 'group-16-inactive': { content: "" }, + 'group-24-active': { content: "" }, + 'group-24-inactive': { content: "" }, + 'hamburger-12-active': { content: "" }, + 'hamburger-12-inactive': { content: "" }, + 'hamburger-16-active': { content: "" }, + 'hamburger-16-inactive': { content: "" }, + 'hamburger-24-active': { content: "" }, + 'hamburger-24-inactive': { content: "" }, + 'hammer-12-active': { content: "" }, + 'hammer-12-inactive': { content: "" }, + 'hammer-16-active': { content: "" }, + 'hammer-16-inactive': { content: "" }, + 'hammer-24-active': { content: "" }, + 'hammer-24-inactive': { content: "" }, + 'heart-12-active': { content: "" }, + 'heart-12-inactive': { content: "" }, + 'heart-16-active': { content: "" }, + 'heart-16-inactive': { content: "" }, + 'heart-24-active': { content: "" }, + 'heart-24-inactive': { content: "" }, + 'helpCenterProduct-12-active': { content: "" }, + 'helpCenterProduct-12-inactive': { content: "" }, + 'helpCenterProduct-16-active': { content: "" }, + 'helpCenterProduct-16-inactive': { content: "" }, + 'helpCenterProduct-24-active': { content: "" }, + 'helpCenterProduct-24-inactive': { content: "" }, + 'helpCenterQuestionMark-12-active': { content: "" }, + 'helpCenterQuestionMark-12-inactive': { content: "" }, + 'helpCenterQuestionMark-16-active': { content: "" }, + 'helpCenterQuestionMark-16-inactive': { content: "" }, + 'helpCenterQuestionMark-24-active': { content: "" }, + 'helpCenterQuestionMark-24-inactive': { content: "" }, + 'hiddenEye-12-active': { content: "" }, + 'hiddenEye-12-inactive': { content: "" }, + 'hiddenEye-16-active': { content: "" }, + 'hiddenEye-16-inactive': { content: "" }, + 'hiddenEye-24-active': { content: "" }, + 'hiddenEye-24-inactive': { content: "" }, + 'hockey-12-active': { content: "" }, + 'hockey-12-inactive': { content: "" }, + 'hockey-16-active': { content: "" }, + 'hockey-16-inactive': { content: "" }, + 'hockey-24-active': { content: "" }, + 'hockey-24-inactive': { content: "" }, + 'home-12-active': { content: "" }, + 'home-12-inactive': { content: "" }, + 'home-16-active': { content: "" }, + 'home-16-inactive': { content: "" }, + 'home-24-active': { content: "" }, + 'home-24-inactive': { content: "" }, + 'horizontalLine-12-active': { content: "" }, + 'horizontalLine-12-inactive': { content: "" }, + 'horizontalLine-16-active': { content: "" }, + 'horizontalLine-16-inactive': { content: "" }, + 'horizontalLine-24-active': { content: "" }, + 'horizontalLine-24-inactive': { content: "" }, + 'hospital-12-active': { content: "" }, + 'hospital-12-inactive': { content: "" }, + 'hospital-16-active': { content: "" }, + 'hospital-16-inactive': { content: "" }, + 'hospital-24-active': { content: "" }, + 'hospital-24-inactive': { content: "" }, + 'hospitalCross-12-active': { content: "" }, + 'hospitalCross-12-inactive': { content: "" }, + 'hospitalCross-16-active': { content: "" }, + 'hospitalCross-16-inactive': { content: "" }, + 'hospitalCross-24-active': { content: "" }, + 'hospitalCross-24-inactive': { content: "" }, + 'hurricane-12-active': { content: "" }, + 'hurricane-12-inactive': { content: "" }, + 'hurricane-16-active': { content: "" }, + '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: "" }, + 'identityCard-12-active': { content: "" }, + 'identityCard-12-inactive': { content: "" }, + 'identityCard-16-active': { content: "" }, + 'identityCard-16-inactive': { content: "" }, + 'identityCard-24-active': { content: "" }, + 'identityCard-24-inactive': { content: "" }, + 'image-12-active': { content: "" }, + 'image-12-inactive': { content: "" }, + 'image-16-active': { content: "" }, + 'image-16-inactive': { content: "" }, + 'image-24-active': { content: "" }, + 'image-24-inactive': { content: "" }, + 'info-12-active': { content: "" }, + 'info-12-inactive': { content: "" }, + 'info-16-active': { content: "" }, + 'info-16-inactive': { content: "" }, + 'info-24-active': { content: "" }, + 'info-24-inactive': { content: "" }, + 'initiator-12-active': { content: "" }, + 'initiator-12-inactive': { content: "" }, + 'initiator-16-active': { content: "" }, + 'initiator-16-inactive': { content: "" }, + 'initiator-24-active': { content: "" }, + 'initiator-24-inactive': { content: "" }, + 'instagramLogo-12-active': { content: "" }, + 'instagramLogo-12-inactive': { content: "" }, + 'instagramLogo-16-active': { content: "" }, + 'instagramLogo-16-inactive': { content: "" }, + 'instagramLogo-24-active': { content: "" }, + 'instagramLogo-24-inactive': { content: "" }, + 'instantUnstakingClock-12-active': { content: "" }, + 'instantUnstakingClock-12-inactive': { content: "" }, + 'instantUnstakingClock-16-active': { content: "" }, + 'instantUnstakingClock-16-inactive': { content: "" }, + 'instantUnstakingClock-24-active': { content: "" }, + 'instantUnstakingClock-24-inactive': { content: "" }, + 'institute-12-active': { content: "" }, + 'institute-12-inactive': { content: "" }, + 'institute-16-active': { content: "" }, + 'institute-16-inactive': { content: "" }, + 'institute-24-active': { content: "" }, + 'institute-24-inactive': { content: "" }, + 'institutionalProduct-12-active': { content: "" }, + 'institutionalProduct-12-inactive': { content: "" }, + 'institutionalProduct-16-active': { content: "" }, + 'institutionalProduct-16-inactive': { content: "" }, + 'institutionalProduct-24-active': { content: "" }, + 'institutionalProduct-24-inactive': { content: "" }, + 'interest-12-active': { content: "" }, + 'interest-12-inactive': { content: "" }, + 'interest-16-active': { content: "" }, + 'interest-16-inactive': { content: "" }, + 'interest-24-active': { content: "" }, + 'interest-24-inactive': { content: "" }, + 'invisible-12-active': { content: "" }, + 'invisible-12-inactive': { content: "" }, + 'invisible-16-active': { content: "" }, + 'invisible-16-inactive': { content: "" }, + 'invisible-24-active': { content: "" }, + 'invisible-24-inactive': { content: "" }, + 'invoice-12-active': { content: "" }, + 'invoice-12-inactive': { content: "" }, + 'invoice-16-active': { content: "" }, + 'invoice-16-inactive': { content: "" }, + 'invoice-24-active': { content: "" }, + 'invoice-24-inactive': { content: "" }, + 'key-12-active': { content: "" }, + 'key-12-inactive': { content: "" }, + 'key-16-active': { content: "" }, + 'key-16-inactive': { content: "" }, + 'key-24-active': { content: "" }, + 'key-24-inactive': { content: "" }, + 'keyboard-12-active': { content: "" }, + 'keyboard-12-inactive': { content: "" }, + 'keyboard-16-active': { content: "" }, + 'keyboard-16-inactive': { content: "" }, + 'keyboard-24-active': { content: "" }, + 'keyboard-24-inactive': { content: "" }, + 'laptop-12-active': { content: "" }, + 'laptop-12-inactive': { content: "" }, + 'laptop-16-active': { content: "" }, + 'laptop-16-inactive': { content: "" }, + 'laptop-24-active': { content: "" }, + 'laptop-24-inactive': { content: "" }, + 'leadChart-12-active': { content: "" }, + 'leadChart-12-inactive': { content: "" }, + 'leadChart-16-active': { content: "" }, + 'leadChart-16-inactive': { content: "" }, + 'leadChart-24-active': { content: "" }, + 'leadChart-24-inactive': { content: "" }, + 'leadCoin-12-active': { content: "" }, + 'leadCoin-12-inactive': { content: "" }, + 'leadCoin-16-active': { content: "" }, + 'leadCoin-16-inactive': { content: "" }, + 'leadCoin-24-active': { content: "" }, + 'leadCoin-24-inactive': { content: "" }, + 'learningRewardsProduct-12-active': { content: "" }, + 'learningRewardsProduct-12-inactive': { content: "" }, + 'learningRewardsProduct-16-active': { content: "" }, + 'learningRewardsProduct-16-inactive': { content: "" }, + 'learningRewardsProduct-24-active': { content: "" }, + 'learningRewardsProduct-24-inactive': { content: "" }, + 'light-12-active': { content: "" }, + 'light-12-inactive': { content: "" }, + 'light-16-active': { content: "" }, + 'light-16-inactive': { content: "" }, + 'light-24-active': { content: "" }, + 'light-24-inactive': { content: "" }, + 'lightbulb-12-active': { content: "" }, + 'lightbulb-12-inactive': { content: "" }, + 'lightbulb-16-active': { content: "" }, + 'lightbulb-16-inactive': { content: "" }, + 'lightbulb-24-active': { content: "" }, + 'lightbulb-24-inactive': { content: "" }, + 'lightning-12-active': { content: "" }, + 'lightning-12-inactive': { content: "" }, + 'lightning-16-active': { content: "" }, + 'lightning-16-inactive': { content: "" }, + 'lightning-24-active': { content: "" }, + 'lightning-24-inactive': { content: "" }, + 'lightningBolt-12-active': { content: "" }, + 'lightningBolt-12-inactive': { content: "" }, + 'lightningBolt-16-active': { content: "" }, + 'lightningBolt-16-inactive': { content: "" }, + 'lightningBolt-24-active': { content: "" }, + 'lightningBolt-24-inactive': { content: "" }, + 'lineChartCrypto-12-active': { content: "" }, + 'lineChartCrypto-12-inactive': { content: "" }, + 'lineChartCrypto-16-active': { content: "" }, + 'lineChartCrypto-16-inactive': { content: "" }, + 'lineChartCrypto-24-active': { content: "" }, + 'lineChartCrypto-24-inactive': { content: "" }, + 'list-12-active': { content: "" }, + 'list-12-inactive': { content: "" }, + 'list-16-active': { content: "" }, + 'list-16-inactive': { content: "" }, + 'list-24-active': { content: "" }, + 'list-24-inactive': { content: "" }, + 'location-12-active': { content: "" }, + 'location-12-inactive': { content: "" }, + 'location-16-active': { content: "" }, + 'location-16-inactive': { content: "" }, + 'location-24-active': { content: "" }, + 'location-24-inactive': { content: "" }, + 'lock-12-active': { content: "" }, + 'lock-12-inactive': { content: "" }, + 'lock-16-active': { content: "" }, + 'lock-16-inactive': { content: "" }, + 'lock-24-active': { content: "" }, + 'lock-24-inactive': { content: "" }, + 'login-12-active': { content: "" }, + 'login-12-inactive': { content: "" }, + 'login-16-active': { content: "" }, + 'login-16-inactive': { content: "" }, + 'login-24-active': { content: "" }, + 'login-24-inactive': { content: "" }, + 'logout-12-active': { content: "" }, + 'logout-12-inactive': { content: "" }, + 'logout-16-active': { content: "" }, + 'logout-16-inactive': { content: "" }, + 'logout-24-active': { content: "" }, + 'logout-24-inactive': { content: "" }, + 'loop-12-active': { content: "" }, + 'loop-12-inactive': { content: "" }, + 'loop-16-active': { content: "" }, + 'loop-16-inactive': { content: "" }, + 'loop-24-active': { content: "" }, + 'loop-24-inactive': { content: "" }, + 'magnifyingGlass-12-active': { content: "" }, + 'magnifyingGlass-12-inactive': { content: "" }, + 'magnifyingGlass-16-active': { content: "" }, + 'magnifyingGlass-16-inactive': { content: "" }, + 'magnifyingGlass-24-active': { content: "" }, + 'magnifyingGlass-24-inactive': { content: "" }, + 'marketCap-12-active': { content: "" }, + 'marketCap-12-inactive': { content: "" }, + 'marketCap-16-active': { content: "" }, + 'marketCap-16-inactive': { content: "" }, + 'marketCap-24-active': { content: "" }, + 'marketCap-24-inactive': { content: "" }, + 'medal-12-active': { content: "" }, + 'medal-12-inactive': { content: "" }, + 'medal-16-active': { content: "" }, + 'medal-16-inactive': { content: "" }, + 'medal-24-active': { content: "" }, + 'medal-24-inactive': { content: "" }, + 'megaphone-12-active': { content: "" }, + 'megaphone-12-inactive': { content: "" }, + 'megaphone-16-active': { content: "" }, + 'megaphone-16-inactive': { content: "" }, + 'megaphone-24-active': { content: "" }, + 'megaphone-24-inactive': { content: "" }, + 'menu-12-active': { content: "" }, + 'menu-12-inactive': { content: "" }, + 'menu-16-active': { content: "" }, + 'menu-16-inactive': { content: "" }, + 'menu-24-active': { content: "" }, + 'menu-24-inactive': { content: "" }, + 'metaverse-12-active': { content: "" }, + 'metaverse-12-inactive': { content: "" }, + 'metaverse-16-active': { content: "" }, + 'metaverse-16-inactive': { content: "" }, + 'metaverse-24-active': { content: "" }, + 'metaverse-24-inactive': { content: "" }, + 'microphone-12-active': { content: "" }, + 'microphone-12-inactive': { content: "" }, + 'microphone-16-active': { content: "" }, + 'microphone-16-inactive': { content: "" }, + 'microphone-24-active': { content: "" }, + 'microphone-24-inactive': { content: "" }, + 'microphoneCordless-12-active': { content: "" }, + 'microphoneCordless-12-inactive': { content: "" }, + 'microphoneCordless-16-active': { content: "" }, + 'microphoneCordless-16-inactive': { content: "" }, + 'microphoneCordless-24-active': { content: "" }, + 'microphoneCordless-24-inactive': { content: "" }, + 'microscope-12-active': { content: "" }, + 'microscope-12-inactive': { content: "" }, + 'microscope-16-active': { content: "" }, + 'microscope-16-inactive': { content: "" }, + 'microscope-24-active': { content: "" }, + 'microscope-24-inactive': { content: "" }, + 'mint-12-active': { content: "" }, + 'mint-12-inactive': { content: "" }, + 'mint-16-active': { content: "" }, + 'mint-16-inactive': { content: "" }, + 'mint-24-active': { content: "" }, + 'mint-24-inactive': { content: "" }, + 'minus-12-active': { content: "" }, + 'minus-12-inactive': { content: "" }, + 'minus-16-active': { content: "" }, + 'minus-16-inactive': { content: "" }, + 'minus-24-active': { content: "" }, + 'minus-24-inactive': { content: "" }, + 'mma-12-active': { content: "" }, + 'mma-12-inactive': { content: "" }, + 'mma-16-active': { content: "" }, + 'mma-16-inactive': { content: "" }, + 'mma-24-active': { content: "" }, + 'mma-24-inactive': { content: "" }, + 'moneyCardCoin-12-active': { content: "" }, + 'moneyCardCoin-12-inactive': { content: "" }, + 'moneyCardCoin-16-active': { content: "" }, + 'moneyCardCoin-16-inactive': { content: "" }, + 'moneyCardCoin-24-active': { content: "" }, + 'moneyCardCoin-24-inactive': { content: "" }, + 'moon-12-active': { content: "" }, + 'moon-12-inactive': { content: "" }, + 'moon-16-active': { content: "" }, + 'moon-16-inactive': { content: "" }, + 'moon-24-active': { content: "" }, + 'moon-24-inactive': { content: "" }, + 'more-12-active': { content: "" }, + 'more-12-inactive': { content: "" }, + 'more-16-active': { content: "" }, + 'more-16-inactive': { content: "" }, + 'more-24-active': { content: "" }, + 'more-24-inactive': { content: "" }, + 'moreVertical-12-active': { content: "" }, + 'moreVertical-12-inactive': { content: "" }, + 'moreVertical-16-active': { content: "" }, + 'moreVertical-16-inactive': { content: "" }, + 'moreVertical-24-active': { content: "" }, + 'moreVertical-24-inactive': { content: "" }, + 'motorsport-12-active': { content: "" }, + 'motorsport-12-inactive': { content: "" }, + 'motorsport-16-active': { content: "" }, + 'motorsport-16-inactive': { content: "" }, + 'motorsport-24-active': { content: "" }, + 'motorsport-24-inactive': { content: "" }, + 'music-12-active': { content: "" }, + 'music-12-inactive': { content: "" }, + 'music-16-active': { content: "" }, + 'music-16-inactive': { content: "" }, + 'music-24-active': { content: "" }, + 'music-24-inactive': { content: "" }, + 'musicArticles-12-active': { content: "" }, + 'musicArticles-12-inactive': { content: "" }, + 'musicArticles-16-active': { content: "" }, + 'musicArticles-16-inactive': { content: "" }, + 'musicArticles-24-active': { content: "" }, + 'musicArticles-24-inactive': { content: "" }, + 'needle-12-active': { content: "" }, + 'needle-12-inactive': { content: "" }, + 'needle-16-active': { content: "" }, + 'needle-16-inactive': { content: "" }, + 'needle-24-active': { content: "" }, + 'needle-24-inactive': { content: "" }, + 'newsFeed-12-active': { content: "" }, + 'newsFeed-12-inactive': { content: "" }, + 'newsFeed-16-active': { content: "" }, + 'newsFeed-16-inactive': { content: "" }, + 'newsFeed-24-active': { content: "" }, + 'newsFeed-24-inactive': { content: "" }, + 'newsletter-12-active': { content: "" }, + 'newsletter-12-inactive': { content: "" }, + 'newsletter-16-active': { content: "" }, + 'newsletter-16-inactive': { content: "" }, + 'newsletter-24-active': { content: "" }, + 'newsletter-24-inactive': { content: "" }, + 'nft-12-active': { content: "" }, + 'nft-12-inactive': { content: "" }, + 'nft-16-active': { content: "" }, + 'nft-16-inactive': { content: "" }, + 'nft-24-active': { content: "" }, + 'nft-24-inactive': { content: "" }, + 'nftBuy-12-active': { content: "" }, + 'nftBuy-12-inactive': { content: "" }, + 'nftBuy-16-active': { content: "" }, + 'nftBuy-16-inactive': { content: "" }, + 'nftBuy-24-active': { content: "" }, + 'nftBuy-24-inactive': { content: "" }, + 'nftOffer-12-active': { content: "" }, + 'nftOffer-12-inactive': { content: "" }, + 'nftOffer-16-active': { content: "" }, + 'nftOffer-16-inactive': { content: "" }, + 'nftOffer-24-active': { content: "" }, + 'nftOffer-24-inactive': { content: "" }, + 'nftProduct-12-active': { content: "" }, + 'nftProduct-12-inactive': { content: "" }, + 'nftProduct-16-active': { content: "" }, + 'nftProduct-16-inactive': { content: "" }, + 'nftProduct-24-active': { content: "" }, + 'nftProduct-24-inactive': { content: "" }, + 'nftSale-12-active': { content: "" }, + 'nftSale-12-inactive': { content: "" }, + 'nftSale-16-active': { content: "" }, + 'nftSale-16-inactive': { content: "" }, + 'nftSale-24-active': { content: "" }, + 'nftSale-24-inactive': { content: "" }, + 'nodeProduct-12-active': { content: "" }, + 'nodeProduct-12-inactive': { content: "" }, + 'nodeProduct-16-active': { content: "" }, + 'nodeProduct-16-inactive': { content: "" }, + 'nodeProduct-24-active': { content: "" }, + 'nodeProduct-24-inactive': { content: "" }, + 'noRocket-12-active': { content: "" }, + 'noRocket-12-inactive': { content: "" }, + 'noRocket-16-active': { content: "" }, + 'noRocket-16-inactive': { content: "" }, + 'noRocket-24-active': { content: "" }, + 'noRocket-24-inactive': { content: "" }, + 'noWifi-12-active': { content: "" }, + 'noWifi-12-inactive': { content: "" }, + 'noWifi-16-active': { content: "" }, + 'noWifi-16-inactive': { content: "" }, + 'noWifi-24-active': { content: "" }, + 'noWifi-24-inactive': { content: "" }, + 'oil-12-active': { content: "" }, + 'oil-12-inactive': { content: "" }, + 'oil-16-active': { content: "" }, + 'oil-16-inactive': { content: "" }, + 'oil-24-active': { content: "" }, + 'oil-24-inactive': { content: "" }, + 'options-12-active': { content: "" }, + 'options-12-inactive': { content: "" }, + 'options-16-active': { content: "" }, + 'options-16-inactive': { content: "" }, + 'options-24-active': { content: "" }, + 'options-24-inactive': { content: "" }, + 'orderBook-12-active': { content: "" }, + 'orderBook-12-inactive': { content: "" }, + 'orderBook-16-active': { content: "" }, + 'orderBook-16-inactive': { content: "" }, + 'orderBook-24-active': { content: "" }, + 'orderBook-24-inactive': { content: "" }, + 'orderHistory-12-active': { content: "" }, + 'orderHistory-12-inactive': { content: "" }, + 'orderHistory-16-active': { content: "" }, + 'orderHistory-16-inactive': { content: "" }, + 'orderHistory-24-active': { content: "" }, + 'orderHistory-24-inactive': { content: "" }, + 'outline-12-active': { content: "" }, + 'outline-12-inactive': { content: "" }, + 'outline-16-active': { content: "" }, + 'outline-16-inactive': { content: "" }, + 'outline-24-active': { content: "" }, + 'outline-24-inactive': { content: "" }, + 'paperAirplane-12-active': { content: "" }, + 'paperAirplane-12-inactive': { content: "" }, + 'paperAirplane-16-active': { content: "" }, + 'paperAirplane-16-inactive': { content: "" }, + 'paperAirplane-24-active': { content: "" }, + 'paperAirplane-24-inactive': { content: "" }, + 'paperclip-12-active': { content: "" }, + 'paperclip-12-inactive': { content: "" }, + 'paperclip-16-active': { content: "" }, + 'paperclip-16-inactive': { content: "" }, + 'paperclip-24-active': { content: "" }, + 'paperclip-24-inactive': { content: "" }, + 'participate-12-active': { content: "" }, + 'participate-12-inactive': { content: "" }, + 'participate-16-active': { content: "" }, + 'participate-16-inactive': { content: "" }, + 'participate-24-active': { content: "" }, + 'participate-24-inactive': { content: "" }, + 'participateProduct-12-active': { content: "" }, + 'participateProduct-12-inactive': { content: "" }, + 'participateProduct-16-active': { content: "" }, + 'participateProduct-16-inactive': { content: "" }, + 'participateProduct-24-active': { content: "" }, + 'participateProduct-24-inactive': { content: "" }, + 'passKey-12-active': { content: "" }, + 'passKey-12-inactive': { content: "" }, + 'passKey-16-active': { content: "" }, + 'passKey-16-inactive': { content: "" }, + 'passKey-24-active': { content: "" }, + 'passKey-24-inactive': { content: "" }, + 'passport-12-active': { content: "" }, + 'passport-12-inactive': { content: "" }, + 'passport-16-active': { content: "" }, + 'passport-16-inactive': { content: "" }, + 'passport-24-active': { content: "" }, + 'passport-24-inactive': { content: "" }, + 'pause-12-active': { content: "" }, + 'pause-12-inactive': { content: "" }, + 'pause-16-active': { content: "" }, + 'pause-16-inactive': { content: "" }, + 'pause-24-active': { content: "" }, + 'pause-24-inactive': { content: "" }, + 'pay-12-active': { content: "" }, + 'pay-12-inactive': { content: "" }, + 'pay-16-active': { content: "" }, + 'pay-16-inactive': { content: "" }, + 'pay-24-active': { content: "" }, + 'pay-24-inactive': { content: "" }, + 'paymentCard-12-active': { content: "" }, + 'paymentCard-12-inactive': { content: "" }, + 'paymentCard-16-active': { content: "" }, + 'paymentCard-16-inactive': { content: "" }, + 'paymentCard-24-active': { content: "" }, + 'paymentCard-24-inactive': { content: "" }, + 'payments-12-active': { content: "" }, + 'payments-12-inactive': { content: "" }, + 'payments-16-active': { content: "" }, + 'payments-16-inactive': { content: "" }, + 'payments-24-active': { content: "" }, + 'payments-24-inactive': { content: "" }, + 'payouts-12-active': { content: "" }, + 'payouts-12-inactive': { content: "" }, + 'payouts-16-active': { content: "" }, + 'payouts-16-inactive': { content: "" }, + 'payouts-24-active': { content: "" }, + 'payouts-24-inactive': { content: "" }, + 'paypal-12-active': { content: "" }, + 'paypal-12-inactive': { content: "" }, + 'paypal-16-active': { content: "" }, + 'paypal-16-inactive': { content: "" }, + 'paypal-24-active': { content: "" }, + 'paypal-24-inactive': { content: "" }, + 'payProduct-12-active': { content: "" }, + 'payProduct-12-inactive': { content: "" }, + 'payProduct-16-active': { content: "" }, + 'payProduct-16-inactive': { content: "" }, + 'payProduct-24-active': { content: "" }, + 'payProduct-24-inactive': { content: "" }, + 'pencil-12-active': { content: "" }, + 'pencil-12-inactive': { content: "" }, + 'pencil-16-active': { content: "" }, + 'pencil-16-inactive': { content: "" }, + 'pencil-24-active': { content: "" }, + 'pencil-24-inactive': { content: "" }, + 'peopleGroup-12-active': { content: "" }, + 'peopleGroup-12-inactive': { content: "" }, + 'peopleGroup-16-active': { content: "" }, + 'peopleGroup-16-inactive': { content: "" }, + 'peopleGroup-24-active': { content: "" }, + 'peopleGroup-24-inactive': { content: "" }, + 'peopleStar-12-active': { content: "" }, + 'peopleStar-12-inactive': { content: "" }, + 'peopleStar-16-active': { content: "" }, + 'peopleStar-16-inactive': { content: "" }, + 'peopleStar-24-active': { content: "" }, + 'peopleStar-24-inactive': { content: "" }, + 'percentage-12-active': { content: "" }, + 'percentage-12-inactive': { content: "" }, + 'percentage-16-active': { content: "" }, + 'percentage-16-inactive': { content: "" }, + 'percentage-24-active': { content: "" }, + 'percentage-24-inactive': { content: "" }, + 'perpetualSwap-12-active': { content: "" }, + 'perpetualSwap-12-inactive': { content: "" }, + 'perpetualSwap-16-active': { content: "" }, + 'perpetualSwap-16-inactive': { content: "" }, + 'perpetualSwap-24-active': { content: "" }, + 'perpetualSwap-24-inactive': { content: "" }, + 'pFPS-12-active': { content: "" }, + 'pFPS-12-inactive': { content: "" }, + 'pFPS-16-active': { content: "" }, + 'pFPS-16-inactive': { content: "" }, + 'pFPS-24-active': { content: "" }, + 'pFPS-24-inactive': { content: "" }, + 'phone-12-active': { content: "" }, + 'phone-12-inactive': { content: "" }, + 'phone-16-active': { content: "" }, + 'phone-16-inactive': { content: "" }, + 'phone-24-active': { content: "" }, + 'phone-24-inactive': { content: "" }, + 'pieChartData-12-active': { content: "" }, + 'pieChartData-12-inactive': { content: "" }, + 'pieChartData-16-active': { content: "" }, + 'pieChartData-16-inactive': { content: "" }, + 'pieChartData-24-active': { content: "" }, + 'pieChartData-24-inactive': { content: "" }, + 'pillBottle-12-active': { content: "" }, + 'pillBottle-12-inactive': { content: "" }, + 'pillBottle-16-active': { content: "" }, + 'pillBottle-16-inactive': { content: "" }, + 'pillBottle-24-active': { content: "" }, + 'pillBottle-24-inactive': { content: "" }, + 'pillCapsule-12-active': { content: "" }, + 'pillCapsule-12-inactive': { content: "" }, + 'pillCapsule-16-active': { content: "" }, + 'pillCapsule-16-inactive': { content: "" }, + 'pillCapsule-24-active': { content: "" }, + 'pillCapsule-24-inactive': { content: "" }, + 'pin-12-active': { content: "" }, + 'pin-12-inactive': { content: "" }, + 'pin-16-active': { content: "" }, + 'pin-16-inactive': { content: "" }, + 'pin-24-active': { content: "" }, + 'pin-24-inactive': { content: "" }, + 'plane-12-active': { content: "" }, + 'plane-12-inactive': { content: "" }, + 'plane-16-active': { content: "" }, + 'plane-16-inactive': { content: "" }, + 'plane-24-active': { content: "" }, + 'plane-24-inactive': { content: "" }, + 'planet-12-active': { content: "" }, + 'planet-12-inactive': { content: "" }, + 'planet-16-active': { content: "" }, + 'planet-16-inactive': { content: "" }, + 'planet-24-active': { content: "" }, + 'planet-24-inactive': { content: "" }, + 'play-12-active': { content: "" }, + 'play-12-inactive': { content: "" }, + 'play-16-active': { content: "" }, + 'play-16-inactive': { content: "" }, + 'play-24-active': { content: "" }, + 'play-24-inactive': { content: "" }, + 'playbutton-12-active': { content: "" }, + 'playbutton-12-inactive': { content: "" }, + 'playbutton-16-active': { content: "" }, + 'playbutton-16-inactive': { content: "" }, + 'playbutton-24-active': { content: "" }, + 'playbutton-24-inactive': { content: "" }, + 'plusMinus-12-active': { content: "" }, + 'plusMinus-12-inactive': { content: "" }, + 'plusMinus-16-active': { content: "" }, + 'plusMinus-16-inactive': { content: "" }, + 'plusMinus-24-active': { content: "" }, + 'plusMinus-24-inactive': { content: "" }, + 'podiumStar-12-active': { content: "" }, + 'podiumStar-12-inactive': { content: "" }, + 'podiumStar-16-active': { content: "" }, + 'podiumStar-16-inactive': { content: "" }, + 'podiumStar-24-active': { content: "" }, + 'podiumStar-24-inactive': { content: "" }, + 'politicsBuilding-12-active': { content: "" }, + 'politicsBuilding-12-inactive': { content: "" }, + 'politicsBuilding-16-active': { content: "" }, + 'politicsBuilding-16-inactive': { content: "" }, + 'politicsBuilding-24-active': { content: "" }, + 'politicsBuilding-24-inactive': { content: "" }, + 'politicsCandidate-12-active': { content: "" }, + 'politicsCandidate-12-inactive': { content: "" }, + 'politicsCandidate-16-active': { content: "" }, + 'politicsCandidate-16-inactive': { content: "" }, + 'politicsCandidate-24-active': { content: "" }, + 'politicsCandidate-24-inactive': { content: "" }, + 'politicsFlag-12-active': { content: "" }, + 'politicsFlag-12-inactive': { content: "" }, + 'politicsFlag-16-active': { content: "" }, + 'politicsFlag-16-inactive': { content: "" }, + 'politicsFlag-24-active': { content: "" }, + 'politicsFlag-24-inactive': { content: "" }, + 'politicsGavel-12-active': { content: "" }, + 'politicsGavel-12-inactive': { content: "" }, + 'politicsGavel-16-active': { content: "" }, + 'politicsGavel-16-inactive': { content: "" }, + 'politicsGavel-24-active': { content: "" }, + 'politicsGavel-24-inactive': { content: "" }, + 'politicsPodium-12-active': { content: "" }, + 'politicsPodium-12-inactive': { content: "" }, + 'politicsPodium-16-active': { content: "" }, + 'politicsPodium-16-inactive': { content: "" }, + 'politicsPodium-24-active': { content: "" }, + 'politicsPodium-24-inactive': { content: "" }, + 'politicsStar-12-active': { content: "" }, + 'politicsStar-12-inactive': { content: "" }, + 'politicsStar-16-active': { content: "" }, + 'politicsStar-16-inactive': { content: "" }, + 'politicsStar-24-active': { content: "" }, + 'politicsStar-24-inactive': { content: "" }, + 'powerTool-12-active': { content: "" }, + 'powerTool-12-inactive': { content: "" }, + 'powerTool-16-active': { content: "" }, + 'powerTool-16-inactive': { content: "" }, + 'powerTool-24-active': { content: "" }, + 'powerTool-24-inactive': { content: "" }, + 'priceAlerts-12-active': { content: "" }, + 'priceAlerts-12-inactive': { content: "" }, + 'priceAlerts-16-active': { content: "" }, + 'priceAlerts-16-inactive': { content: "" }, + 'priceAlerts-24-active': { content: "" }, + 'priceAlerts-24-inactive': { content: "" }, + 'priceAlertsCheck-12-active': { content: "" }, + 'priceAlertsCheck-12-inactive': { content: "" }, + 'priceAlertsCheck-16-active': { content: "" }, + 'priceAlertsCheck-16-inactive': { content: "" }, + 'priceAlertsCheck-24-active': { content: "" }, + 'priceAlertsCheck-24-inactive': { content: "" }, + 'primePoduct-12-active': { content: "" }, + 'primePoduct-12-inactive': { content: "" }, + 'primePoduct-16-active': { content: "" }, + 'primePoduct-16-inactive': { content: "" }, + 'primePoduct-24-active': { content: "" }, + 'primePoduct-24-inactive': { content: "" }, + 'privateClientProduct-12-active': { content: "" }, + 'privateClientProduct-12-inactive': { content: "" }, + 'privateClientProduct-16-active': { content: "" }, + 'privateClientProduct-16-inactive': { content: "" }, + 'privateClientProduct-24-active': { content: "" }, + 'privateClientProduct-24-inactive': { content: "" }, + 'profile-12-active': { content: "" }, + 'profile-12-inactive': { content: "" }, + 'profile-16-active': { content: "" }, + 'profile-16-inactive': { content: "" }, + 'profile-24-active': { content: "" }, + 'profile-24-inactive': { content: "" }, + 'proProduct-12-active': { content: "" }, + 'proProduct-12-inactive': { content: "" }, + 'proProduct-16-active': { content: "" }, + 'proProduct-16-inactive': { content: "" }, + 'proProduct-24-active': { content: "" }, + 'proProduct-24-inactive': { content: "" }, + 'protection-12-active': { content: "" }, + 'protection-12-inactive': { content: "" }, + 'protection-16-active': { content: "" }, + 'protection-16-inactive': { content: "" }, + 'protection-24-active': { content: "" }, + 'protection-24-inactive': { content: "" }, + 'pulse-12-active': { content: "" }, + 'pulse-12-inactive': { content: "" }, + 'pulse-16-active': { content: "" }, + 'pulse-16-inactive': { content: "" }, + 'pulse-24-active': { content: "" }, + 'pulse-24-inactive': { content: "" }, + 'pyramid-12-active': { content: "" }, + 'pyramid-12-inactive': { content: "" }, + 'pyramid-16-active': { content: "" }, + 'pyramid-16-inactive': { content: "" }, + 'pyramid-24-active': { content: "" }, + 'pyramid-24-inactive': { content: "" }, + 'qrCode-12-active': { content: "" }, + 'qrCode-12-inactive': { content: "" }, + 'qrCode-16-active': { content: "" }, + 'qrCode-16-inactive': { content: "" }, + 'qrCode-24-active': { content: "" }, + 'qrCode-24-inactive': { content: "" }, + 'qrCodeAlt-12-active': { content: "" }, + 'qrCodeAlt-12-inactive': { content: "" }, + 'qrCodeAlt-16-active': { content: "" }, + 'qrCodeAlt-16-inactive': { content: "" }, + 'qrCodeAlt-24-active': { content: "" }, + 'qrCodeAlt-24-inactive': { content: "" }, + 'queryTransact-12-active': { content: "" }, + 'queryTransact-12-inactive': { content: "" }, + 'queryTransact-16-active': { content: "" }, + 'queryTransact-16-inactive': { content: "" }, + 'queryTransact-24-active': { content: "" }, + 'queryTransact-24-inactive': { content: "" }, + 'questionMark-12-active': { content: "" }, + 'questionMark-12-inactive': { content: "" }, + 'questionMark-16-active': { content: "" }, + 'questionMark-16-inactive': { content: "" }, + 'questionMark-24-active': { content: "" }, + 'questionMark-24-inactive': { content: "" }, + 'quotation-12-active': { content: "" }, + 'quotation-12-inactive': { content: "" }, + 'quotation-16-active': { content: "" }, + 'quotation-16-inactive': { content: "" }, + 'quotation-24-active': { content: "" }, + 'quotation-24-inactive': { content: "" }, + 'rain-12-active': { content: "" }, + 'rain-12-inactive': { content: "" }, + 'rain-16-active': { content: "" }, + 'rain-16-inactive': { content: "" }, + 'rain-24-active': { content: "" }, + 'rain-24-inactive': { content: "" }, + 'ratingsCheck-12-active': { content: "" }, + 'ratingsCheck-12-inactive': { content: "" }, + 'ratingsCheck-16-active': { content: "" }, + 'ratingsCheck-16-inactive': { content: "" }, + 'ratingsCheck-24-active': { content: "" }, + 'ratingsCheck-24-inactive': { content: "" }, + 'ratingsChecks-12-active': { content: "" }, + 'ratingsChecks-12-inactive': { content: "" }, + 'ratingsChecks-16-active': { content: "" }, + 'ratingsChecks-16-inactive': { content: "" }, + 'ratingsChecks-24-active': { content: "" }, + 'ratingsChecks-24-inactive': { content: "" }, + 'ratingsStar-12-active': { content: "" }, + 'ratingsStar-12-inactive': { content: "" }, + 'ratingsStar-16-active': { content: "" }, + 'ratingsStar-16-inactive': { content: "" }, + 'ratingsStar-24-active': { content: "" }, + 'ratingsStar-24-inactive': { content: "" }, + 'reCenter-12-active': { content: "" }, + 'reCenter-12-inactive': { content: "" }, + 'reCenter-16-active': { content: "" }, + 'reCenter-16-inactive': { content: "" }, + 'reCenter-24-active': { content: "" }, + 'reCenter-24-inactive': { content: "" }, + 'rectangle-12-active': { content: "" }, + 'rectangle-12-inactive': { content: "" }, + 'rectangle-16-active': { content: "" }, + 'rectangle-16-inactive': { content: "" }, + 'rectangle-24-active': { content: "" }, + 'rectangle-24-inactive': { content: "" }, + 'recurring-12-active': { content: "" }, + 'recurring-12-inactive': { content: "" }, + 'recurring-16-active': { content: "" }, + 'recurring-16-inactive': { content: "" }, + 'recurring-24-active': { content: "" }, + 'recurring-24-inactive': { content: "" }, + 'refresh-12-active': { content: "" }, + 'refresh-12-inactive': { content: "" }, + 'refresh-16-active': { content: "" }, + 'refresh-16-inactive': { content: "" }, + 'refresh-24-active': { content: "" }, + 'refresh-24-inactive': { content: "" }, + 'regulated-12-active': { content: "" }, + 'regulated-12-inactive': { content: "" }, + 'regulated-16-active': { content: "" }, + 'regulated-16-inactive': { content: "" }, + 'regulated-24-active': { content: "" }, + 'regulated-24-inactive': { content: "" }, + 'regulatedFutures-12-active': { content: "" }, + 'regulatedFutures-12-inactive': { content: "" }, + 'regulatedFutures-16-active': { content: "" }, + 'regulatedFutures-16-inactive': { content: "" }, + 'regulatedFutures-24-active': { content: "" }, + 'regulatedFutures-24-inactive': { content: "" }, + 'report-12-active': { content: "" }, + 'report-12-inactive': { content: "" }, + 'report-16-active': { content: "" }, + 'report-16-inactive': { content: "" }, + 'report-24-active': { content: "" }, + 'report-24-inactive': { content: "" }, + 'rewardsProduct-12-active': { content: "" }, + 'rewardsProduct-12-inactive': { content: "" }, + 'rewardsProduct-16-active': { content: "" }, + 'rewardsProduct-16-inactive': { content: "" }, + 'rewardsProduct-24-active': { content: "" }, + 'rewardsProduct-24-inactive': { content: "" }, + 'ribbon-12-active': { content: "" }, + 'ribbon-12-inactive': { content: "" }, + 'ribbon-16-active': { content: "" }, + 'ribbon-16-inactive': { content: "" }, + 'ribbon-24-active': { content: "" }, + 'ribbon-24-inactive': { content: "" }, + 'robot-12-active': { content: "" }, + 'robot-12-inactive': { content: "" }, + 'robot-16-active': { content: "" }, + 'robot-16-inactive': { content: "" }, + 'robot-24-active': { content: "" }, + 'robot-24-inactive': { content: "" }, + 'rocket-12-active': { content: "" }, + 'rocket-12-inactive': { content: "" }, + 'rocket-16-active': { content: "" }, + 'rocket-16-inactive': { content: "" }, + 'rocket-24-active': { content: "" }, + 'rocket-24-inactive': { content: "" }, + 'rocketShip-12-active': { content: "" }, + 'rocketShip-12-inactive': { content: "" }, + 'rocketShip-16-active': { content: "" }, + 'rocketShip-16-inactive': { content: "" }, + 'rocketShip-24-active': { content: "" }, + 'rocketShip-24-inactive': { content: "" }, + 'rollingSpot-12-active': { content: "" }, + 'rollingSpot-12-inactive': { content: "" }, + 'rollingSpot-16-active': { content: "" }, + 'rollingSpot-16-inactive': { content: "" }, + 'rollingSpot-24-active': { content: "" }, + 'rollingSpot-24-inactive': { content: "" }, + 'rosettaProduct-12-active': { content: "" }, + 'rosettaProduct-12-inactive': { content: "" }, + 'rosettaProduct-16-active': { content: "" }, + 'rosettaProduct-16-inactive': { content: "" }, + 'rosettaProduct-24-active': { content: "" }, + 'rosettaProduct-24-inactive': { content: "" }, + 'rottenTomato-12-active': { content: "" }, + 'rottenTomato-12-inactive': { content: "" }, + 'rottenTomato-16-active': { content: "" }, + 'rottenTomato-16-inactive': { content: "" }, + 'rottenTomato-24-active': { content: "" }, + 'rottenTomato-24-inactive': { content: "" }, + 'royalty-12-active': { content: "" }, + 'royalty-12-inactive': { content: "" }, + 'royalty-16-active': { content: "" }, + 'royalty-16-inactive': { content: "" }, + 'royalty-24-active': { content: "" }, + 'royalty-24-inactive': { content: "" }, + 'safe-12-active': { content: "" }, + 'safe-12-inactive': { content: "" }, + 'safe-16-active': { content: "" }, + 'safe-16-inactive': { content: "" }, + 'safe-24-active': { content: "" }, + 'safe-24-inactive': { content: "" }, + 'save-12-active': { content: "" }, + 'save-12-inactive': { content: "" }, + 'save-16-active': { content: "" }, + 'save-16-inactive': { content: "" }, + 'save-24-active': { content: "" }, + 'save-24-inactive': { content: "" }, + 'savingsBank-12-active': { content: "" }, + 'savingsBank-12-inactive': { content: "" }, + 'savingsBank-16-active': { content: "" }, + 'savingsBank-16-inactive': { content: "" }, + 'savingsBank-24-active': { content: "" }, + 'savingsBank-24-inactive': { content: "" }, + 'scanQrCode-12-active': { content: "" }, + 'scanQrCode-12-inactive': { content: "" }, + 'scanQrCode-16-active': { content: "" }, + 'scanQrCode-16-inactive': { content: "" }, + 'scanQrCode-24-active': { content: "" }, + 'scanQrCode-24-inactive': { content: "" }, + 'scienceAtom-12-active': { content: "" }, + 'scienceAtom-12-inactive': { content: "" }, + 'scienceAtom-16-active': { content: "" }, + 'scienceAtom-16-inactive': { content: "" }, + 'scienceAtom-24-active': { content: "" }, + 'scienceAtom-24-inactive': { content: "" }, + 'scienceBeaker-12-active': { content: "" }, + 'scienceBeaker-12-inactive': { content: "" }, + 'scienceBeaker-16-active': { content: "" }, + 'scienceBeaker-16-inactive': { content: "" }, + 'scienceBeaker-24-active': { content: "" }, + 'scienceBeaker-24-inactive': { content: "" }, + 'scienceMoon-12-active': { content: "" }, + 'scienceMoon-12-inactive': { content: "" }, + 'scienceMoon-16-active': { content: "" }, + 'scienceMoon-16-inactive': { content: "" }, + 'scienceMoon-24-active': { content: "" }, + 'scienceMoon-24-inactive': { content: "" }, + 'search-12-active': { content: "" }, + 'search-12-inactive': { content: "" }, + 'search-16-active': { content: "" }, + 'search-16-inactive': { content: "" }, + 'search-24-active': { content: "" }, + 'search-24-inactive': { content: "" }, + 'securityKey-12-active': { content: "" }, + 'securityKey-12-inactive': { content: "" }, + 'securityKey-16-active': { content: "" }, + 'securityKey-16-inactive': { content: "" }, + 'securityKey-24-active': { content: "" }, + 'securityKey-24-inactive': { content: "" }, + 'securityShield-12-active': { content: "" }, + 'securityShield-12-inactive': { content: "" }, + 'securityShield-16-active': { content: "" }, + 'securityShield-16-inactive': { content: "" }, + 'securityShield-24-active': { content: "" }, + 'securityShield-24-inactive': { content: "" }, + 'seen-12-active': { content: "" }, + 'seen-12-inactive': { content: "" }, + 'seen-16-active': { content: "" }, + 'seen-16-inactive': { content: "" }, + 'seen-24-active': { content: "" }, + 'seen-24-inactive': { content: "" }, + 'sendReceive-12-active': { content: "" }, + 'sendReceive-12-inactive': { content: "" }, + 'sendReceive-16-active': { content: "" }, + 'sendReceive-16-inactive': { content: "" }, + 'sendReceive-24-active': { content: "" }, + 'sendReceive-24-inactive': { content: "" }, + 'setPinCode-12-active': { content: "" }, + 'setPinCode-12-inactive': { content: "" }, + 'setPinCode-16-active': { content: "" }, + 'setPinCode-16-inactive': { content: "" }, + 'setPinCode-24-active': { content: "" }, + 'setPinCode-24-inactive': { content: "" }, + 'settings-12-active': { content: "" }, + 'settings-12-inactive': { content: "" }, + 'settings-16-active': { content: "" }, + 'settings-16-inactive': { content: "" }, + 'settings-24-active': { content: "" }, + 'settings-24-inactive': { content: "" }, + 'share-12-active': { content: "" }, + 'share-12-inactive': { content: "" }, + 'share-16-active': { content: "" }, + 'share-16-inactive': { content: "" }, + 'share-24-active': { content: "" }, + 'share-24-inactive': { content: "" }, + 'shield-12-active': { content: "" }, + 'shield-12-inactive': { content: "" }, + 'shield-16-active': { content: "" }, + 'shield-16-inactive': { content: "" }, + 'shield-24-active': { content: "" }, + 'shield-24-inactive': { content: "" }, + 'shieldOutline-12-active': { content: "" }, + 'shieldOutline-12-inactive': { content: "" }, + 'shieldOutline-16-active': { content: "" }, + 'shieldOutline-16-inactive': { content: "" }, + 'shieldOutline-24-active': { content: "" }, + 'shieldOutline-24-inactive': { content: "" }, + 'shoe-12-active': { content: "" }, + 'shoe-12-inactive': { content: "" }, + 'shoe-16-active': { content: "" }, + 'shoe-16-inactive': { content: "" }, + 'shoe-24-active': { content: "" }, + 'shoe-24-inactive': { content: "" }, + 'shoppingCart-12-active': { content: "" }, + 'shoppingCart-12-inactive': { content: "" }, + 'shoppingCart-16-active': { content: "" }, + 'shoppingCart-16-inactive': { content: "" }, + 'shoppingCart-24-active': { content: "" }, + 'shoppingCart-24-inactive': { content: "" }, + 'signinProduct-12-active': { content: "" }, + 'signinProduct-12-inactive': { content: "" }, + 'signinProduct-16-active': { content: "" }, + 'signinProduct-16-inactive': { content: "" }, + 'signinProduct-24-active': { content: "" }, + 'signinProduct-24-inactive': { content: "" }, + 'singlecloud-12-active': { content: "" }, + 'singlecloud-12-inactive': { content: "" }, + 'singlecloud-16-active': { content: "" }, + 'singlecloud-16-inactive': { content: "" }, + 'singlecloud-24-active': { content: "" }, + 'singlecloud-24-inactive': { content: "" }, + 'singleCoin-12-active': { content: "" }, + 'singleCoin-12-inactive': { content: "" }, + 'singleCoin-16-active': { content: "" }, + 'singleCoin-16-inactive': { content: "" }, + 'singleCoin-24-active': { content: "" }, + 'singleCoin-24-inactive': { content: "" }, + 'singleNote-12-active': { content: "" }, + 'singleNote-12-inactive': { content: "" }, + 'singleNote-16-active': { content: "" }, + 'singleNote-16-inactive': { content: "" }, + 'singleNote-24-active': { content: "" }, + 'singleNote-24-inactive': { content: "" }, + 'smartContract-12-active': { content: "" }, + 'smartContract-12-inactive': { content: "" }, + 'smartContract-16-active': { content: "" }, + 'smartContract-16-inactive': { content: "" }, + 'smartContract-24-active': { content: "" }, + 'smartContract-24-inactive': { content: "" }, + 'snow-12-active': { content: "" }, + 'snow-12-inactive': { content: "" }, + 'snow-16-active': { content: "" }, + 'snow-16-inactive': { content: "" }, + 'snow-24-active': { content: "" }, + 'snow-24-inactive': { content: "" }, + 'soccer-12-active': { content: "" }, + 'soccer-12-inactive': { content: "" }, + 'soccer-16-active': { content: "" }, + 'soccer-16-inactive': { content: "" }, + 'soccer-24-active': { content: "" }, + 'soccer-24-inactive': { content: "" }, + 'socialChat-12-active': { content: "" }, + 'socialChat-12-inactive': { content: "" }, + 'socialChat-16-active': { content: "" }, + 'socialChat-16-inactive': { content: "" }, + 'socialChat-24-active': { content: "" }, + 'socialChat-24-inactive': { content: "" }, + 'socialReshare-12-active': { content: "" }, + 'socialReshare-12-inactive': { content: "" }, + 'socialReshare-16-active': { content: "" }, + 'socialReshare-16-inactive': { content: "" }, + 'socialReshare-24-active': { content: "" }, + 'socialReshare-24-inactive': { content: "" }, + 'socialShare-12-active': { content: "" }, + 'socialShare-12-inactive': { content: "" }, + 'socialShare-16-active': { content: "" }, + 'socialShare-16-inactive': { content: "" }, + 'socialShare-24-active': { content: "" }, + 'socialShare-24-inactive': { content: "" }, + 'sofort-12-active': { content: "" }, + 'sofort-12-inactive': { content: "" }, + 'sofort-16-active': { content: "" }, + 'sofort-16-inactive': { content: "" }, + 'sofort-24-active': { content: "" }, + 'sofort-24-inactive': { content: "" }, + 'sortDoubleArrow-12-active': { content: "" }, + 'sortDoubleArrow-12-inactive': { content: "" }, + 'sortDoubleArrow-16-active': { content: "" }, + 'sortDoubleArrow-16-inactive': { content: "" }, + 'sortDoubleArrow-24-active': { content: "" }, + 'sortDoubleArrow-24-inactive': { content: "" }, + 'sortDown-12-active': { content: "" }, + 'sortDown-12-inactive': { content: "" }, + 'sortDown-16-active': { content: "" }, + 'sortDown-16-inactive': { content: "" }, + 'sortDown-24-active': { content: "" }, + 'sortDown-24-inactive': { content: "" }, + 'sortDownCenter-12-active': { content: "" }, + 'sortDownCenter-12-inactive': { content: "" }, + 'sortDownCenter-16-active': { content: "" }, + 'sortDownCenter-16-inactive': { content: "" }, + 'sortDownCenter-24-active': { content: "" }, + 'sortDownCenter-24-inactive': { content: "" }, + 'sortUp-12-active': { content: "" }, + 'sortUp-12-inactive': { content: "" }, + 'sortUp-16-active': { content: "" }, + 'sortUp-16-inactive': { content: "" }, + 'sortUp-24-active': { content: "" }, + 'sortUp-24-inactive': { content: "" }, + 'sortUpCenter-12-active': { content: "" }, + 'sortUpCenter-12-inactive': { content: "" }, + 'sortUpCenter-16-active': { content: "" }, + 'sortUpCenter-16-inactive': { content: "" }, + 'sortUpCenter-24-active': { content: "" }, + 'sortUpCenter-24-inactive': { content: "" }, + 'soundOff-12-active': { content: "" }, + 'soundOff-12-inactive': { content: "" }, + 'soundOff-16-active': { content: "" }, + 'soundOff-16-inactive': { content: "" }, + 'soundOff-24-active': { content: "" }, + 'soundOff-24-inactive': { content: "" }, + 'soundOn-12-active': { content: "" }, + 'soundOn-12-inactive': { content: "" }, + 'soundOn-16-active': { content: "" }, + 'soundOn-16-inactive': { content: "" }, + 'soundOn-24-active': { content: "" }, + 'soundOn-24-inactive': { content: "" }, + 'sparkle-12-active': { content: "" }, + 'sparkle-12-inactive': { content: "" }, + 'sparkle-16-active': { content: "" }, + 'sparkle-16-inactive': { content: "" }, + 'sparkle-24-active': { content: "" }, + 'sparkle-24-inactive': { content: "" }, + 'speaker-12-active': { content: "" }, + 'speaker-12-inactive': { content: "" }, + 'speaker-16-active': { content: "" }, + 'speaker-16-inactive': { content: "" }, + 'speaker-24-active': { content: "" }, + 'speaker-24-inactive': { content: "" }, + 'speechBubble-12-active': { content: "" }, + 'speechBubble-12-inactive': { content: "" }, + 'speechBubble-16-active': { content: "" }, + 'speechBubble-16-inactive': { content: "" }, + 'speechBubble-24-active': { content: "" }, + 'speechBubble-24-inactive': { content: "" }, + 'stableCoin-12-active': { content: "" }, + 'stableCoin-12-inactive': { content: "" }, + 'stableCoin-16-active': { content: "" }, + 'stableCoin-16-inactive': { content: "" }, + 'stableCoin-24-active': { content: "" }, + 'stableCoin-24-inactive': { content: "" }, + 'stablecoinStack-12-active': { content: "" }, + 'stablecoinStack-12-inactive': { content: "" }, + 'stablecoinStack-16-active': { content: "" }, + 'stablecoinStack-16-inactive': { content: "" }, + 'stablecoinStack-24-active': { content: "" }, + 'stablecoinStack-24-inactive': { content: "" }, + 'staggeredList-12-active': { content: "" }, + 'staggeredList-12-inactive': { content: "" }, + 'staggeredList-16-active': { content: "" }, + 'staggeredList-16-inactive': { content: "" }, + 'staggeredList-24-active': { content: "" }, + 'staggeredList-24-inactive': { content: "" }, + 'stake-12-active': { content: "" }, + 'stake-12-inactive': { content: "" }, + 'stake-16-active': { content: "" }, + 'stake-16-inactive': { content: "" }, + 'stake-24-active': { content: "" }, + 'stake-24-inactive': { content: "" }, + 'staking-12-active': { content: "" }, + 'staking-12-inactive': { content: "" }, + 'staking-16-active': { content: "" }, + 'staking-16-inactive': { content: "" }, + 'staking-24-active': { content: "" }, + 'staking-24-inactive': { content: "" }, + 'star-12-active': { content: "" }, + 'star-12-inactive': { content: "" }, + 'star-16-active': { content: "" }, + 'star-16-inactive': { content: "" }, + 'star-24-active': { content: "" }, + 'star-24-inactive': { content: "" }, + 'starAward-12-active': { content: "" }, + 'starAward-12-inactive': { content: "" }, + 'starAward-16-active': { content: "" }, + 'starAward-16-inactive': { content: "" }, + 'starAward-24-active': { content: "" }, + 'starAward-24-inactive': { content: "" }, + 'starBubble-12-active': { content: "" }, + 'starBubble-12-inactive': { content: "" }, + 'starBubble-16-active': { content: "" }, + 'starBubble-16-inactive': { content: "" }, + 'starBubble-24-active': { content: "" }, + 'starBubble-24-inactive': { content: "" }, + 'starTrophy-12-active': { content: "" }, + 'starTrophy-12-inactive': { content: "" }, + 'starTrophy-16-active': { content: "" }, + 'starTrophy-16-inactive': { content: "" }, + 'starTrophy-24-active': { content: "" }, + 'starTrophy-24-inactive': { content: "" }, + 'statusDot-12-active': { content: "" }, + 'statusDot-12-inactive': { content: "" }, + 'statusDot-16-active': { content: "" }, + 'statusDot-16-inactive': { content: "" }, + 'statusDot-24-active': { content: "" }, + 'statusDot-24-inactive': { content: "" }, + 'step0-12-active': { content: "" }, + 'step0-12-inactive': { content: "" }, + 'step0-16-active': { content: "" }, + 'step0-16-inactive': { content: "" }, + 'step0-24-active': { content: "" }, + 'step0-24-inactive': { content: "" }, + 'step1-12-active': { content: "" }, + 'step1-12-inactive': { content: "" }, + 'step1-16-active': { content: "" }, + 'step1-16-inactive': { content: "" }, + 'step1-24-active': { content: "" }, + 'step1-24-inactive': { content: "" }, + 'step2-12-active': { content: "" }, + 'step2-12-inactive': { content: "" }, + 'step2-16-active': { content: "" }, + 'step2-16-inactive': { content: "" }, + 'step2-24-active': { content: "" }, + 'step2-24-inactive': { content: "" }, + 'step3-12-active': { content: "" }, + 'step3-12-inactive': { content: "" }, + 'step3-16-active': { content: "" }, + 'step3-16-inactive': { content: "" }, + 'step3-24-active': { content: "" }, + 'step3-24-inactive': { content: "" }, + 'step4-12-active': { content: "" }, + 'step4-12-inactive': { content: "" }, + 'step4-16-active': { content: "" }, + 'step4-16-inactive': { content: "" }, + 'step4-24-active': { content: "" }, + 'step4-24-inactive': { content: "" }, + 'step5-12-active': { content: "" }, + 'step5-12-inactive': { content: "" }, + 'step5-16-active': { content: "" }, + 'step5-16-inactive': { content: "" }, + 'step5-24-active': { content: "" }, + 'step5-24-inactive': { content: "" }, + 'step6-12-active': { content: "" }, + 'step6-12-inactive': { content: "" }, + 'step6-16-active': { content: "" }, + 'step6-16-inactive': { content: "" }, + 'step6-24-active': { content: "" }, + 'step6-24-inactive': { content: "" }, + 'step7-12-active': { content: "" }, + 'step7-12-inactive': { content: "" }, + 'step7-16-active': { content: "" }, + 'step7-16-inactive': { content: "" }, + 'step7-24-active': { content: "" }, + 'step7-24-inactive': { content: "" }, + 'step8-12-active': { content: "" }, + 'step8-12-inactive': { content: "" }, + 'step8-16-active': { content: "" }, + 'step8-16-inactive': { content: "" }, + 'step8-24-active': { content: "" }, + 'step8-24-inactive': { content: "" }, + 'step9-12-active': { content: "" }, + 'step9-12-inactive': { content: "" }, + 'step9-16-active': { content: "" }, + 'step9-16-inactive': { content: "" }, + 'step9-24-active': { content: "" }, + 'step9-24-inactive': { content: "" }, + 'strategy-12-active': { content: "" }, + 'strategy-12-inactive': { content: "" }, + 'strategy-16-active': { content: "" }, + 'strategy-16-inactive': { content: "" }, + 'strategy-24-active': { content: "" }, + 'strategy-24-inactive': { content: "" }, + 'sun-12-active': { content: "" }, + 'sun-12-inactive': { content: "" }, + 'sun-16-active': { content: "" }, + 'sun-16-inactive': { content: "" }, + 'sun-24-active': { content: "" }, + 'sun-24-inactive': { content: "" }, + 'support-12-active': { content: "" }, + 'support-12-inactive': { content: "" }, + 'support-16-active': { content: "" }, + 'support-16-inactive': { content: "" }, + 'support-24-active': { content: "" }, + 'support-24-inactive': { content: "" }, + 'tag-12-active': { content: "" }, + 'tag-12-inactive': { content: "" }, + 'tag-16-active': { content: "" }, + 'tag-16-inactive': { content: "" }, + 'tag-24-active': { content: "" }, + 'tag-24-inactive': { content: "" }, + 'taxes-12-active': { content: "" }, + 'taxes-12-inactive': { content: "" }, + 'taxes-16-active': { content: "" }, + 'taxes-16-inactive': { content: "" }, + 'taxes-24-active': { content: "" }, + 'taxes-24-inactive': { content: "" }, + 'taxesReceipt-12-active': { content: "" }, + 'taxesReceipt-12-inactive': { content: "" }, + 'taxesReceipt-16-active': { content: "" }, + 'taxesReceipt-16-inactive': { content: "" }, + 'taxesReceipt-24-active': { content: "" }, + 'taxesReceipt-24-inactive': { content: "" }, + 'telephone-12-active': { content: "" }, + 'telephone-12-inactive': { content: "" }, + 'telephone-16-active': { content: "" }, + 'telephone-16-inactive': { content: "" }, + 'telephone-24-active': { content: "" }, + 'telephone-24-inactive': { content: "" }, + 'tennis-12-active': { content: "" }, + 'tennis-12-inactive': { content: "" }, + 'tennis-16-active': { content: "" }, + 'tennis-16-inactive': { content: "" }, + 'tennis-24-active': { content: "" }, + 'tennis-24-inactive': { content: "" }, + 'test-12-active': { content: "" }, + 'test-12-inactive': { content: "" }, + 'test-16-active': { content: "" }, + 'test-16-inactive': { content: "" }, + 'test-24-active': { content: "" }, + 'test-24-inactive': { content: "" }, + 'thermometer-12-active': { content: "" }, + 'thermometer-12-inactive': { content: "" }, + 'thermometer-16-active': { content: "" }, + 'thermometer-16-inactive': { content: "" }, + 'thermometer-24-active': { content: "" }, + 'thermometer-24-inactive': { content: "" }, + 'thumbsDown-12-active': { content: "" }, + 'thumbsDown-12-inactive': { content: "" }, + 'thumbsDown-16-active': { content: "" }, + 'thumbsDown-16-inactive': { content: "" }, + 'thumbsDown-24-active': { content: "" }, + 'thumbsDown-24-inactive': { content: "" }, + 'thumbsDownOutline-12-active': { content: "" }, + 'thumbsDownOutline-12-inactive': { content: "" }, + 'thumbsDownOutline-16-active': { content: "" }, + 'thumbsDownOutline-16-inactive': { content: "" }, + 'thumbsDownOutline-24-active': { content: "" }, + 'thumbsDownOutline-24-inactive': { content: "" }, + 'thumbsUp-12-active': { content: "" }, + 'thumbsUp-12-inactive': { content: "" }, + 'thumbsUp-16-active': { content: "" }, + 'thumbsUp-16-inactive': { content: "" }, + 'thumbsUp-24-active': { content: "" }, + 'thumbsUp-24-inactive': { content: "" }, + 'thumbsUpOutline-12-active': { content: "" }, + 'thumbsUpOutline-12-inactive': { content: "" }, + 'thumbsUpOutline-16-active': { content: "" }, + 'thumbsUpOutline-16-inactive': { content: "" }, + 'thumbsUpOutline-24-active': { content: "" }, + 'thumbsUpOutline-24-inactive': { content: "" }, + 'tokenLaunchCoin-12-active': { content: "" }, + 'tokenLaunchCoin-12-inactive': { content: "" }, + 'tokenLaunchCoin-16-active': { content: "" }, + 'tokenLaunchCoin-16-inactive': { content: "" }, + 'tokenLaunchCoin-24-active': { content: "" }, + 'tokenLaunchCoin-24-inactive': { content: "" }, + 'tokenLaunchRocket-12-active': { content: "" }, + 'tokenLaunchRocket-12-inactive': { content: "" }, + 'tokenLaunchRocket-16-active': { content: "" }, + 'tokenLaunchRocket-16-inactive': { content: "" }, + 'tokenLaunchRocket-24-active': { content: "" }, + 'tokenLaunchRocket-24-inactive': { content: "" }, + 'tokenSales-12-active': { content: "" }, + 'tokenSales-12-inactive': { content: "" }, + 'tokenSales-16-active': { content: "" }, + 'tokenSales-16-inactive': { content: "" }, + 'tokenSales-24-active': { content: "" }, + 'tokenSales-24-inactive': { content: "" }, + 'tornado-12-active': { content: "" }, + 'tornado-12-inactive': { content: "" }, + 'tornado-16-active': { content: "" }, + 'tornado-16-inactive': { content: "" }, + 'tornado-24-active': { content: "" }, + 'tornado-24-inactive': { content: "" }, + 'trading-12-active': { content: "" }, + 'trading-12-inactive': { content: "" }, + 'trading-16-active': { content: "" }, + 'trading-16-inactive': { content: "" }, + 'trading-24-active': { content: "" }, + 'trading-24-inactive': { content: "" }, + 'transactions-12-active': { content: "" }, + 'transactions-12-inactive': { content: "" }, + 'transactions-16-active': { content: "" }, + 'transactions-16-inactive': { content: "" }, + 'transactions-24-active': { content: "" }, + 'transactions-24-inactive': { content: "" }, + 'trashCan-12-active': { content: "" }, + 'trashCan-12-inactive': { content: "" }, + 'trashCan-16-active': { content: "" }, + 'trashCan-16-inactive': { content: "" }, + 'trashCan-24-active': { content: "" }, + 'trashCan-24-inactive': { content: "" }, + 'trophy-12-active': { content: "" }, + 'trophy-12-inactive': { content: "" }, + 'trophy-16-active': { content: "" }, + 'trophy-16-inactive': { content: "" }, + 'trophy-24-active': { content: "" }, + 'trophy-24-inactive': { content: "" }, + 'trophyCup-12-active': { content: "" }, + 'trophyCup-12-inactive': { content: "" }, + 'trophyCup-16-active': { content: "" }, + 'trophyCup-16-inactive': { content: "" }, + 'trophyCup-24-active': { content: "" }, + 'trophyCup-24-inactive': { content: "" }, + 'tshirt-12-active': { content: "" }, + 'tshirt-12-inactive': { content: "" }, + 'tshirt-16-active': { content: "" }, + 'tshirt-16-inactive': { content: "" }, + 'tshirt-24-active': { content: "" }, + 'tshirt-24-inactive': { content: "" }, + 'tv-12-active': { content: "" }, + 'tv-12-inactive': { content: "" }, + 'tv-16-active': { content: "" }, + 'tv-16-inactive': { content: "" }, + 'tv-24-active': { content: "" }, + 'tv-24-inactive': { content: "" }, + 'tvStand-12-active': { content: "" }, + 'tvStand-12-inactive': { content: "" }, + 'tvStand-16-active': { content: "" }, + 'tvStand-16-inactive': { content: "" }, + 'tvStand-24-active': { content: "" }, + 'tvStand-24-inactive': { content: "" }, + 'twitterLogo-12-active': { content: "" }, + 'twitterLogo-12-inactive': { content: "" }, + 'twitterLogo-16-active': { content: "" }, + 'twitterLogo-16-inactive': { content: "" }, + 'twitterLogo-24-active': { content: "" }, + 'twitterLogo-24-inactive': { content: "" }, + 'ultility-12-active': { content: "" }, + 'ultility-12-inactive': { content: "" }, + 'ultility-16-active': { content: "" }, + 'ultility-16-inactive': { content: "" }, + 'ultility-24-active': { content: "" }, + 'ultility-24-inactive': { content: "" }, + 'umbrella-12-active': { content: "" }, + 'umbrella-12-inactive': { content: "" }, + 'umbrella-16-active': { content: "" }, + 'umbrella-16-inactive': { content: "" }, + 'umbrella-24-active': { content: "" }, + 'umbrella-24-inactive': { content: "" }, + 'undo-12-active': { content: "" }, + 'undo-12-inactive': { content: "" }, + 'undo-16-active': { content: "" }, + 'undo-16-inactive': { content: "" }, + 'undo-24-active': { content: "" }, + 'undo-24-inactive': { content: "" }, + 'unfollowPeople-12-active': { content: "" }, + 'unfollowPeople-12-inactive': { content: "" }, + 'unfollowPeople-16-active': { content: "" }, + 'unfollowPeople-16-inactive': { content: "" }, + 'unfollowPeople-24-active': { content: "" }, + 'unfollowPeople-24-inactive': { content: "" }, + 'unknown-12-active': { content: "" }, + 'unknown-12-inactive': { content: "" }, + 'unknown-16-active': { content: "" }, + 'unknown-16-inactive': { content: "" }, + 'unknown-24-active': { content: "" }, + 'unknown-24-inactive': { content: "" }, + 'unlock-12-active': { content: "" }, + 'unlock-12-inactive': { content: "" }, + 'unlock-16-active': { content: "" }, + 'unlock-16-inactive': { content: "" }, + 'unlock-24-active': { content: "" }, + 'unlock-24-inactive': { content: "" }, + 'upArrow-12-active': { content: "" }, + 'upArrow-12-inactive': { content: "" }, + 'upArrow-16-active': { content: "" }, + 'upArrow-16-inactive': { content: "" }, + 'upArrow-24-active': { content: "" }, + 'upArrow-24-inactive': { content: "" }, + 'upload-12-active': { content: "" }, + 'upload-12-inactive': { content: "" }, + 'upload-16-active': { content: "" }, + 'upload-16-inactive': { content: "" }, + 'upload-24-active': { content: "" }, + 'upload-24-inactive': { content: "" }, + 'venturesProduct-12-active': { content: "" }, + 'venturesProduct-12-inactive': { content: "" }, + 'venturesProduct-16-active': { content: "" }, + 'venturesProduct-16-inactive': { content: "" }, + 'venturesProduct-24-active': { content: "" }, + 'venturesProduct-24-inactive': { content: "" }, + 'verifiedBadge-12-active': { content: "" }, + 'verifiedBadge-12-inactive': { content: "" }, + 'verifiedBadge-16-active': { content: "" }, + 'verifiedBadge-16-inactive': { content: "" }, + 'verifiedBadge-24-active': { content: "" }, + 'verifiedBadge-24-inactive': { content: "" }, + 'verifiedPools-12-active': { content: "" }, + 'verifiedPools-12-inactive': { content: "" }, + 'verifiedPools-16-active': { content: "" }, + 'verifiedPools-16-inactive': { content: "" }, + 'verifiedPools-24-active': { content: "" }, + 'verifiedPools-24-inactive': { content: "" }, + 'verticalLine-12-active': { content: "" }, + 'verticalLine-12-inactive': { content: "" }, + 'verticalLine-16-active': { content: "" }, + 'verticalLine-16-inactive': { content: "" }, + 'verticalLine-24-active': { content: "" }, + 'verticalLine-24-inactive': { content: "" }, + 'virus-12-active': { content: "" }, + 'virus-12-inactive': { content: "" }, + 'virus-16-active': { content: "" }, + 'virus-16-inactive': { content: "" }, + 'virus-24-active': { content: "" }, + 'virus-24-inactive': { content: "" }, + 'visible-12-active': { content: "" }, + 'visible-12-inactive': { content: "" }, + 'visible-16-active': { content: "" }, + 'visible-16-inactive': { content: "" }, + 'visible-24-active': { content: "" }, + 'visible-24-inactive': { content: "" }, + 'waasProduct-12-active': { content: "" }, + 'waasProduct-12-inactive': { content: "" }, + 'waasProduct-16-active': { content: "" }, + 'waasProduct-16-inactive': { content: "" }, + 'waasProduct-24-active': { content: "" }, + 'waasProduct-24-inactive': { content: "" }, + 'wallet-12-active': { content: "" }, + 'wallet-12-inactive': { content: "" }, + 'wallet-16-active': { content: "" }, + 'wallet-16-inactive': { content: "" }, + 'wallet-24-active': { content: "" }, + 'wallet-24-inactive': { content: "" }, + 'walletLogo-12-active': { content: "" }, + 'walletLogo-12-inactive': { content: "" }, + 'walletLogo-16-active': { content: "" }, + 'walletLogo-16-inactive': { content: "" }, + 'walletLogo-24-active': { content: "" }, + 'walletLogo-24-inactive': { content: "" }, + 'walletProduct-12-active': { content: "" }, + 'walletProduct-12-inactive': { content: "" }, + 'walletProduct-16-active': { content: "" }, + 'walletProduct-16-inactive': { content: "" }, + 'walletProduct-24-active': { content: "" }, + 'walletProduct-24-inactive': { content: "" }, + 'warning-12-active': { content: "" }, + 'warning-12-inactive': { content: "" }, + 'warning-16-active': { content: "" }, + 'warning-16-inactive': { content: "" }, + 'warning-24-active': { content: "" }, + 'warning-24-inactive': { content: "" }, + 'webhooks-12-active': { content: "" }, + 'webhooks-12-inactive': { content: "" }, + 'webhooks-16-active': { content: "" }, + 'webhooks-16-inactive': { content: "" }, + 'webhooks-24-active': { content: "" }, + 'webhooks-24-inactive': { content: "" }, + 'wellness-12-active': { content: "" }, + 'wellness-12-inactive': { content: "" }, + 'wellness-16-active': { content: "" }, + 'wellness-16-inactive': { content: "" }, + 'wellness-24-active': { content: "" }, + 'wellness-24-inactive': { content: "" }, + 'wifi-12-active': { content: "" }, + 'wifi-12-inactive': { content: "" }, + 'wifi-16-active': { content: "" }, + 'wifi-16-inactive': { content: "" }, + 'wifi-24-active': { content: "" }, + 'wifi-24-inactive': { content: "" }, + 'wind-12-active': { content: "" }, + 'wind-12-inactive': { content: "" }, + 'wind-16-active': { content: "" }, + 'wind-16-inactive': { content: "" }, + 'wind-24-active': { content: "" }, + 'wind-24-inactive': { content: "" }, + 'wireTransfer-12-active': { content: "" }, + 'wireTransfer-12-inactive': { content: "" }, + 'wireTransfer-16-active': { content: "" }, + 'wireTransfer-16-inactive': { content: "" }, + 'wireTransfer-24-active': { content: "" }, + 'wireTransfer-24-inactive': { content: "" }, + 'withdraw-12-active': { content: "" }, + 'withdraw-12-inactive': { content: "" }, + 'withdraw-16-active': { content: "" }, + 'withdraw-16-inactive': { content: "" }, + 'withdraw-24-active': { content: "" }, + 'withdraw-24-inactive': { content: "" }, + 'wrapToken-12-active': { content: "" }, + 'wrapToken-12-inactive': { content: "" }, + 'wrapToken-16-active': { content: "" }, + 'wrapToken-16-inactive': { content: "" }, + 'wrapToken-24-active': { content: "" }, + 'wrapToken-24-inactive': { content: "" }, + 'xLogo-12-active': { content: "" }, + 'xLogo-12-inactive': { content: "" }, + 'xLogo-16-active': { content: "" }, + 'xLogo-16-inactive': { content: "" }, + 'xLogo-24-active': { content: "" }, + 'xLogo-24-inactive': { content: "" }, +} as const; + +export type SvgMapEntry = { content: string }; +export type SvgMap = Record; +export type SvgKey = keyof typeof svgMap; + +export default svgMap; diff --git a/apps/test-expo/src/hooks/useFonts.ts b/apps/test-expo/src/hooks/useFonts.ts new file mode 100644 index 0000000000..dbd71e608a --- /dev/null +++ b/apps/test-expo/src/hooks/useFonts.ts @@ -0,0 +1,32 @@ +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'; +import { SourceCodePro_400Regular } from '@expo-google-fonts/source-code-pro/400Regular'; +import { SourceCodePro_600SemiBold } from '@expo-google-fonts/source-code-pro/600SemiBold'; +import { useFonts as useFontsSourceCodePro } from '@expo-google-fonts/source-code-pro/useFonts'; +import { useFonts as useFontsExpo } from 'expo-font'; + +const localFonts = { + CoinbaseIcons: require('@coinbase/cds-icons/fonts/native/CoinbaseIcons.ttf') as string, +}; + +const interFonts = { + Inter_400Regular, + Inter_600SemiBold, +}; + +const sourceCodeProFonts = { + SourceCodePro_400Regular, + SourceCodePro_600SemiBold, +}; + +export function useFonts() { + const [loadedLocal, errorLocal] = useFontsExpo(localFonts); + const [loadedInter, errorInter] = useFontsInter(interFonts); + const [loadedSourceCodePro, errorSourceCodePro] = useFontsSourceCodePro(sourceCodeProFonts); + + return [ + loadedLocal && loadedInter && loadedSourceCodePro, + errorLocal || errorInter || errorSourceCodePro, + ]; +} diff --git a/apps/test-expo/src/playground/ExamplesListScreen.tsx b/apps/test-expo/src/playground/ExamplesListScreen.tsx new file mode 100644 index 0000000000..c5b15f77ca --- /dev/null +++ b/apps/test-expo/src/playground/ExamplesListScreen.tsx @@ -0,0 +1,71 @@ +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 } from '@react-navigation/native'; + +import { SearchFilterContext } from './ExamplesSearchProvider'; +import { keyToRouteName } from './keyToRouteName'; +import type { ExamplesListScreenProps } from './types'; + +const initialRouteKey = 'Examples'; +const searchRouteKey = 'Search'; + +const innerSpacingConfig: CellSpacing = { paddingX: 1 }; + +export function ExamplesListScreen({ route }: ExamplesListScreenProps) { + const searchFilter = useContext(SearchFilterContext); + const routeKeys = route.params?.routeKeys ?? []; + const { navigate } = useNavigation(); + const { bottom } = useSafeAreaInsets(); + + const renderItem: ListRenderItem = useCallback( + ({ item }) => { + const handlePress = () => { + navigate(keyToRouteName(item) as never); + }; + + return ( + + ); + }, + [navigate], + ); + + const data = [...routeKeys, 'IconSheet'] + .sort() + .filter((key) => key !== initialRouteKey && key !== searchRouteKey) + .filter((key) => { + if (searchFilter !== '') { + return key.toLowerCase().includes(searchFilter.toLowerCase()); + } + return true; + }); + + return ( + + + + ); +} diff --git a/apps/test-expo/src/playground/ExamplesSearchProvider.tsx b/apps/test-expo/src/playground/ExamplesSearchProvider.tsx new file mode 100644 index 0000000000..e274e5dc2c --- /dev/null +++ b/apps/test-expo/src/playground/ExamplesSearchProvider.tsx @@ -0,0 +1,18 @@ +import React, { useState } from 'react'; + +export const SearchFilterContext = React.createContext(''); +export const SetSearchFilterContext = React.createContext< + React.Dispatch> +>(() => {}); + +export const ExamplesSearchProvider: React.FC> = ({ + children, +}) => { + const [filter, setFilter] = useState(''); + + return ( + + {children} + + ); +}; diff --git a/apps/test-expo/src/playground/IconSheetScreen.tsx b/apps/test-expo/src/playground/IconSheetScreen.tsx new file mode 100644 index 0000000000..6b5505b4ec --- /dev/null +++ b/apps/test-expo/src/playground/IconSheetScreen.tsx @@ -0,0 +1,41 @@ +import { SvgXml } from 'react-native-svg'; +import type { IconSourcePixelSize } from '@coinbase/cds-common/types'; +import { useTheme } from '@coinbase/cds-mobile'; +import { IconSheet } from '@coinbase/cds-mobile/icons/__stories__/IconSheet'; + +import { svgMap } from '../__generated__/iconSvgMap'; + +const getIconSourceSize = (iconSize: number): IconSourcePixelSize => { + if (iconSize <= 12) return 12; + if (iconSize <= 16) return 16; + return 24; +}; + +export function IconSheetScreen() { + const theme = useTheme(); + return ( + { + const size = theme.iconSize[iconSize]; + const sourceSize = getIconSourceSize(size); + const key = `${iconName}-${sourceSize}-inactive`; + + if (!(key in svgMap)) { + throw new Error( + `Icon ${key} not found in iconSvgMap. You probably need to run the generateIconSvgMap script to update it.`, + ); + } + + return ( + + ); + }} + /> + ); +} 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/apps/test-expo/src/playground/keyToRouteName.ts b/apps/test-expo/src/playground/keyToRouteName.ts new file mode 100644 index 0000000000..7629a56ae9 --- /dev/null +++ b/apps/test-expo/src/playground/keyToRouteName.ts @@ -0,0 +1,3 @@ +export function keyToRouteName(key: string) { + return `Debug${key}` as const; +} 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/test-expo/src/routes.ts b/apps/test-expo/src/routes.ts new file mode 100644 index 0000000000..d80ead566f --- /dev/null +++ b/apps/test-expo/src/routes.ts @@ -0,0 +1,900 @@ +/** + * 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: 'AndroidNavigationBar', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/AndroidNavigationBar.stories').default, + }, + { + key: 'AnimatedCaret', + getComponent: () => + require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, + }, + { + key: 'AreaChart', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/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/visualizations/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/visualizations/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/visualizations/chart/__stories__/CartesianChart.stories') + .default, + }, + { + key: 'ChartAccessibility', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/chart/__stories__/ChartAccessibility.stories') + .default, + }, + { + key: 'ChartTransitions', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/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: '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/visualizations/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/visualizations/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/visualizations/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/visualizations/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/visualizations/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/visualizations/sparkline/__stories__/Sparkline.stories') + .default, + }, + { + key: 'SparklineGradient', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/sparkline/__stories__/SparklineGradient.stories') + .default, + }, + { + key: 'SparklineInteractive', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') + .default, + }, + { + key: 'SparklineInteractiveHeader', + getComponent: () => + require('@coinbase/cds-mobile/visualizations/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/test-expo/tsconfig.json b/apps/test-expo/tsconfig.json new file mode 100644 index 0000000000..754926c77e --- /dev/null +++ b/apps/test-expo/tsconfig.json @@ -0,0 +1,36 @@ +{ + "extends": "../../tsconfig.project.json", + "compilerOptions": { + "declarationDir": "dts", + "jsx": "react-native", + "resolveJsonModule": true, + "moduleSuffixes": [ + ".ios", + ".android", + ".native", + "" + ] + }, + "include": [ + "src/**/*", + "*.tsx", + "*.config.js", + "*.config.ts", + "*.json" + ], + "exclude": [], + "references": [ + { + "path": "../../packages/common" + }, + { + "path": "../../packages/mobile" + }, + { + "path": "../../packages/icons" + }, + { + "path": "../../packages/illustrations" + } + ] +} 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..a78782d1d7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -32,6 +32,7 @@ const ignores = [ '**/esm/**', '**/lib/**', '**/templates/**', + '**/__testfixtures__/**', '**/.next/**', // These files use assert { type: 'json' } syntax that breaks eslint and must be fully ignored '**/getAffectedRoutes.mjs', @@ -46,10 +47,22 @@ const ignores = [ '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 +81,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 +160,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 +289,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 +323,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, ]; @@ -309,6 +423,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 +449,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..427890dd9c 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) => { @@ -112,6 +96,13 @@ export async function prepare() { template: 'mobileRoutes.ejs', dest: `apps/mobile-app/scripts/utils/routes.mjs`, }); + + // Write to test-expo for Expo demo app + await writeFile({ + data: { routes: consumerRoutes }, + template: 'mobileRoutes.ejs', + dest: `apps/test-expo/src/routes.ts`, + }); } catch (err) { if (err instanceof Error) { console.log(err.message); 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/migrator/tsconfig.build.json b/packages/migrator/tsconfig.build.json new file mode 100644 index 0000000000..a227a333b1 --- /dev/null +++ b/packages/migrator/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "sourceMap": false + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "**/__stories__/**", + "**/__tests__/**", + "**/__mocks__/**", + "**/__fixtures__/**", + "**/*.stories.*", + "**/*.test.*", + "**/*.spec.*" + ], + "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/project.json b/packages/mobile-visreg/project.json index 7a629734df..e3dd981cf4 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/CHANGELOG.md b/packages/mobile-visualization/CHANGELOG.md index 0c57fda888..bc81f3f2b9 100644 --- a/packages/mobile-visualization/CHANGELOG.md +++ b/packages/mobile-visualization/CHANGELOG.md @@ -8,6 +8,18 @@ All notable changes to this project will be documented in this file. +## 3.4.0-beta.27 (4/1/2026 PST) + +#### 🐞 Fixes + +- Fix scrubber beacon initial load glitch. [[#573](https://github.com/coinbase/cds/pull/573)] + +## 3.4.0-beta.26 (3/31/2026 PST) + +#### 🐞 Fixes + +- Fix scrubber beacon label single frame delay for y value. [[#570](https://github.com/coinbase/cds/pull/570)] + ## 3.4.0-beta.25 (3/24/2026 PST) #### 🐞 Fixes 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 6216f4359f..2211e1a59d 100644 --- a/packages/mobile-visualization/package.json +++ b/packages/mobile-visualization/package.json @@ -1,7 +1,8 @@ { "name": "@coinbase/cds-mobile-visualization", - "version": "3.4.0-beta.25", + "version": "3.4.0-beta.27", "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/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 1573ed87bc..cc21a04190 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Deprecate Card and its sub-components. [[#562](https://github.com/coinbase/cds/pull/562)] + +#### 📘 Misc + +- Chore: deprecate CardGroup. [[#560](https://github.com/coinbase/cds/pull/560)] + +## 8.60.0 (3/29/2026 PST) + +#### 🚀 Updates + +- Add indeterminate ProgressCircle. [[#501](https://github.com/coinbase/cds/pull/501)] + ## 8.59.0 (3/27/2026 PST) #### 🚀 Updates 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 ce059f0866..1f330dba9d 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.59.0", + "version": "8.60.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", @@ -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" @@ -211,25 +217,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 dabaddda43..da5b36e435 100644 --- a/packages/mobile/src/alpha/combobox/Combobox.tsx +++ b/packages/mobile/src/alpha/combobox/Combobox.tsx @@ -13,6 +13,7 @@ import { KeyboardAvoidingView, Platform, type TextInput, View } from 'react-nati import Fuse from 'fuse.js'; import { Button } from '../../buttons/Button'; +import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; import { Box } from '../../layout'; import { StickyFooter } from '../../sticky-footer/StickyFooter'; import { DefaultSelectControl } from '../select/DefaultSelectControl'; @@ -72,7 +73,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 */ @@ -248,6 +249,7 @@ const ComboboxBase = memo( ); const searchInputRef = useRef(null); + const safeBottomPadding = useSafeBottomPadding(); const handleTrayVisibilityChange = useCallback((visibility: 'visible' | 'hidden') => { if (visibility === 'visible') { searchInputRef.current?.focus(); @@ -279,7 +281,7 @@ const ComboboxBase = memo( footer={({ handleClose }) => ( - + ); }; @@ -665,11 +665,11 @@ const CustomOptionComponent = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onPress }) => { return ( - + - + ); }; @@ -1325,7 +1325,7 @@ const SelectV3Screen = () => { - + diff --git a/packages/mobile/src/alpha/select/__tests__/DefaultSelectControl.test.tsx b/packages/mobile/src/alpha/select/__tests__/DefaultSelectControl.test.tsx index eec7208129..199fb3b804 100644 --- a/packages/mobile/src/alpha/select/__tests__/DefaultSelectControl.test.tsx +++ b/packages/mobile/src/alpha/select/__tests__/DefaultSelectControl.test.tsx @@ -199,7 +199,7 @@ describe('DefaultSelectControl', () => { ); const button = screen.getByRole('button'); - expect(button).toHaveAccessibilityState({ disabled: true }); + expect(button).toBeDisabled(); }); it('renders with helper text', () => { diff --git a/packages/mobile/src/alpha/select/__tests__/DefaultSelectOption.test.tsx b/packages/mobile/src/alpha/select/__tests__/DefaultSelectOption.test.tsx index 9a67cfa810..2672ebcb08 100644 --- a/packages/mobile/src/alpha/select/__tests__/DefaultSelectOption.test.tsx +++ b/packages/mobile/src/alpha/select/__tests__/DefaultSelectOption.test.tsx @@ -28,7 +28,7 @@ describe('DefaultSelectOption', () => { , ); const option = screen.getByRole('menuitem'); - expect(option).toHaveAccessibilityState({ disabled: true }); + expect(option).toBeDisabled(); }); it('has correct accessibility attributes for single select', () => { @@ -39,7 +39,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('menuitem'); - expect(option).toHaveAccessibilityState({ selected: true }); + expect(option).toBeSelected(); }); it('has correct accessibility attributes for multi select', () => { @@ -50,7 +50,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('checkbox'); - expect(option).toHaveAccessibilityState({ checked: true }); + expect(option).toBeChecked(); }); it('has correct accessibility attributes for indeterminate state', () => { @@ -61,7 +61,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('checkbox'); - expect(option).toHaveAccessibilityState({ checked: 'mixed' }); + expect(option).toBePartiallyChecked(); }); it('sets custom accessibility role', () => { @@ -166,7 +166,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('menuitem'); - expect(option).toHaveAccessibilityState({ selected: true }); + expect(option).toBeSelected(); }); it('renders unselected state', () => { @@ -177,7 +177,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('menuitem'); - expect(option).toHaveAccessibilityState({ selected: false }); + expect(option).not.toBeSelected(); }); it('renders disabled state', () => { @@ -188,7 +188,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('menuitem'); - expect(option).toHaveAccessibilityState({ disabled: true }); + expect(option).toBeDisabled(); }); it('renders enabled state', () => { @@ -199,7 +199,7 @@ describe('DefaultSelectOption', () => { ); const option = screen.getByRole('menuitem'); - expect(option).not.toHaveAccessibilityState({ disabled: true }); + expect(option).toBeEnabled(); }); }); diff --git a/packages/mobile/src/alpha/select/__tests__/Select.test.tsx b/packages/mobile/src/alpha/select/__tests__/Select.test.tsx index 4b2df96a49..842103d934 100644 --- a/packages/mobile/src/alpha/select/__tests__/Select.test.tsx +++ b/packages/mobile/src/alpha/select/__tests__/Select.test.tsx @@ -502,7 +502,7 @@ describe('Select', () => { ); const button = screen.getByRole('button'); - expect(button).toHaveAccessibilityState({ disabled: true }); + expect(button).toBeDisabled(); }); it('does not open when disabled', () => { diff --git a/packages/mobile/src/alpha/select/types.ts b/packages/mobile/src/alpha/select/types.ts index ffc65bb827..eb2dac878d 100644 --- a/packages/mobile/src/alpha/select/types.ts +++ b/packages/mobile/src/alpha/select/types.ts @@ -3,6 +3,7 @@ import type { AccessibilityRole, StyleProp, TouchableOpacity, View, ViewStyle } import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; import type { CellBaseProps } from '../../cells/Cell'; +import type { CellAccessoryProps } from '../../cells/CellAccessory'; import type { InputStackBaseProps } from '../../controls/InputStack'; import type { TextInputBaseProps } from '../../controls/TextInput'; import type { BoxProps } from '../../layout'; @@ -130,7 +131,7 @@ export type SelectOptionGroupProps< /** Accessibility role for options */ accessibilityRole?: AccessibilityRole; /** 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 */ @@ -308,7 +309,7 @@ export type SelectControlComponent< SelectOptionValue extends string = string, > = React.FC< SelectControlProps & { - ref?: React.Ref; + ref?: React.Ref>; } >; @@ -517,8 +518,8 @@ export type SelectProps< export type SelectRef = View & Pick & { refs: { - reference: React.RefObject; - floating: React.RefObject | null; + reference: React.RefObject; + floating: React.RefObject | null; }; }; diff --git a/packages/mobile/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx b/packages/mobile/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx index fed62cc311..2086dc4244 100644 --- a/packages/mobile/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx +++ b/packages/mobile/src/alpha/tabbed-chips/__tests__/TabbedChips.test.tsx @@ -35,15 +35,11 @@ describe('TabbedChips(Alpha)', () => { const firstTestId = tabs[0].testID ?? tabs[0].id; const secondTestId = tabs[1].testID ?? tabs[1].id; - expect(screen.getByTestId(firstTestId)).toHaveAccessibilityState({ selected: true }); + expect(screen.getByTestId(firstTestId)).toBeSelected(); fireEvent.press(screen.getByTestId(secondTestId)); - await waitFor(() => - expect(screen.getByTestId(secondTestId)).toHaveAccessibilityState({ selected: true }), - ); - await waitFor(() => - expect(screen.getByTestId(firstTestId)).toHaveAccessibilityState({ selected: false }), - ); + await waitFor(() => expect(screen.getByTestId(secondTestId)).toBeSelected()); + await waitFor(() => expect(screen.getByTestId(firstTestId)).not.toBeSelected()); }); }); diff --git a/packages/mobile/src/animation/Lottie.tsx b/packages/mobile/src/animation/Lottie.tsx index a186806485..1495e64f8d 100644 --- a/packages/mobile/src/animation/Lottie.tsx +++ b/packages/mobile/src/animation/Lottie.tsx @@ -58,10 +58,15 @@ const LottieContent = memo( ); export const Lottie = memo( - forwardRef((props: LottieProps, forwardedRef: React.ForwardedRef) => { - const colorFilters = useLottieColorFilters(props.source, props.colorFilters); - return ; - }), + forwardRef( + ( + { colorFilters: colorFiltersProp, ...props }: LottieProps, + forwardedRef: React.ForwardedRef, + ) => { + const colorFilters = useLottieColorFilters(props.source, colorFiltersProp); + return ; + }, + ), ); LottieContent.displayName = 'LottieContent'; diff --git a/packages/mobile/src/animation/LottieStatusAnimation.tsx b/packages/mobile/src/animation/LottieStatusAnimation.tsx index c52ec501e1..b3fd52b247 100644 --- a/packages/mobile/src/animation/LottieStatusAnimation.tsx +++ b/packages/mobile/src/animation/LottieStatusAnimation.tsx @@ -1,7 +1,7 @@ import { memo, useMemo } from 'react'; +import type { DimensionValue } from 'react-native'; 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 { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; import { tradeStatus } from '@coinbase/cds-lottie-files/tradeStatus'; @@ -47,12 +47,12 @@ export const LottieStatusAnimation = memo( return ( ); }, diff --git a/packages/mobile/src/animation/__tests__/LottieStatusAnimation.test.tsx b/packages/mobile/src/animation/__tests__/LottieStatusAnimation.test.tsx index 01ecb69725..7d347043b6 100644 --- a/packages/mobile/src/animation/__tests__/LottieStatusAnimation.test.tsx +++ b/packages/mobile/src/animation/__tests__/LottieStatusAnimation.test.tsx @@ -27,7 +27,7 @@ describe('LottieStatusAnimation', () => { it('renders a LottieStatusAnimation', () => { render( - + , ); @@ -41,7 +41,7 @@ describe('LottieStatusAnimation', () => { render( @@ -59,7 +59,7 @@ describe('LottieStatusAnimation', () => { const props: LottieStatusAnimationProps = { status, testID: `lottie-status-animation-${status}`, - height: '100', + height: 100, }; render( @@ -75,7 +75,7 @@ describe('LottieStatusAnimation', () => { it('renders with cardSuccess status', () => { render( - + , ); expect(screen.getByTestId('lottie-card-success')).toBeTruthy(); @@ -86,7 +86,7 @@ describe('LottieStatusAnimation', () => { render( { it('transitions from pending to success', async () => { const { rerender } = render( - + , ); expect(screen.getByTestId('lottie-transition')).toBeTruthy(); rerender( - + , ); expect(screen.getByTestId('lottie-transition')).toBeTruthy(); @@ -119,14 +119,14 @@ describe('LottieStatusAnimation', () => { it('transitions from pending to failure', async () => { const { rerender } = render( - + , ); expect(screen.getByTestId('lottie-transition-fail')).toBeTruthy(); rerender( - + , ); expect(screen.getByTestId('lottie-transition-fail')).toBeTruthy(); @@ -137,7 +137,7 @@ describe('LottieStatusAnimation', () => { const { rerender } = render( { rerender( ; +/** + * Canonical props type for Banner. + * + * Note: docs docgen expects a `${ComponentName}Props` export (for Banner -> BannerProps) + * to generate styles selector metadata used by the docs Styles tab. + */ +export type BannerProps = BannerBaseProps & + Omit & { + /** Slot-level styles for Banner. */ + styles?: { + /** Persistent outer wrapper around both dismissible and non-dismissible variants. */ + root?: StyleProp; + /** Main content container (`HStack`) for banner body. */ + content?: StyleProp; + /** Start icon wrapper. */ + start?: StyleProp; + /** Right-side body wrapper containing middle content and actions. */ + body?: StyleProp; + /** Middle content wrapper containing title/message/label region. */ + middle?: StyleProp; + /** + * Label text style. + * Applies only when `label` is a string rendered by Banner. + * If `label` is a custom node, style that node directly. + */ + label?: StyleProp; + /** + * Actions row style. + * Applies only when at least one action (`primaryAction` or `secondaryAction`) is rendered. + */ + actions?: StyleProp; + /** + * Dismiss button wrapper style. + * Applies only when `showDismiss` is true. + */ + dismiss?: StyleProp; + }; + }; +/** + * @deprecated Use `BannerProps` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ +export type MobileBannerProps = BannerProps; export const Banner = memo( forwardRef(function Banner( @@ -97,8 +139,9 @@ export const Banner = memo( marginEnd, marginBottom, marginStart, + styles, ...props - }: MobileBannerProps, + }: BannerProps, forwardedRef: React.ForwardedRef, ) { const [isCollapsed, setIsCollapsed] = useState(false); @@ -129,12 +172,12 @@ export const Banner = memo( font: 'label1', color: primaryActionColor, testID: `${testID}-action--primary`, - ...primaryAction.props, + ...(primaryAction.props as LinkProps), }); } else { return React.cloneElement(primaryAction, { testID: `${testID}-action--primary`, - ...primaryAction.props, + ...(primaryAction.props as any), // we don't know the type of element this ReactNode is }); } }, [primaryAction, primaryActionColor, testID]); @@ -147,12 +190,12 @@ export const Banner = memo( font: 'label1', color: secondaryActionColor, testID: `${testID}-action--secondary`, - ...secondaryAction.props, + ...(secondaryAction.props as LinkProps), }); } else { return React.cloneElement(secondaryAction, { testID: `${testID}-action--secondary`, - ...secondaryAction.props, + ...(secondaryAction.props as any), // we don't know the type of element this ReactNode is }); } }, [secondaryAction, secondaryActionColor, testID]); @@ -171,109 +214,110 @@ export const Banner = memo( ); const borderBox = ( - + ); const content = ( - - + {/** Start */} + - {/** Start */} - - - - - {/** Middle */} - - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {typeof children === 'string' ? ( - - {children} - - ) : ( - children - )} - - {typeof label === 'string' ? ( - - {label} + + + + {/** Middle */} + + + {typeof title === 'string' ? ( + + {title} ) : ( - label + title + )} + {typeof children === 'string' ? ( + + {children} + + ) : ( + children )} - {/** Actions */} - {(!!clonedPrimaryAction || !!clonedSecondaryAction) && ( - - {clonedPrimaryAction} - {clonedSecondaryAction} - + {typeof label === 'string' ? ( + + {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/mobile/src/banner/__tests__/Banner.test.tsx b/packages/mobile/src/banner/__tests__/Banner.test.tsx index 96c94173bf..c92bd692db 100644 --- a/packages/mobile/src/banner/__tests__/Banner.test.tsx +++ b/packages/mobile/src/banner/__tests__/Banner.test.tsx @@ -3,7 +3,7 @@ import type { View } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { Text } from '../../typography/Text'; -import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { DefaultThemeProvider, treeHasStyleProp } from '../../utils/testHelpers'; import type { MobileBannerProps } from '../Banner'; import { Banner } from '../Banner'; @@ -39,6 +39,37 @@ describe('Banner', () => { flexDirection: 'column', }); }); + + it('keeps a stable root wrapper regardless of dismiss state', () => { + const { rerender, toJSON } = render(); + + const visibleTree = toJSON(); + expect(visibleTree).toBeTruthy(); + expect(Array.isArray(visibleTree)).toBe(false); + expect(visibleTree).toHaveProperty('type', 'View'); + + rerender(); + + const dismissibleTree = toJSON(); + expect(dismissibleTree).toBeTruthy(); + expect(Array.isArray(dismissibleTree)).toBe(false); + expect(dismissibleTree).toHaveProperty('type', 'View'); + }); + + it('applies styles.root and styles.content', () => { + const { toJSON } = render( + , + ); + + const tree = toJSON(); + expect(treeHasStyleProp(tree, (s) => s.borderTopWidth === 1)).toBe(true); + expect(treeHasStyleProp(tree, (s) => s.borderBottomWidth === 2)).toBe(true); + }); }); describe('Banner actions', () => { diff --git a/packages/mobile/src/buttons/AvatarButton.tsx b/packages/mobile/src/buttons/AvatarButton.tsx index d817afc8ae..3bad218697 100644 --- a/packages/mobile/src/buttons/AvatarButton.tsx +++ b/packages/mobile/src/buttons/AvatarButton.tsx @@ -1,15 +1,101 @@ import React, { memo } from 'react'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; -import type { SharedProps } from '@coinbase/cds-common/types'; import { Avatar, type AvatarBaseProps } from '../media'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; import type { ButtonBaseProps } from './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 and will be removed in a future major release. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ + borderTopWidth?: PressableBaseProps['borderTopWidth']; + /** + * @deprecated Border props on `AvatarButton` have no effect and will be removed in a future major release. 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 AvatarButtonProps = PressableBaseProps & - SharedProps & - Pick & + DeprecatedAvatarButtonBorderProps & + Pick & Pick; export const AvatarButton = memo(function AvatarButton({ @@ -21,24 +107,23 @@ export const AvatarButton = memo(function AvatarButton({ colorScheme, borderColor, name, - ...props + ...pressableProps }: AvatarButtonProps) { - const height = compact ? interactableHeight.compact : interactableHeight.regular; - return ( diff --git a/packages/mobile/src/buttons/Button.tsx b/packages/mobile/src/buttons/Button.tsx index f71fe47b47..0bd170ae16 100644 --- a/packages/mobile/src/buttons/Button.tsx +++ b/packages/mobile/src/buttons/Button.tsx @@ -1,30 +1,29 @@ import React, { forwardRef, isValidElement, memo, useCallback, useMemo } from 'react'; -import { - ActivityIndicator, - type PressableStateCallbackType, - StyleSheet, - type View, -} from 'react-native'; +import { type PressableStateCallbackType, StyleSheet, type View } from 'react-native'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { ButtonVariant, IconName, + NegativeSpace, SharedAccessibilityProps, SharedProps, } from '@coinbase/cds-common/types'; -import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; +import { Spinner } from '../loaders'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; import { Text } from '../typography/Text'; +import { ProgressCircle } from '../visualizations/ProgressCircle'; + +const defaultProgressCircleSize = 24; + +const defaultLoadingSpinnerSize = 24; export const styles = StyleSheet.create({ inline: { width: 'auto', - minWidth: 64, }, block: { width: '100%', @@ -50,6 +49,10 @@ export type ButtonBaseProps = SharedProps & disabled?: boolean; /** Mark the button as loading and display a spinner. */ loading?: boolean; + /** Size of the loading progress circle in px. + * @default 24 + */ + progressCircleSize?: number; /** Mark the background and border as transparent until interacted with. */ transparent?: boolean; /** Change to block and expand to 100% of parent width. */ @@ -93,6 +96,7 @@ export const Button = memo( { variant = 'primary', loading, + progressCircleSize = defaultProgressCircleSize, transparent, block, compact, @@ -117,7 +121,7 @@ export const Button = memo( wrapperStyles, feedback = compact ? 'light' : 'normal', borderColor, - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border borderRadius = compact ? 700 : 900, accessibilityLabel, accessibilityHint, @@ -140,12 +144,9 @@ export const Button = memo( const sizingStyle = block ? styles.block : styles.inline; const justifyContent = flush ? 'flex-start' : hasIcon ? 'space-between' : 'center'; - const minHeight = interactableHeight[compact ? 'compact' : 'regular']; - - const { paddingX, paddingY, marginStart, marginEnd } = getButtonSpacingProps({ - compact, - flush, - }); + const paddingX = compact ? 2 : 4; + const paddingY = compact ? 1 : 2; + const flushMargin = flush ? (-paddingX as NegativeSpace) : undefined; const pressableStyle = useCallback( (state: PressableStateCallbackType) => [ @@ -157,8 +158,8 @@ export const Button = memo( const childrenNode = useMemo( () => - isValidElement<{ children?: React.ReactNode }>(children) && - Boolean(children.props.children) ? ( + isValidElement(children) && + Boolean((children.props as Record).children) ? ( children ) : ( {loading ? ( - + ) : ( <> {start ?? diff --git a/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx b/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx index 694477bbfa..8c9fcf6da7 100644 --- a/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx +++ b/packages/mobile/src/buttons/DefaultSlideButtonBackground.tsx @@ -1,9 +1,9 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { forwardRef, memo } from 'react'; import type { View } from 'react-native'; -import { animated, to } from '@react-spring/native'; +import Animated, { useAnimatedStyle } from 'react-native-reanimated'; import { Box } from '../layout/Box'; -import { TextHeadline } from '../typography/TextHeadline'; +import { Text } from '../typography/Text'; import type { SlideButtonBackgroundProps } from './SlideButton'; @@ -26,8 +26,8 @@ export const DefaultSlideButtonBackground = memo( ) => { const horizontalPadding = compact ? 7 : 9; - const animatedStyle = useMemo( - () => ({ opacity: disabled ? 0.5 : to(progress, (value) => 1 - value) }), + const animatedStyle = useAnimatedStyle( + () => ({ opacity: disabled ? 0.5 : 1 - progress.value }), [progress, disabled], ); @@ -47,19 +47,20 @@ export const DefaultSlideButtonBackground = memo( style={style} width="100%" > - + {typeof uncheckedLabel !== 'string' ? ( uncheckedLabel ) : ( - {uncheckedLabel} - + )} - + ); }, diff --git a/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx b/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx index 9d0e6f0b3a..96526b7fc9 100644 --- a/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx +++ b/packages/mobile/src/buttons/DefaultSlideButtonHandle.tsx @@ -1,24 +1,36 @@ -import React, { forwardRef, memo, useMemo } from 'react'; +import React, { forwardRef, memo, useEffect, useMemo } from 'react'; import { StyleSheet } from 'react-native'; import type { View } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withDelay, + withSpring, + type WithSpringConfig, +} from 'react-native-reanimated'; import { variants } from '@coinbase/cds-common/tokens/button'; -import { - animated, - type SpringConfig, - useChain, - useSpring, - useSpringRef, -} from '@react-spring/native'; +import type { SpringConfig } from '@react-spring/core'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Box } from '../layout/Box'; -import { Spinner } from '../loaders/Spinner'; import { Pressable } from '../system/Pressable'; -import { TextHeadline } from '../typography/TextHeadline'; +import { Text } from '../typography/Text'; +import { ProgressCircle } from '../visualizations/ProgressCircle'; import type { SlideButtonBaseProps, SlideButtonHandleProps } from './SlideButton'; +export const slideButtonSpringConfig = { + stiffness: 300, + damping: 26, + mass: 1, + overshootClamping: true, +} as const satisfies WithSpringConfig; + +/** + * @deprecated SlideButton no longer uses react-spring; this value no longer used by {@link DefaultSlideButtonHandle} but retained for migration only. Use {@link slideButtonSpringConfig} with Reanimated `withSpring` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const animationConfig = { tension: 300, clamp: true } as const satisfies SpringConfig; export type SlideButtonHandleCheckedProps = Pick & { @@ -61,19 +73,28 @@ export const styles = StyleSheet.create({ export const SlideButtonHandleChecked = memo( ({ label, end, compact }: SlideButtonHandleCheckedProps) => { const theme = useTheme(); - const handleWidth = compact ? 40 : 56; + const iconSize = compact ? 's' : 'm'; + const iconSizeValue = theme.iconSize[iconSize]; return ( - {typeof label !== 'string' ? label : {label}} + {typeof label !== 'string' ? ( + label + ) : ( + + {label} + + )} - {end ?? } + {end ?? ( + + )} ); @@ -82,16 +103,16 @@ export const SlideButtonHandleChecked = memo( export const SlideButtonHandleUnchecked = memo( ({ start, compact }: SlideButtonHandleUncheckedProps) => { + const theme = useTheme(); const iconSize = compact ? 's' : 'm'; - const handleWidth = compact ? 40 : 56; return ( {start ?? } @@ -122,32 +143,27 @@ export const DefaultSlideButtonHandle = memo( ) => { const backgroundColor = variants[variant].background; - const checkedSpringRef = useSpringRef(); - const uncheckedSpringRef = useSpringRef(); - const checkedSpring = useSpring({ - opacity: checked ? 1 : 0, - ref: checkedSpringRef, - config: animationConfig, - immediate: !checked, - }); - const uncheckedSpring = useSpring({ - opacity: checked ? 0 : 1, - ref: uncheckedSpringRef, - config: animationConfig, - }); - useChain( - checked ? [uncheckedSpringRef, checkedSpringRef] : [checkedSpringRef, uncheckedSpringRef], - [0, 0.1], - ); + const checkedOpacity = useSharedValue(checked ? 1 : 0); + const uncheckedOpacity = useSharedValue(checked ? 0 : 1); + + useEffect(() => { + if (checked) { + uncheckedOpacity.value = withSpring(0, slideButtonSpringConfig); + checkedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); + } else { + checkedOpacity.value = 0; + uncheckedOpacity.value = withDelay(100, withSpring(1, slideButtonSpringConfig)); + } + }, [checked, checkedOpacity, uncheckedOpacity]); const containerStyle = useMemo(() => [styles.base, style], [style]); - const animatedCheckedStyle = useMemo( - () => [styles.absoluteContainer, { opacity: checkedSpring.opacity }], - [checkedSpring], + const animatedCheckedStyle = useAnimatedStyle( + () => ({ opacity: checkedOpacity.value }), + [checkedOpacity], ); - const animatedUncheckedStyle = useMemo( - () => [styles.absoluteContainer, { opacity: uncheckedSpring.opacity }], - [uncheckedSpring], + const animatedUncheckedStyle = useAnimatedStyle( + () => ({ opacity: uncheckedOpacity.value }), + [uncheckedOpacity], ); return ( @@ -165,7 +181,7 @@ export const DefaultSlideButtonHandle = memo( loading={checked} {...props} > - + - - + + - + ); }, diff --git a/packages/mobile/src/buttons/IconButton.tsx b/packages/mobile/src/buttons/IconButton.tsx index 72977c670a..41ff1e5541 100644 --- a/packages/mobile/src/buttons/IconButton.tsx +++ b/packages/mobile/src/buttons/IconButton.tsx @@ -1,29 +1,27 @@ -import React, { forwardRef, memo, useCallback, useMemo } from 'react'; -import { - ActivityIndicator, - type PressableStateCallbackType, - type View, - type ViewStyle, -} from 'react-native'; +import { forwardRef, memo } from 'react'; +import { type View } from 'react-native'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; import type { IconButtonVariant, IconName, IconSize, + NegativeSpace, SharedProps, } from '@coinbase/cds-common/types'; -import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; +import { ProgressCircle } from '../visualizations/ProgressCircle'; -import type { ButtonBaseProps } from './Button'; +import { type ButtonBaseProps } from './Button'; export type IconButtonBaseProps = SharedProps & Omit & - Pick & { + Pick< + ButtonBaseProps, + 'disabled' | 'transparent' | 'compact' | 'flush' | 'loading' | 'progressCircleSize' + > & { /** Name of the icon, as defined in Figma. */ name: IconName; /** @@ -54,12 +52,13 @@ export const IconButton = memo( color, borderColor, iconSize = compact ? 's' : 'm', - borderWidth = 100, + borderWidth = 0, // remove Pressable's default transparent border borderRadius = 1000, feedback = compact ? 'light' : 'normal', flush, + padding = compact ? 1.5 : 2, loading, - style, + progressCircleSize, accessibilityHint, accessibilityLabel, ...props @@ -67,7 +66,7 @@ export const IconButton = memo( ref, ) { const theme = useTheme(); - + const iconSizeValue = theme.iconSize[iconSize]; const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -75,62 +74,40 @@ export const IconButton = memo( const backgroundValue = background ?? variantStyle.background; const borderColorValue = borderColor ?? variantStyle.borderColor; - const minHeight = interactableHeight[compact ? 'compact' : 'regular']; - - const { marginStart, marginEnd } = getButtonSpacingProps({ compact, flush }); - - const sizingStyle = useMemo( - () => ({ - height: minHeight, - width: minHeight, - alignItems: 'center', - flexDirection: 'column', - justifyContent: 'center', - }), - [minHeight], - ); - - const pressableStyle = useCallback( - (state: PressableStateCallbackType) => [ - sizingStyle, - typeof style === 'function' ? style(state) : style, - ], - [sizingStyle, style], - ); + const flushMargin = flush ? (-padding as NegativeSpace) : undefined; return ( {loading ? ( - ) : ( /* TO DO: test using currentColor like web does on Icon here */ - + )} ); diff --git a/packages/mobile/src/buttons/IconCounterButton.tsx b/packages/mobile/src/buttons/IconCounterButton.tsx index c7361cea0c..980c1b0fd1 100644 --- a/packages/mobile/src/buttons/IconCounterButton.tsx +++ b/packages/mobile/src/buttons/IconCounterButton.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef, memo } from 'react'; -import type { View } from 'react-native'; +import React, { forwardRef, memo, useMemo } from 'react'; +import type { PressableStateCallbackType, StyleProp, TextStyle, View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { IconSize, ValidateProps } from '@coinbase/cds-common/types'; import { formatCount } from '@coinbase/cds-common/utils/formatCount'; @@ -27,11 +27,23 @@ 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` or `color` to customize icon color. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ dangerouslySetColor?: string; }; -export type IconCounterButtonProps = IconCounterButtonBaseProps & PressableProps; +export type IconCounterButtonProps = IconCounterButtonBaseProps & + PressableProps & { + /** Custom styles for individual elements of the IconCounterButton component */ + styles?: { + /** Root Pressable element */ + root?: PressableProps['style']; + /** Icon element rendered when `icon` is an icon name */ + icon?: StyleProp; + }; + }; export const IconCounterButton = memo( forwardRef(function IconCounterButton( @@ -43,14 +55,31 @@ export const IconCounterButton = memo( count = 0, color = 'fg', dangerouslySetColor, + styles, + style, ...props }: IconCounterButtonProps, ref: React.ForwardedRef, ) { + const rootStyleOverride = styles?.root; + + const rootStyle = useMemo(() => { + if (typeof style === 'function' || typeof rootStyleOverride === 'function') { + return (state: PressableStateCallbackType) => { + const baseStyle = typeof style === 'function' ? style(state) : style; + const rootOverride = + typeof rootStyleOverride === 'function' ? rootStyleOverride(state) : rootStyleOverride; + return [baseStyle, rootOverride]; + }; + } + return [style, rootStyleOverride]; + }, [rootStyleOverride, style]); + return ( @@ -64,6 +93,7 @@ export const IconCounterButton = memo( dangerouslySetColor={dangerouslySetColor} name={icon as IconName} size={size} + style={styles?.icon} /> ) : ( icon diff --git a/packages/mobile/src/buttons/SlideButton.tsx b/packages/mobile/src/buttons/SlideButton.tsx index 031b1c81e6..c04564ef45 100644 --- a/packages/mobile/src/buttons/SlideButton.tsx +++ b/packages/mobile/src/buttons/SlideButton.tsx @@ -1,19 +1,25 @@ -import React, { forwardRef, memo, useCallback, useId, useMemo } from 'react'; +import React, { forwardRef, memo, useCallback, useEffect, useId, useMemo } from 'react'; import { type AccessibilityActionEvent, type StyleProp, View, type ViewStyle } from 'react-native'; import type { ForwardedRef } from 'react'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; -import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeight'; +import Animated, { + type SharedValue, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import type { ButtonVariant } from '@coinbase/cds-common/types'; -import type { SpringValue } from '@react-spring/native'; -import { animated, to, useSpring } from '@react-spring/native'; import { useLayout } from '../hooks/useLayout'; import type { PressableProps } from '../system/Pressable'; import { DefaultSlideButtonBackground } from './DefaultSlideButtonBackground'; -import { animationConfig, DefaultSlideButtonHandle } from './DefaultSlideButtonHandle'; +import { DefaultSlideButtonHandle, slideButtonSpringConfig } from './DefaultSlideButtonHandle'; export const slideButtonTestID = 'slide-button'; +// +export const DEFAULT_COMPACT_HEIGHT = 40; +export const DEFAULT_REGULAR_HEIGHT = 56; export type SlideButtonBackgroundProps = Pick< SlideButtonBaseProps, @@ -28,7 +34,7 @@ export type SlideButtonBackgroundProps = Pick< | 'uncheckedLabel' | 'variant' > & { - progress: SpringValue; + progress: SharedValue; style?: StyleProp; }; @@ -43,7 +49,7 @@ export type SlideButtonHandleProps = PressableProps & | 'endCheckedNode' | 'variant' > & { - progress: SpringValue; + progress: SharedValue; style?: StyleProp; }; @@ -102,6 +108,8 @@ export type SlideButtonBaseProps = Omit & { * Height of the entire button component (background and handle). * If you pass a custom SlideButtonBackgroundComponent or SlideButtonHandleComponent, * this property will be applied to both. + * + * @default 40px for compact variant, 56px for regular variant */ height?: number; /** @@ -172,7 +180,7 @@ export const SlideButton = memo( onSlideComplete, onChange, disabled, - height, + height = compact ? DEFAULT_COMPACT_HEIGHT : DEFAULT_REGULAR_HEIGHT, checkThreshold = 0.7, SlideButtonHandleComponent = DefaultSlideButtonHandle, SlideButtonBackgroundComponent = DefaultSlideButtonBackground, @@ -189,13 +197,16 @@ export const SlideButton = memo( const labelId = useId(); const [containerSize, onLayout] = useLayout(); - const { progress } = useSpring({ progress: checked ? 1 : 0, config: animationConfig }); + const progress = useSharedValue(checked ? 1 : 0); - const buttonMinHeight = interactableHeight[compact ? 'compact' : 'regular']; - const buttonMinWidth = buttonMinHeight; + useEffect(() => { + progress.value = withSpring(checked ? 1 : 0, slideButtonSpringConfig); + }, [checked, progress]); + + const buttonMinWidth = height; const handleComplete = useCallback(() => { - void progress.start(1); + progress.value = withSpring(1, slideButtonSpringConfig); onChange?.(true); onSlideComplete?.(); onSlideEnd?.(); @@ -241,7 +252,7 @@ export const SlideButton = memo( const progressValue = autoCompleteSlideOnThresholdMet ? newWidth : Math.min(1, newWidth); - void progress.set(progressValue); + progress.value = progressValue; }) .onEnd(({ translationX }) => { if (checked || disabled) return; @@ -253,7 +264,7 @@ export const SlideButton = memo( return; } - void progress.start(0); + progress.value = withSpring(0, slideButtonSpringConfig); onSlideCancel?.(); onSlideEnd?.(); }) @@ -276,22 +287,24 @@ export const SlideButton = memo( ); const containerStyle = useMemo( - () => [ - { height: height ?? buttonMinHeight, width: '100%', position: 'relative' } as const, - styles?.container, - ], - [height, buttonMinHeight, styles?.container], + () => [{ height, width: '100%', position: 'relative' } as const, styles?.container], + [height, styles?.container], ); - const animatedStyle = useMemo( - () => - ({ - position: 'absolute', - height: height ?? buttonMinHeight, - minWidth: buttonMinWidth, - width: to(progress, (value) => `${value * 100}%`), - }) as const, - [height, buttonMinHeight, buttonMinWidth, progress], + const animatedWidthStyle = useAnimatedStyle( + () => ({ + width: `${progress.value * 100}%`, + }), + [progress], + ); + + const staticHandleStyle = useMemo( + () => ({ + position: 'absolute' as const, + height, + minWidth: buttonMinWidth, + }), + [height, buttonMinWidth], ); return ( @@ -311,7 +324,7 @@ export const SlideButton = memo( variant={variant} /> - + - + ); diff --git a/packages/mobile/src/buttons/__stories__/AvatarButton.stories.tsx b/packages/mobile/src/buttons/__stories__/AvatarButton.stories.tsx index d435b6121d..2466c6d92f 100644 --- a/packages/mobile/src/buttons/__stories__/AvatarButton.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/AvatarButton.stories.tsx @@ -1,10 +1,11 @@ import React, { useCallback, useState } from 'react'; -import { Text, View } from 'react-native'; +import { View } from 'react-native'; import { getAvatarFallbackColor } from '@coinbase/cds-common/media/getAvatarFallbackColor'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { HStack } from '../../layout/HStack'; import { VStack } from '../../layout/VStack'; +import { Text } from '../../typography/Text'; import type { AvatarButtonProps } from '../AvatarButton'; import { AvatarButton } from '../AvatarButton'; diff --git a/packages/mobile/src/buttons/__stories__/Button.stories.tsx b/packages/mobile/src/buttons/__stories__/Button.stories.tsx index 27d061fc8e..55660958af 100644 --- a/packages/mobile/src/buttons/__stories__/Button.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/Button.stories.tsx @@ -10,14 +10,15 @@ import { Button, type ButtonProps } from '../Button'; import { ButtonGroup } from '../ButtonGroup'; 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 }, @@ -25,6 +26,14 @@ const buttonStories: Omit[] = [ { disabled: true }, { loading: true }, { loading: true, compact: true }, + { loading: true, transparent: true }, + { loading: true, transparent: true, compact: true }, + { loading: true, variant: 'secondary' }, + { loading: true, variant: 'secondary', compact: true }, + { loading: true, variant: 'positive' }, + { loading: true, variant: 'positive', compact: true }, + { loading: true, variant: 'negative' }, + { loading: true, variant: 'negative', compact: true }, { startIcon: 'backArrow' }, { endIcon: 'backArrow' }, { startIcon: 'backArrow', endIcon: 'forwardArrow' }, diff --git a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx index 459ad7fd99..57333a3903 100644 --- a/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/IconButton.stories.tsx @@ -3,7 +3,7 @@ import type { GestureResponderEvent } from 'react-native'; import { names } from '@coinbase/cds-icons/names'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; -import { HStack } from '../../layout'; +import { HStack, VStack } from '../../layout'; import { Box } from '../../layout/Box'; import { Text } from '../../typography/Text'; import { IconButton, type IconButtonProps } from '../IconButton'; @@ -40,12 +40,6 @@ const variants = [ ), title: Secondary transparent, }, - { - component: (props?: Partial) => ( - - ), - title: ForegroundMuted, - }, { component: (props?: Partial) => ( ) => ( - + ), - title: ForegroundMuted transparent, + title: 'Primary flush start', + }, + { + component: (props?: Partial) => ( + + ), + title: 'Primary flush end', }, ]; @@ -86,6 +86,15 @@ const IconButtonScreen = () => { Disabled secondary + + + Loading primary + @@ -119,17 +128,77 @@ const IconButtonScreen = () => {
+ + + + + + Loading by variant + + + + + + + + + + + + Loading by icon size + + + + + + + + + + + Loading compact vs regular + + + + + + + + {names.map((name) => { return ( - ); diff --git a/packages/mobile/src/buttons/__stories__/IconCounterButton.stories.tsx b/packages/mobile/src/buttons/__stories__/IconCounterButton.stories.tsx index d1380fd754..fda52b85ab 100644 --- a/packages/mobile/src/buttons/__stories__/IconCounterButton.stories.tsx +++ b/packages/mobile/src/buttons/__stories__/IconCounterButton.stories.tsx @@ -42,8 +42,8 @@ const IconCounterButtonScreen = () => { diff --git a/packages/mobile/src/buttons/__tests__/AvatarButton.test.tsx b/packages/mobile/src/buttons/__tests__/AvatarButton.test.tsx index a836334cba..2041313ffe 100644 --- a/packages/mobile/src/buttons/__tests__/AvatarButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/AvatarButton.test.tsx @@ -1,4 +1,4 @@ -import { Animated, Pressable } from 'react-native'; +import { Animated } from 'react-native'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { debounce } from '../../utils/debounce'; @@ -28,14 +28,16 @@ describe('AvatarButton', () => { expect(screen.UNSAFE_queryAllByType(Animated.View)).toHaveLength(1); }); - it('renders a pressable', () => { + it('renders and responds to press', () => { + const onPress = jest.fn(); render( - + , ); - expect(screen.UNSAFE_queryAllByType(Pressable)).toHaveLength(1); + fireEvent.press(screen.getByTestId('avatar-button')); + expect(onPress).toHaveBeenCalled(); }); it('renders children Avatar', () => { @@ -69,4 +71,20 @@ describe('AvatarButton', () => { expect(spy).toHaveBeenCalled(); }); + + it('accepts deprecated border props without changing rendering', () => { + render( + + + , + ); + + expect(screen.getByTestId('avatar-button')).toBeTruthy(); + }); }); diff --git a/packages/mobile/src/buttons/__tests__/Button.perf-test.tsx b/packages/mobile/src/buttons/__tests__/Button.perf-test.tsx index 5f440a7c2c..815022cc52 100644 --- a/packages/mobile/src/buttons/__tests__/Button.perf-test.tsx +++ b/packages/mobile/src/buttons/__tests__/Button.perf-test.tsx @@ -1,6 +1,6 @@ /* eslint-disable jest/expect-expect */ import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; -import { fireEvent, screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react-native'; import { measurePerformance } from 'reassure'; import { Button } from '../Button'; @@ -8,7 +8,7 @@ import { Button } from '../Button'; describe('Button performance tests', () => { it('fires `onPress` when clicked', async () => { const scenario = async () => { - fireEvent.click(screen.getByRole('button')); + fireEvent.press(screen.getByRole('button')); }; await measurePerformance(, { 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__/IconButton.test.tsx b/packages/mobile/src/buttons/__tests__/IconButton.test.tsx index 87185fa307..8ed8bf4abf 100644 --- a/packages/mobile/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/mobile/src/buttons/__tests__/IconButton.test.tsx @@ -96,24 +96,24 @@ describe('IconButton', () => { expect(screen.getByTestId('test-test-id')).toBeTruthy(); }); - it('does not render ActivityIndicator when not loading', () => { + it('does not render ProgressCircle when not loading', () => { render( , ); - expect(screen.queryByTestId('icon-button-activity-indicator')).toBeNull(); + expect(screen.queryByTestId('icon-button-progress-circle')).toBeNull(); }); - it('renders ActivityIndicator when loading', () => { + it('renders ProgressCircle when loading', () => { render( , ); - expect(screen.getByTestId('icon-button-activity-indicator')).toBeTruthy(); + expect(screen.getByTestId('icon-button-progress-circle')).toBeTruthy(); }); it('renders Icon with overridden iconSize', () => { 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 96678f0013..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) => ({ @@ -58,6 +62,10 @@ const getBorderRadiusPinStyle = (borderRadius: number) => ({ all: {}, }); +/** + * @deprecated Use ContentCard instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const Card = memo(function OldCard({ children, background = 'bg', diff --git a/packages/mobile/src/cards/CardBody.tsx b/packages/mobile/src/cards/CardBody.tsx index 13748f95ca..c42de534c0 100644 --- a/packages/mobile/src/cards/CardBody.tsx +++ b/packages/mobile/src/cards/CardBody.tsx @@ -92,6 +92,10 @@ const CardBodyAction = memo(function CardBodyAction({ ); }); +/** + * @deprecated Use ContentCardBody instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardBody = memo(function CardBody({ testID = 'card-body', title, diff --git a/packages/mobile/src/cards/CardFooter.tsx b/packages/mobile/src/cards/CardFooter.tsx index c55ec5518c..d122674aaf 100644 --- a/packages/mobile/src/cards/CardFooter.tsx +++ b/packages/mobile/src/cards/CardFooter.tsx @@ -16,6 +16,10 @@ export type CardFooterBaseProps = Pick< export type CardFooterProps = CardFooterBaseProps & Omit; +/** + * @deprecated Use ContentCardFooter instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardFooter = memo(function CardFooter({ children, paddingBottom = 2, diff --git a/packages/mobile/src/cards/CardGroup.tsx b/packages/mobile/src/cards/CardGroup.tsx index d420669a5c..7f9135d6fd 100644 --- a/packages/mobile/src/cards/CardGroup.tsx +++ b/packages/mobile/src/cards/CardGroup.tsx @@ -6,10 +6,22 @@ import { Divider } from '../layout/Divider'; import type { GroupProps, RenderGroupItem } from '../layout/Group'; import { Group } from '../layout/Group'; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardGroupBaseProps = GroupProps; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardGroupProps = CardGroupBaseProps; export type CardGroupRenderItem = RenderGroupItem; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardGroup = memo( forwardRef(function CardGroup( { diff --git a/packages/mobile/src/cards/CardHeader.tsx b/packages/mobile/src/cards/CardHeader.tsx index 0c868ed308..e0259c5cdf 100644 --- a/packages/mobile/src/cards/CardHeader.tsx +++ b/packages/mobile/src/cards/CardHeader.tsx @@ -5,8 +5,16 @@ import { HStack } from '../layout/HStack'; import { RemoteImage } from '../media/RemoteImage'; import { Text } from '../typography/Text'; +/** + * @deprecated Use ContentCardHeaderProps instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardHeaderProps = CardHeaderBaseProps; +/** + * @deprecated Use ContentCardHeader instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardHeader = memo( ({ avatar, metaData, description, action, testID }: CardHeaderProps) => { return ( diff --git a/packages/mobile/src/cards/CardMedia.tsx b/packages/mobile/src/cards/CardMedia.tsx index f7bb277370..a6c7f5b3f7 100644 --- a/packages/mobile/src/cards/CardMedia.tsx +++ b/packages/mobile/src/cards/CardMedia.tsx @@ -13,6 +13,10 @@ import type { import { Pictogram, SpotSquare } from '../illustrations'; import { getSource, RemoteImage } from '../media/RemoteImage'; +/** + * @deprecated Use SpotSquare when `type` is "spotSquare", Pictogram when `type` is "pictogram", or RemoteImage when `type` is "image". This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardMediaProps = CommonCardMediaProps; const imageProps: Record = { @@ -27,26 +31,16 @@ const imageProps: Record = { end: defaultMediaSize, }; +/** + * @deprecated Use SpotSquare when `type` is "spotSquare", Pictogram when `type` is "pictogram", or RemoteImage when `type` is "image". This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ 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 b3f55c0b92..19a3378405 100644 --- a/packages/mobile/src/cards/LikeButton.tsx +++ b/packages/mobile/src/cards/LikeButton.tsx @@ -7,11 +7,11 @@ 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 { useTheme } from '../hooks/useTheme'; import { TextIcon } from '../icons/TextIcon'; import { HStack } from '../layout/HStack'; import type { PressableProps } from '../system/Pressable'; @@ -25,7 +25,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, @@ -42,6 +42,7 @@ const scaleOut = convertMotionConfig(scaleOutConfig); export const LikeButton = memo(function LikeButton({ count = 0, compact = true, + padding = compact ? 1.5 : 2, // mirror IconButton's padding flush, liked = false, onPress, @@ -52,7 +53,7 @@ export const LikeButton = memo(function LikeButton({ }: LikeButtonProps) { 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 }); @@ -79,6 +80,12 @@ export const LikeButton = memo(function LikeButton({ [], ); + // 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 18e2e55947..047b9ded89 100644 --- a/packages/mobile/src/carousel/Carousel.tsx +++ b/packages/mobile/src/carousel/Carousel.tsx @@ -44,7 +44,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 & { @@ -129,10 +129,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'; }; @@ -194,6 +194,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 @@ -263,14 +268,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 & { @@ -556,6 +553,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -573,7 +571,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props }: CarouselProps, ref: React.ForwardedRef, @@ -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 a59a3cd17c..ca140ec8e1 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'; @@ -35,6 +41,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; @@ -59,7 +66,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. */ @@ -105,7 +112,24 @@ export const Cell = memo(function Cell({ 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, @@ -144,9 +168,55 @@ export const Cell = memo(function Cell({ 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 } : {}), @@ -234,7 +304,7 @@ export const Cell = memo(function Cell({ ); }, [ - borderRadius, + borderProps, testID, disabled, selected, @@ -282,7 +352,7 @@ export const Cell = memo(function Cell({ accessibilityState={{ disabled, ...accessibilityState }} background="bg" blendStyles={blendStyles} - borderRadius={borderRadius} + {...borderProps} contentStyle={pressStyles} disabled={disabled} onPress={onPress} @@ -305,7 +375,7 @@ export const Cell = memo(function Cell({ 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 2467d975a9..fc93eb98f0 100644 --- a/packages/mobile/src/cells/ListCell.tsx +++ b/packages/mobile/src/cells/ListCell.tsx @@ -3,7 +3,7 @@ import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; import { compactListHeight, listHeight } from '@coinbase/cds-common/tokens/cell'; 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'; @@ -176,16 +176,20 @@ export const ListCell = memo(function ListCell({ variant, onPress, spacingVariant = compact ? 'compact' : 'normal', + minHeight: minHeightProp, style, styles, ...props }: ListCellProps) { + // 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 61caf5b527..228dbca7fe 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 c8a4820451..ad00408c3b 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 8482067cf8..c21fb0a9c5 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 { useTheme } from '../hooks/useTheme'; @@ -71,13 +71,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 b06a0dff01..c189e7f8fe 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'; @@ -87,7 +88,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', @@ -120,6 +130,8 @@ export const InputStack = memo(function InputStack({ inputBackground = 'bg', borderWidth = 100, focusedBorderWidth = borderWidth, + styles, + style, ...props }: InputStackProps) { const theme = useTheme(); @@ -222,10 +234,23 @@ export const InputStack = memo(function InputStack({ 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 && ( @@ -245,7 +270,7 @@ export const InputStack = memo(function InputStack({ )} {!!startNode && <>{startNode}} {!!labelNode && labelVariant === 'inside' ? ( - + {labelNode} {inputNode} @@ -266,9 +291,9 @@ export const InputStack = memo(function InputStack({ // 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 2745edca55..1ef36bff2f 100644 --- a/packages/mobile/src/controls/Radio.tsx +++ b/packages/mobile/src/controls/Radio.tsx @@ -46,11 +46,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 ( @@ -118,13 +118,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 55d822a1a3..973f975bbb 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'; @@ -117,7 +117,7 @@ export const SearchInput = memo( const refs = useMergeRefs(ref, internalRef); const handleOnFocus = useCallback( - (e: NativeSyntheticEvent) => { + (e: FocusEvent) => { onFocus?.(e); if (!disableBackArrow && startIcon === undefined) { @@ -128,7 +128,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 33567862f6..f9edd09c73 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'; @@ -25,6 +25,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(function SelectOption({ title, description, diff --git a/packages/mobile/src/controls/Switch.tsx b/packages/mobile/src/controls/Switch.tsx index b57d06b4fd..fff09a9c2a 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 { useTheme } from '../hooks/useTheme'; import { Box } from '../layout/Box'; @@ -9,10 +9,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, @@ -95,31 +115,39 @@ const SwitchIcon = ({ }; const SwitchWithRef = forwardRef(function SwitchWithRef( - { children, ...props }: SwitchProps, + { children, style, styles, ...props }: SwitchProps, ref: React.ForwardedRef, ) { 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 1b05c1d182..f9f408de78 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 { useInputBorderStyle } from '../hooks/useInputBorderStyle'; @@ -176,13 +174,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); }, @@ -203,7 +201,7 @@ export const TextInput = memo( ...(labelVariant === 'inside' && hasLabel && !compact && { - paddingBottom: 0, + paddingBottom: theme.space[1], paddingTop: 0, }), }), @@ -223,7 +221,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, @@ -320,6 +319,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 5e25b47d4c..bd1df5293b 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'; @@ -90,7 +90,7 @@ export const DateInput = memo( */ const handleBlur = useCallback( - (event: NativeSyntheticEvent) => { + (event: BlurEvent) => { onBlur?.(event); if (!required || !hasTyped.current) return; const error = validateDateInput(inputValue); @@ -122,7 +122,6 @@ export const DateInput = memo( ); diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index 789db33a7f..fada52242a 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 b6d45920a2..60c3f6f056 100644 --- a/packages/mobile/src/dots/DotCount.tsx +++ b/packages/mobile/src/dots/DotCount.tsx @@ -55,8 +55,6 @@ const [opacityEnter, opacityExit, scaleEnter, scaleExit] = convertMotionConfigs( dotScaleExitConfig, ]); -const dotTextPaddingHorizontal = 6; - const variantColorMap: Record = { negative: 'bgNegative', }; @@ -86,6 +84,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 & { @@ -108,6 +118,8 @@ export const DotCount = memo( variant = 'negative', count, max, + height = dotCountSize, + width, overlap, style, styles, @@ -117,7 +129,7 @@ export const DotCount = memo( const [childrenSize, onChildrenLayout] = useDotsLayout(); const transforms = useDotPinStyles( childrenSize, - { width: dotCountSize + dotTextPaddingHorizontal, height: dotCountSize } as LayoutRectangle, + { width: width ?? height, height } as LayoutRectangle, overlap, ); @@ -143,11 +155,17 @@ export const DotCount = memo( 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(() => { @@ -189,11 +207,6 @@ export const DotCount = memo( [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 @@ -207,7 +220,7 @@ export const DotCount = memo( {!shouldUnmount && shouldShow && ( - + {parseDotCountMaxOverflow(countInternal, max)} @@ -223,9 +236,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 19bf9b7237..ba9b456615 100644 --- a/packages/mobile/src/dots/DotSymbol.tsx +++ b/packages/mobile/src/dots/DotSymbol.tsx @@ -121,7 +121,7 @@ export const DotSymbol = memo( 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 5966716216..5ba1a32028 100644 --- a/packages/mobile/src/icons/Icon.tsx +++ b/packages/mobile/src/icons/Icon.tsx @@ -46,7 +46,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>; @@ -158,7 +161,8 @@ export const Icon = memo(function Icon({ 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 db8c0645de..2d32e8a82a 100644 --- a/packages/mobile/src/layout/Fallback.tsx +++ b/packages/mobile/src/layout/Fallback.tsx @@ -19,7 +19,7 @@ export type FallbackBaseProps = { * @default rectangle */ shape?: Shape; - width: number | string; + width: DimensionValue; /** Disables randomization of rectangle shape width. */ disableRandomRectWidth?: boolean; /** @@ -48,7 +48,11 @@ export const Fallback = memo(function Fallback({ [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/loaders/Spinner.tsx b/packages/mobile/src/loaders/Spinner.tsx index 12437c729b..2582e3c59b 100644 --- a/packages/mobile/src/loaders/Spinner.tsx +++ b/packages/mobile/src/loaders/Spinner.tsx @@ -4,6 +4,10 @@ import type { ActivityIndicatorProps } from 'react-native'; import { useTheme } from '../hooks/useTheme'; +/** + * @deprecated Use indeterminate ProgressCircle or ActivityIndicator component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const Spinner = memo(function Spinner({ size = 'small', animating, diff --git a/packages/mobile/src/media/Avatar.tsx b/packages/mobile/src/media/Avatar.tsx index c239dffd87..187f5a842a 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, @@ -21,6 +23,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'; @@ -105,8 +108,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -117,8 +120,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -129,8 +132,8 @@ export const Avatar = memo( return ( {placeholderLetter} @@ -149,10 +152,9 @@ export const Avatar = memo( () => ( @@ -162,11 +164,40 @@ export const Avatar = memo( [avatarText, shapeStyle, colorSchemeRgb], ); + const hexagonColoredFallback = useMemo( + () => ( + + + + + + + + + + {avatarText} + + ), + [avatarText, colorSchemeRgb], + ); + return ( + ) : shape === 'hexagon' ? ( + hexagonColoredFallback ) : ( coloredFallback )} @@ -210,4 +243,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 8d590f9e88..f6adb981d2 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 { useTheme } from '../hooks/useTheme'; @@ -43,7 +44,7 @@ type BaseRemoteImageProps = Omit { return ( - + - + {image} @@ -227,11 +228,11 @@ export const RemoteImage = memo(function RemoteImage({ ); } @@ -285,8 +286,8 @@ export const RemoteImage = memo(function RemoteImage({ 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 1d4d70b970..b0f3b96b2b 100644 --- a/packages/mobile/src/media/RemoteImageGroup.tsx +++ b/packages/mobile/src/media/RemoteImageGroup.tsx @@ -87,15 +87,22 @@ export const RemoteImageGroup = ({ 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 1928e70edb..34bc5b87bd 100644 --- a/packages/mobile/src/media/__tests__/Avatar.test.tsx +++ b/packages/mobile/src/media/__tests__/Avatar.test.tsx @@ -14,12 +14,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(); }); @@ -145,4 +143,23 @@ 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(); + }); }); 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 6edb4ed15f..ebdda497fa 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 { Icon } from '../icons'; import { HStack } from '../layout/HStack'; import { type DrawerRefBaseProps, Tray } from '../overlays'; @@ -35,16 +35,18 @@ export const NavigationTitleSelect = memo( 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 ( <> @@ -61,11 +63,26 @@ export const NavigationTitleSelect = memo( {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 7a72f73bb5..2a2b6226b8 100644 --- a/packages/mobile/src/navigation/TopNavBar.tsx +++ b/packages/mobile/src/navigation/TopNavBar.tsx @@ -162,7 +162,7 @@ export const TopNavBar = memo( 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 fdf3550570..680d5d38d6 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'; @@ -17,7 +16,6 @@ import { Overlay } from './overlay/Overlay'; import { useAlertAnimation } from './useAlertAnimation'; export type AlertBaseProps = SharedProps & - Pick & Pick & { /** * Alert title @@ -57,6 +55,7 @@ export type AlertBaseProps = SharedProps & * @default horizontal */ actionLayout?: 'horizontal' | 'vertical'; + zIndex?: ViewStyle['zIndex']; }; export type AlertProps = AlertBaseProps; @@ -191,7 +190,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 d406c5237f..4d76e66f33 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, @@ -7,7 +8,6 @@ import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Button } from '../buttons'; import { useA11y } from '../hooks/useA11y'; -import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps, HStack } from '../layout'; import { ColorSurge } from '../motion/ColorSurge'; import { Text } from '../typography/Text'; @@ -24,7 +24,7 @@ export const Toast = memo( { text, action, onWillHide, onDidHide, bottomOffset, variant, accessibilityLabel, ...props }, ref, ) => { - const theme = useTheme(); + const { bottom: safeAreaBottom } = useSafeAreaInsets(); const [{ opacity, bottom }, animateIn, animateOut] = useToastAnimation(); const { announceForA11y } = useA11y(); const defaultA11yLabel = text + (action ? action.label : ''); @@ -71,8 +71,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 918c8e9ea8..56b7253cfb 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, @@ -28,6 +28,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useHasNotch } from '../../hooks/useHasNotch'; import { useTheme } from '../../hooks/useTheme'; import { Box } from '../../layout/Box'; import { HandleBar, type HandleBarProps } from '../handlebar/HandleBar'; @@ -39,7 +40,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 6578022243..3b15d19423 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -24,7 +24,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 92049d357b..41f4dc21d8 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -2,9 +2,10 @@ 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 { 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 955f3390f3..0038001e15 100644 --- a/packages/mobile/src/page/PageHeader.tsx +++ b/packages/mobile/src/page/PageHeader.tsx @@ -2,11 +2,12 @@ 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 { 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 d1a48d81e0..31cf1721e8 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -1,19 +1,16 @@ -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 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'; @@ -73,13 +70,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; @@ -110,6 +107,7 @@ export type StepperHeaderProps = Record activeStep: StepperValue | null; flatStepIds: string[]; complete?: boolean; + disableAnimateOnMount?: boolean; style?: StyleProp; }; @@ -129,9 +127,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; @@ -215,9 +213,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; @@ -248,8 +251,22 @@ export type StepperProps = Record = Record>( props: StepperProps & { ref?: React.Ref }, @@ -287,14 +304,13 @@ const StepperBase = memo( StepperHeaderComponent = direction === 'vertical' ? null : (DefaultStepperHeaderHorizontal as StepperHeaderComponent), - progressSpringConfig = defaultProgressSpringConfig, + progressTimingConfig = defaultProgressTimingConfig, animate = true, disableAnimateOnMount, ...props }: StepperProps, ref: React.Ref, ) => { - const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); // Derive activeStep from activeStepId @@ -339,92 +355,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 ( @@ -448,42 +430,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 73475aa465..7cd566e9bb 100644 --- a/packages/mobile/src/tabs/TabLabel.tsx +++ b/packages/mobile/src/tabs/TabLabel.tsx @@ -78,12 +78,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 ebfa6d87eb..b77b0cdbc3 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -31,7 +31,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 ; @@ -204,6 +206,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 e53dfd049e..fe02cafb01 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('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 0 }], }); }); @@ -131,7 +134,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 68 }, { translateY: 0 }], }); }); @@ -210,7 +212,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 20 }, { translateY: 0 }], }); }); @@ -244,7 +245,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ width: 68, - height: 40, transform: [{ translateX: 0 }, { translateY: 8 }], }); }); @@ -278,7 +278,6 @@ describe('SegmentedTabs', () => { expect(screen.getByTestId('tabs-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 ce625bd35f..efcad6c784 100644 --- a/packages/mobile/src/tag/Tag.tsx +++ b/packages/mobile/src/tag/Tag.tsx @@ -100,12 +100,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} > @@ -116,9 +116,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 7242ce1ea6..4ad1754a1f 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, @@ -27,6 +27,7 @@ import { } from '@floating-ui/react-native'; import { animated, config as springConfig, useSpring } from '@react-spring/native'; +import { useDimensions } from '../hooks/useDimensions'; import { useTheme } from '../hooks/useTheme'; import { DefaultTourMask } from './DefaultTourMask'; @@ -116,6 +117,7 @@ const TourComponent = ({ testID, }: TourProps) => { const theme = useTheme(); + const { statusBarHeight } = useDimensions(); const defaultTourStepOffset = theme.space[3]; const defaultTourStepShiftPadding = theme.space[4]; @@ -155,7 +157,7 @@ const TourComponent = ({ [animationApi, onChange], ); - const api = useTour({ steps, activeTourStep, onChange: handleChange }); + const api = useTour({ steps, activeTourStep, onChange: handleChange }); const { activeTourStepTarget, setActiveTourStepTarget } = api; // Component Lifecycle & Side Effects @@ -175,9 +177,18 @@ const TourComponent = ({ const handleActiveTourStepTargetChange = useCallback( (target: View | null) => { 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 }); }, }); @@ -185,7 +196,7 @@ const TourComponent = ({ setActiveTourStepTarget(target); }, - [animationApi, refs, setActiveTourStepTarget], + [animationApi, refs, setActiveTourStepTarget, statusBarHeight], ); return ( @@ -209,7 +220,7 @@ const TourComponent = ({ {!(activeTourStep.hideOverlay ?? hideOverlay) && !!activeTourStepTarget && ( 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/DefaultProgressCircleContent.tsx b/packages/mobile/src/visualizations/DefaultProgressCircleContent.tsx index fc80a842f0..c17630fa2c 100644 --- a/packages/mobile/src/visualizations/DefaultProgressCircleContent.tsx +++ b/packages/mobile/src/visualizations/DefaultProgressCircleContent.tsx @@ -7,7 +7,7 @@ import { ProgressTextLabel } from './ProgressTextLabel'; export const DefaultProgressCircleContent = memo( ({ - progress, + progress = 0, disableAnimateOnMount, disabled, color = 'fgMuted', diff --git a/packages/mobile/src/visualizations/ProgressBar.tsx b/packages/mobile/src/visualizations/ProgressBar.tsx index b0890f6747..851b4dc258 100644 --- a/packages/mobile/src/visualizations/ProgressBar.tsx +++ b/packages/mobile/src/visualizations/ProgressBar.tsx @@ -9,9 +9,8 @@ import { } from 'react-native'; import { animateProgressBaseSpec } from '@coinbase/cds-common/animation/progress'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { usePreviousValues } from '@coinbase/cds-common/hooks/usePreviousValues'; import type { SharedAccessibilityProps, SharedProps, Weight } from '@coinbase/cds-common/types'; -import { useProgressSize } from '@coinbase/cds-common/visualizations/useProgressSize'; +import { getProgressSize } from '@coinbase/cds-common/visualizations/getProgressSize'; import { convertMotionConfig } from '../animation/convertMotionConfig'; import { useTheme } from '../hooks/useTheme'; @@ -22,7 +21,7 @@ export type ProgressBaseProps = SharedProps & Pick & Pick & { /** Number between 0-1 representing the progress percentage */ - progress: number; + progress?: number; /** Toggle used to change thickness of progress visualization * @default normal * */ @@ -62,7 +61,7 @@ export const ProgressBar = memo( ( { weight = 'normal', - progress, + progress = 0, color = 'bgPrimary', disabled, disableAnimateOnMount, @@ -76,7 +75,7 @@ export const ProgressBar = memo( forwardedRef: React.ForwardedRef, ) => { const theme = useTheme(); - const height = useProgressSize(weight); + const height = getProgressSize(weight); const animatedProgress = useRef(new Animated.Value(disableAnimateOnMount ? progress : 0)); @@ -159,7 +158,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/ProgressBarWithFloatLabel.tsx b/packages/mobile/src/visualizations/ProgressBarWithFloatLabel.tsx index bae9872ad2..4f3c8f94b0 100644 --- a/packages/mobile/src/visualizations/ProgressBarWithFloatLabel.tsx +++ b/packages/mobile/src/visualizations/ProgressBarWithFloatLabel.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, I18nManager, @@ -8,7 +8,6 @@ import { type ViewStyle, } from 'react-native'; import { animateProgressBaseSpec } from '@coinbase/cds-common/animation/progress'; -import { usePreviousValues } from '@coinbase/cds-common/hooks/usePreviousValues'; import type { Placement } from '@coinbase/cds-common/types'; import { convertMotionConfig } from '../animation/convertMotionConfig'; @@ -19,6 +18,11 @@ import { getProgressBarLabelParts, type ProgressBarLabel } from './getProgressBa import { type ProgressBaseProps } from './ProgressBar'; import { ProgressTextLabel } from './ProgressTextLabel'; +const getEndTranslateX = (containerWidth: number, textWidth: number, progress: number) => + I18nManager.isRTL + ? Math.min(containerWidth - textWidth, containerWidth - containerWidth * progress) + : Math.max(0, containerWidth * progress - textWidth); + export type ProgressBarFloatLabelProps = Pick< ProgressBarWithFloatLabelProps, 'label' | 'progress' | 'disableAnimateOnMount' | 'disabled' | 'labelPlacement' | 'styles' @@ -28,73 +32,53 @@ const ProgressBarFloatLabel = memo( ({ label, disabled, - progress, + progress = 0, disableAnimateOnMount, labelPlacement, styles, }: ProgressBarFloatLabelProps) => { const [textWidth, setTextWidth] = useState(-1); - const { addPreviousValue: addPreviousPercent } = usePreviousValues([ - disableAnimateOnMount ? progress : 0, - ]); const [size, onLayout] = useLayout(); const containerWidth = size.width; - const [hasAnimationMounted, setHasAnimationMounted] = useState(!disableAnimateOnMount); - const animatedProgress = useMemo(() => new Animated.Value(0), []); - - addPreviousPercent(progress); + const animatedTranslateX = useRef(new Animated.Value(0)); const { value: labelNum, render: renderLabel } = getProgressBarLabelParts(label); useEffect(() => { - if (containerWidth > 0 && textWidth > -1) { - if (!hasAnimationMounted && disableAnimateOnMount) { - animatedProgress.setValue(progress); - setHasAnimationMounted(true); - } else { - Animated.timing( - animatedProgress, - convertMotionConfig({ - toValue: progress, - ...animateProgressBaseSpec, - useNativeDriver: true, - }), - )?.start(); - } + if (containerWidth <= 0 || textWidth < 0) return; + + const targetTranslateX = getEndTranslateX(containerWidth, textWidth, progress); + + if (disableAnimateOnMount) { + animatedTranslateX.current.setValue(targetTranslateX); + } else { + Animated.timing( + animatedTranslateX.current, + convertMotionConfig({ + toValue: targetTranslateX, + ...animateProgressBaseSpec, + useNativeDriver: true, + }), + ).start(); } - }, [ - progress, - containerWidth, - textWidth, - animatedProgress, - disableAnimateOnMount, - hasAnimationMounted, - ]); + }, [progress, containerWidth, textWidth, disableAnimateOnMount]); const handleTextLayout = useCallback((event: LayoutChangeEvent) => { setTextWidth(event.nativeEvent.layout.width); }, []); + const hasDimensions = containerWidth > 0 && textWidth > -1; + const containerStyle = useMemo(() => [styles?.labelContainer], [styles?.labelContainer]); const labelStyle = useMemo( () => [ { - opacity: hasAnimationMounted ? 1 : 0, - transform: [ - { - translateX: animatedProgress.interpolate({ - inputRange: [0, 1], - outputRange: [ - I18nManager.isRTL ? containerWidth - textWidth : 0, - I18nManager.isRTL ? 0 : containerWidth - textWidth, - ], - }), - }, - ], + opacity: hasDimensions ? 1 : 0, + transform: [{ translateX: animatedTranslateX.current }], }, ], - [containerWidth, textWidth, hasAnimationMounted, animatedProgress], + [hasDimensions], ); return ( diff --git a/packages/mobile/src/visualizations/ProgressCircle.tsx b/packages/mobile/src/visualizations/ProgressCircle.tsx index e1fb90da12..37b7e5a7cd 100644 --- a/packages/mobile/src/visualizations/ProgressCircle.tsx +++ b/packages/mobile/src/visualizations/ProgressCircle.tsx @@ -1,12 +1,12 @@ import React, { forwardRef, memo, useEffect, useMemo, useRef } from 'react'; -import { Animated, type StyleProp, type View, type ViewStyle } from 'react-native'; +import { Animated, type StyleProp, StyleSheet, type View, type ViewStyle } from 'react-native'; 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 { useProgressSize } from '@coinbase/cds-common/visualizations/useProgressSize'; +import { getProgressSize } from '@coinbase/cds-common/visualizations/getProgressSize'; import { isTest } from '@coinbase/cds-utils'; import { convertMotionConfig } from '../animation/convertMotionConfig'; @@ -22,6 +22,7 @@ import { type CircleType = React.ComponentClass; const AnimatedCircle = Animated.createAnimatedComponent(Circle as CircleType); +const AnimatedSvg = Animated.createAnimatedComponent(Svg); export type ProgressCircleBaseProps = ProgressBaseProps & { /** @@ -45,6 +46,10 @@ export type ProgressCircleBaseProps = ProgressBaseProps & { * Optional component to override the default content rendered inside the circle. */ contentNode?: React.ReactNode; + /** + * Toggle used to show an indeterminate progress circle. + */ + indeterminate?: boolean; }; export type ProgressCircleProps = ProgressCircleBaseProps & { @@ -80,26 +85,26 @@ export type ProgressCircleContentProps = Pick< type ProgressInnerCircleProps = Pick< ProgressCircleBaseProps, - 'progress' | 'onAnimationEnd' | 'onAnimationStart' | 'disableAnimateOnMount' + 'progress' | 'onAnimationEnd' | 'onAnimationStart' | 'disableAnimateOnMount' | 'indeterminate' > & - Required> & { + Required> & { visuallyDisabled?: boolean; style?: Partial; + strokeWidth: number; }; const ProgressCircleInner = memo( ({ size, - progress, + progress = 0, color, - weight, + strokeWidth, visuallyDisabled, style, onAnimationEnd, onAnimationStart, disableAnimateOnMount, }: ProgressInnerCircleProps) => { - const strokeWidth = useProgressSize(weight); const theme = useTheme(); const circleRef = useRef>(null); @@ -128,7 +133,7 @@ const ProgressCircleInner = memo( return ( 0 ? 'round' : 'butt'} @@ -148,14 +153,15 @@ export const ProgressCircle = memo( forwardRef( ( { + indeterminate, weight = 'normal', - progress, + progress = indeterminate ? 0.75 : 0, // Default is empty string due to iOS VoiceOver repeating percentage multiple times when // a11y label isn't specified - accessibilityLabel = '', - color = 'bgPrimary', + accessibilityLabel = indeterminate ? 'Loading' : '', + color = indeterminate ? 'fgMuted' : 'bgPrimary', disabled, - disableAnimateOnMount, + disableAnimateOnMount = indeterminate ? true : false, testID, hideContent, hideText, @@ -169,7 +175,7 @@ export const ProgressCircle = memo( forwardedRef: React.ForwardedRef, ) => { const theme = useTheme(); - const strokeWidth = useProgressSize(weight); + const strokeWidth = getProgressSize(weight); const visSize = size ?? '100%'; @@ -180,37 +186,68 @@ export const ProgressCircle = memo( [strokeWidth, styles?.textContainer], ); + const animatedRotate = useRef(new Animated.Value(0)); + + useEffect(() => { + if (!indeterminate) return; + // if indeterminate, animate the rotation of the svg + const animation = Animated.loop( + Animated.timing( + animatedRotate.current, + convertMotionConfig({ + toValue: 1, + duration: 'slow4', + easing: 'linear', + fromValue: 0, + }), + ), + ); + animation.start(); + return () => animation.stop(); + }, [indeterminate]); + return ( - {({ width, height, circleSize }: VisualizationContainerDimension) => ( - + {({ width, height, circleSize }: VisualizationContainerDimension) => { + return ( - @@ -226,16 +263,17 @@ export const ProgressCircle = memo( - + {!hideText && !hideContent && ( {/* We clip the content node to the circle to prevent the node from overflowing over the circle */} @@ -247,21 +285,29 @@ export const ProgressCircle = memo( overflow="hidden" width="100%" > - {contentNode ?? ( - - )} + {contentNode ?? + (!indeterminate && ( + + ))} )} - - )} + ); + }} ); }, ), ); + +const styleSheet = StyleSheet.create({ + svg: { + flexGrow: 0, + flexShrink: 0, + }, +}); 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/__stories__/ProgressCircle.stories.tsx b/packages/mobile/src/visualizations/__stories__/ProgressCircle.stories.tsx index 4733449b5b..ff587afbd3 100644 --- a/packages/mobile/src/visualizations/__stories__/ProgressCircle.stories.tsx +++ b/packages/mobile/src/visualizations/__stories__/ProgressCircle.stories.tsx @@ -107,6 +107,58 @@ const ProgressBarScreen = () => { )} + + + {({ calculateProgress }) => ( + + + + + + + + + )} + + {({ calculateProgress }) => ( diff --git a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx index 5253f33f71..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( - + @@ -94,7 +93,7 @@ describe('ProgressBar test', () => { // necessary for Animated.timing delay act(() => void jest.runAllTimers()); expect(floatLabel).toHaveStyle({ - transform: [{ translateX: 90 }], // (200/2) -10 + transform: [{ translateX: 80 }], // containerWidth * progress - textWidth = 200*0.5 - 20 }); expect(screen.getAllByText('50%')[0]).toBeDefined(); @@ -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( - + { expect(progressCircle.props.accessibilityValue.now).toBe(7); expect(Number.isInteger(progressCircle.props.accessibilityValue.now)).toBe(true); }); + + it('renders indeterminate progress circle without percentage text', () => { + const size = 100; + render( + + + , + ); + + const root = screen.getByTestId('indeterminate-progress-circle'); + expect(root.props.accessibilityRole).toBe('progressbar'); + expect(screen.getByTestId('cds-progress-circle-inner')).toBeTruthy(); + expect(screen.queryByText('75%')).toBeNull(); + expect(root).toBeAccessible(); + }); }); diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile/src/visualizations/chart/CartesianChart.tsx similarity index 99% rename from packages/mobile-visualization/src/chart/CartesianChart.tsx rename to packages/mobile/src/visualizations/chart/CartesianChart.tsx index 122bfa13a4..4d7e53a4c5 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile/src/visualizations/chart/CartesianChart.tsx @@ -1,11 +1,12 @@ import React, { forwardRef, memo, useCallback, useMemo } from 'react'; import { type StyleProp, type View, type ViewStyle } from 'react-native'; import type { Rect } from '@coinbase/cds-common/types'; -import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout'; -import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout'; -import { Box } from '@coinbase/cds-mobile/layout'; import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia'; +import { useLayout } from '../../hooks/useLayout'; +import type { BoxBaseProps, BoxProps } from '../../layout'; +import { Box } from '../../layout'; + import { ScrubberAccessibilityView, type ScrubberAccessibilityViewProps, diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile/src/visualizations/chart/ChartContextBridge.tsx similarity index 83% rename from packages/mobile-visualization/src/chart/ChartContextBridge.tsx rename to packages/mobile/src/visualizations/chart/ChartContextBridge.tsx index 89db490bf8..b057269767 100644 --- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx +++ b/packages/mobile/src/visualizations/chart/ChartContextBridge.tsx @@ -4,9 +4,21 @@ * https://github.com/pmndrs/its-fine/blob/598b81f02778c22ed21121c2b1a786bdefb14e23/src/index.tsx */ -import * as React from 'react'; +import { + Component, + type Context, + createContext, + type FC, + type PropsWithChildren, + type ReactNode, + useContext, + useId, + useMemo, + useState, +} from 'react'; import type ReactReconciler from 'react-reconciler'; -import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider'; + +import { ThemeContext } from '../../system/ThemeProvider'; import { ScrubberContext } from './utils/context'; import { CartesianChartContext } from './ChartProvider'; @@ -16,11 +28,7 @@ import { CartesianChartContext } from './ChartProvider'; * Only these contexts will be made available inside the chart's Skia tree. * This improves performance by avoiding the overhead of rendering every bridged context. */ -const BRIDGED_CONTEXTS: React.Context[] = [ - 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 95% rename from packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx rename to packages/mobile/src/visualizations/chart/scrubber/DefaultScrubberBeacon.tsx index 1dbdcc1944..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'; @@ -119,8 +119,8 @@ export const DefaultScrubberBeacon = memo( idlePulseShared.value = idlePulse ?? false; }, [idlePulse, idlePulseShared]); - const animatedX = useSharedValue(0); - const animatedY = useSharedValue(0); + const animatedX = useSharedValue(null); + const animatedY = useSharedValue(null); // Calculate the target point position - project data to pixels const targetPoint = useDerivedValue(() => { @@ -154,8 +154,10 @@ export const DefaultScrubberBeacon = memo( // Create animated point using the animated values const animatedPoint = useDerivedValue(() => { + // If the animated values have not been set yet, return the target point + if (animatedX.value === null || animatedY.value === null) return targetPoint.value; return { x: animatedX.value, y: animatedY.value }; - }, [animatedX, animatedY]); + }, [targetPoint, animatedX, animatedY]); useImperativeHandle( ref, 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 94% rename from packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx rename to packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconLabelGroup.tsx index 5755e17850..0e49c8be5e 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx +++ b/packages/mobile/src/visualizations/chart/scrubber/ScrubberBeaconLabelGroup.tsx @@ -62,19 +62,29 @@ const PositionedLabel = memo<{ const x = useDerivedValue(() => positions.value[index]?.x ?? 0, [positions, index]); const targetY = useDerivedValue(() => positions.value[index]?.y ?? 0, [positions, index]); - const animatedY = useSharedValue(0); + const idleAnimatedY = useSharedValue(null); useAnimatedReaction( () => ({ y: targetY.value, idle: unwrapAnimatedValue(isIdle) }), (current, previous) => { - if (previous === null || !previous.idle || !current.idle) { - animatedY.value = current.y; + // Only animate idle-to-idle updates, immediately set the value for other changes. + if (previous?.idle && current.idle) { + idleAnimatedY.value = buildTransition(current.y, updateTransition); } else { - animatedY.value = buildTransition(current.y, updateTransition); + idleAnimatedY.value = current.y; } }, [updateTransition], ); + // When scrubbing, use the targetY value, when idle, use the idleAnimatedY value. + const y = useDerivedValue( + () => + unwrapAnimatedValue(isIdle) && idleAnimatedY.value !== null + ? idleAnimatedY.value + : targetY.value, + [isIdle, idleAnimatedY, targetY], + ); + const dx = useDerivedValue(() => { return position.value === 'right' ? labelHorizontalOffset : -labelHorizontalOffset; }, [position, labelHorizontalOffset]); @@ -95,7 +105,7 @@ const PositionedLabel = memo<{ opacity={opacity} seriesId={seriesId} x={x} - y={animatedY} + y={y} /> ); }, 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/package.json b/packages/ui-mobile-playground/package.json index c306b08144..1a9f9f6a01 100644 --- a/packages/ui-mobile-playground/package.json +++ b/packages/ui-mobile-playground/package.json @@ -36,13 +36,12 @@ "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/native": "6.1.17", "@react-navigation/stack": "^6.3.16", - "react": "^18.3.1", - "react-native": "^0.74.5", - "react-native-safe-area-context": "^4.10.5" + "react": "~19.1.2", + "react-native": "~0.81.5", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1" }, "dependencies": { "lodash": "^4.17.21" @@ -50,12 +49,17 @@ "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-mobile": "workspace:^", "@react-navigation/elements": "^1.3.17", - "@react-navigation/native": "^6.1.6", + "@react-navigation/native": "6.1.17", "@react-navigation/stack": "^6.3.16", - "@types/react": "^18.3.12", - "react-native-safe-area-context": "4.10.5" + "@types/react": "19.1.2", + "react": "19.1.2", + "react-native": "0.81.5", + "react-native-safe-area-context": "5.6.0", + "react-native-svg": "15.12.1" } } diff --git a/packages/ui-mobile-playground/src/components/staticRoutes.ts b/packages/ui-mobile-playground/src/components/staticRoutes.ts index 65757231c5..21f1c8c3b1 100644 --- a/packages/ui-mobile-playground/src/components/staticRoutes.ts +++ b/packages/ui-mobile-playground/src/components/staticRoutes.ts @@ -1,3 +1,5 @@ +import type { JSX } from 'react'; + import { keyToRouteName } from './keyToRouteName'; export const initialRouteKey = 'Examples'; diff --git a/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx b/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx index 3c4fb60f83..66f12c1a31 100644 --- a/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx +++ b/packages/ui-mobile-playground/src/components/useExampleNavigatorProps.tsx @@ -2,7 +2,6 @@ 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'; @@ -10,7 +9,7 @@ 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 { Text } from '@coinbase/cds-mobile/typography/Text'; import type { StackHeaderProps, StackNavigationOptions } from '@react-navigation/stack'; import { SearchFilterContext, SetSearchFilterContext } from './ExamplesSearchProvider'; @@ -20,8 +19,6 @@ type UseExampleNavigatorPropsOptions = { setColorScheme?: React.Dispatch>; }; -const iconButtonHeight = interactableHeight.regular; - export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigatorPropsOptions) { const theme = useTheme(); const { top } = useSafeAreaInsets(); @@ -49,7 +46,11 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator const showBackButton = isFocused && canGoBack && !isSearch; const showSearch = routeName === initialRouteName; - const iconButtonPlaceholder = ; + const iconButtonPlaceholder = ( + + + + ); const leftHeaderButton = showSearch ? ( @@ -121,7 +122,9 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator value={searchFilter} /> ) : ( - {titleForScene} + + {titleForScene} + )} diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index a071d79cfa..d80ead566f 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/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, }, { @@ -336,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', @@ -346,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, }, { @@ -512,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, }, { @@ -553,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, }, { @@ -574,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, }, { @@ -618,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/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/CHANGELOG.md b/packages/web-visualization/CHANGELOG.md index ee611be4a1..b15f861240 100644 --- a/packages/web-visualization/CHANGELOG.md +++ b/packages/web-visualization/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to this project will be documented in this file. +## 3.4.0-beta.27 (4/1/2026 PST) + +This is an artificial version bump with no new change. + +## 3.4.0-beta.26 (3/31/2026 PST) + +This is an artificial version bump with no new change. + ## 3.4.0-beta.25 (3/24/2026 PST) #### 🐞 Fixes diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index f81cf7463e..bd3c780394 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -1,7 +1,8 @@ { "name": "@coinbase/cds-web-visualization", - "version": "3.4.0-beta.25", + "version": "3.4.0-beta.27", "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/CHANGELOG.md b/packages/web/CHANGELOG.md index 11379be821..68f6d8bb71 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,22 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Deprecate Card and its sub-components. [[#562](https://github.com/coinbase/cds/pull/562)] + +#### 📘 Misc + +- Chore: deprecate CardGroup. [[#560](https://github.com/coinbase/cds/pull/560)] + +## 8.60.0 (3/29/2026 PST) + +#### 🚀 Updates + +- Add indeterminate ProgressCircle. [[#501](https://github.com/coinbase/cds/pull/501)] + ## 8.59.0 (3/27/2026 PST) #### 🚀 Updates 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 893201da3c..2b416864a9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.59.0", + "version": "8.60.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", @@ -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,34 +223,39 @@ "@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" }, "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 51dd87e52f..173b02a2b9 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, @@ -104,7 +99,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 @@ -241,13 +239,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} @@ -257,6 +254,8 @@ const DefaultSelectControlComponent = memo( {label} @@ -264,7 +263,15 @@ const DefaultSelectControlComponent = memo( ) : ( label ), - [labelVariant, classNames?.controlLabelNode, styles?.controlLabelNode, label, setOpen], + [ + labelVariant, + compact, + classNames?.controlLabelNode, + styles?.controlLabelNode, + label, + shouldShowCompactLabel, + setOpen, + ], ); const valueNode = useMemo(() => { @@ -359,16 +366,8 @@ 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)} - paddingStart={1} role={role} style={styles?.controlInputNode} tabIndex={tabIndex} @@ -380,14 +379,14 @@ const DefaultSelectControlComponent = memo( height="100%" justifyContent="center" minWidth={0} - paddingX={1} + paddingEnd={2} style={styles?.controlStartNode} > {startNode} )} {shouldShowCompactLabel ? ( - + {labelNode} ) : null} @@ -411,8 +410,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} @@ -431,8 +428,6 @@ const DefaultSelectControlComponent = memo( classNames?.controlStartNode, classNames?.controlValueNode, disabled, - labelVariant, - compact, styles?.controlInputNode, styles?.controlStartNode, styles?.controlValueNode, @@ -441,7 +436,6 @@ const DefaultSelectControlComponent = memo( shouldShowCompactLabel, labelNode, align, - isMultiSelect, valueNode, contentNode, setOpen, @@ -457,8 +451,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 ? ( @@ -475,7 +467,6 @@ const DefaultSelectControlComponent = memo( [ classNames?.controlEndNode, labelVariant, - compact, styles?.controlEndNode, customEndNode, open, @@ -484,6 +475,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 413cb7d036..538c39f2bd 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(); @@ -303,7 +308,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 ef70b903a1..e82f80cb75 100644 --- a/packages/web/src/alpha/select/Select.tsx +++ b/packages/web/src/alpha/select/Select.tsx @@ -247,7 +247,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/__stories__/Select.stories.tsx b/packages/web/src/alpha/select/__stories__/Select.stories.tsx index 1523597d1f..f11d62b569 100644 --- a/packages/web/src/alpha/select/__stories__/Select.stories.tsx +++ b/packages/web/src/alpha/select/__stories__/Select.stories.tsx @@ -10,8 +10,8 @@ import { cx } from '../../../cx'; import { Icon } from '../../../icons/Icon'; import { HStack } from '../../../layout/HStack'; import { VStack } from '../../../layout/VStack'; -import { Spinner } from '../../../loaders'; import { Text } from '../../../typography/Text'; +import { ProgressCircle } from '../../../visualizations/ProgressCircle'; import { Select, type SelectControlComponent, @@ -551,11 +551,11 @@ export const MixedDefaultAndCustomComponentOptions = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onClick }) => { return ( - + - + ); }; @@ -1646,12 +1646,12 @@ export const CustomOptionComponent = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onClick }) => { return ( - - + + - + ); }; 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 2301b70491..e35f320ded 100644 --- a/packages/web/src/banner/Banner.tsx +++ b/packages/web/src/banner/Banner.tsx @@ -19,10 +19,12 @@ import type { import { css } from '@linaria/core'; import { Collapsible } from '../collapsible'; +import { cx } from '../cx'; 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'; @@ -37,6 +39,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; @@ -87,6 +112,7 @@ export type BannerBaseProps = SharedProps & { }; export type BannerProps = BannerBaseProps & + StylesAndClassNames & Omit, 'children' | 'title'>; export const Banner = memo( @@ -119,6 +145,8 @@ export const Banner = memo( marginStart, marginEnd, width = '100%', + classNames, + styles, ...props }: BannerProps, ref: React.ForwardedRef, @@ -200,123 +228,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} ) : ( - label + title + )} + {typeof children === 'string' ? ( + + {children} + + ) : ( + 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 ? ( + return ( - - {content} - + {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 a138cdcd48..f8ddf8e182 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'; @@ -11,11 +10,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' @@ -36,8 +124,6 @@ const baseCss = css` display: flex; align-items: center; justify-content: center; - width: var(--interactable-height); - height: var(--interactable-height); min-width: unset; `; @@ -53,6 +139,7 @@ export const AvatarButton: AvatarButtonComponent = memo( compact, colorScheme, shape, + borderColor, selected, name, ...props @@ -61,29 +148,24 @@ export const AvatarButton: AvatarButtonComponent = memo( ) => { 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 41d570c02d..2bc126c123 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, @@ -10,17 +10,22 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useResolveResponsiveProp } from '../hooks/useResolveResponsiveProp'; +import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; -import { Spinner } from '../loaders/Spinner'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; import { Text } from '../typography/Text'; +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 + */ export const spinnerHeight = 2.5; +const defaultProgressCircleSize = 24; + const baseCss = css` text-decoration: none; display: inline-flex; @@ -90,29 +95,10 @@ 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); `; -const spinnerStyle = { - width: '24px', - height: '24px', - border: '2px solid', - borderTopColor: 'var(--color-transparent)', - borderRightColor: 'var(--color-transparent)', - borderLeftColor: 'var(--color-transparent)', -}; - export const buttonDefaultElement = 'button'; export type ButtonDefaultElement = typeof buttonDefaultElement; @@ -130,6 +116,10 @@ export type ButtonBaseProps = Polymorphic.ExtendableProps< disabled?: boolean; /** Mark the button as loading and display a spinner. */ loading?: boolean; + /** Size of the loading progress circle in px. + * @default 24 + */ + progressCircleSize?: number; /** Mark the background and border as transparent until interacted with. */ transparent?: boolean; /** Change to block and expand to 100% of parent width. */ @@ -184,6 +174,7 @@ export const Button: ButtonComponent = memo( as, variant = 'primary', loading, + progressCircleSize = defaultProgressCircleSize, transparent, block, compact, @@ -205,20 +196,21 @@ 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 }: ButtonProps, ref?: Polymorphic.Ref, ) => { + const theme = useTheme(); const Component = (as ?? buttonDefaultElement) satisfies React.ElementType; const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon ?? endIcon); @@ -230,6 +222,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 ( @@ -282,7 +286,13 @@ export const Button: ButtonComponent = memo( {loading && ( - + )} 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( @@ -81,18 +66,18 @@ 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, flush, loading, + progressCircleSize, accessibilityLabel, accessibilityHint, ...props @@ -103,14 +88,20 @@ export const IconButton: IconButtonComponent = memo( const theme = useTheme(); const iconSizeValue = theme.iconSize[iconSize]; + const spinnerSize = iconSizeValue / 10; - const spinnerSizeStyles = useMemo( - () => ({ - width: iconSizeValue, - height: iconSizeValue, - }), - [iconSizeValue], - ); + 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]; @@ -130,32 +121,27 @@ 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 0fa2e524c8..07ae007a27 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 { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -25,14 +26,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( @@ -45,6 +64,10 @@ export const IconCounterButton = memo( color = 'fg', dangerouslySetColor, background = 'transparent', + styles, + classNames, + className, + style, ...props }: IconCounterButtonProps, ref: React.Ref, @@ -53,6 +76,8 @@ export const IconCounterButton = memo( > @@ -62,10 +87,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 bf8c6dcf5e..cd9cbae0d9 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 0aafa6118d..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 }, @@ -26,6 +27,14 @@ const buttonStories: Omit[] = [ { disabled: true }, { loading: true }, { loading: true, compact: true }, + { loading: true, transparent: true }, + { loading: true, transparent: true, compact: true }, + { loading: true, variant: 'secondary' }, + { loading: true, variant: 'secondary', compact: true }, + { loading: true, variant: 'positive' }, + { loading: true, variant: 'positive', compact: true }, + { loading: true, variant: 'negative' }, + { loading: true, variant: 'negative', compact: true }, { startIcon: 'backArrow' }, { endIcon: 'backArrow' }, { startIcon: 'backArrow', endIcon: 'forwardArrow' }, diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index 6c9261a94d..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', }, ]; @@ -102,6 +108,12 @@ export const Default = () => ( Variants Loading + {variants.map((variant, index) => ( + + {variant.component({ accessibilityLabel, loading: true, compact: false })} + {variant.title} + + ))} {variants.map((variant, index) => ( {variant.component({ accessibilityLabel, loading: true })} @@ -120,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/buttons/__tests__/IconButton.test.tsx b/packages/web/src/buttons/__tests__/IconButton.test.tsx index ccc5b9f40c..4b6992441a 100644 --- a/packages/web/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/web/src/buttons/__tests__/IconButton.test.tsx @@ -139,39 +139,39 @@ describe('IconButton', () => { expect(screen.getByTestId('test-test-id')).toBeDefined(); }); - it('renders Spinner when loading and not Icon', () => { + it('renders ProgressCircle when loading and not Icon', () => { render( , ); - expect(screen.getByTestId('icon-button-spinner')).toBeInTheDocument(); + expect(screen.getByTestId('icon-button-progress-circle')).toBeInTheDocument(); expect(screen.queryByTestId(`icon-${name}`)).not.toBeInTheDocument(); // Assuming Icon component adds a testID like this or similar identifiable attribute }); - it('renders Spinner with correct size when loading and compact', () => { + it('renders ProgressCircle with correct size when loading and compact', () => { render( , ); - const spinner = screen.getByTestId('icon-button-spinner'); - expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.s}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.s}px`); + const progressCircle = screen.getByTestId('icon-button-progress-circle'); + expect(progressCircle).toBeInTheDocument(); + expect(progressCircle).toHaveStyle({ '--width': `${defaultTheme.iconSize.s}px` }); + expect(progressCircle).toHaveStyle({ '--height': `${defaultTheme.iconSize.s}px` }); }); - it('renders Spinner with correct size when loading and not compact', () => { + it('renders ProgressCircle with correct size when loading and not compact', () => { render( , ); - const spinner = screen.getByTestId('icon-button-spinner'); - expect(spinner).toBeInTheDocument(); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.m}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.m}px`); + const progressCircle = screen.getByTestId('icon-button-progress-circle'); + expect(progressCircle).toBeInTheDocument(); + expect(progressCircle).toHaveStyle({ '--width': `${defaultTheme.iconSize.m}px` }); + expect(progressCircle).toHaveStyle({ '--height': `${defaultTheme.iconSize.m}px` }); }); it('renders Icon with overridden iconSize', () => { @@ -186,16 +186,16 @@ describe('IconButton', () => { ); }); - it('renders Spinner with overridden iconSize when loading', () => { + it('renders ProgressCircle with overridden iconSize when loading', () => { render( , ); - const spinner = screen.getByTestId('icon-button-spinner'); - expect(spinner).toHaveStyle(`width: ${defaultTheme.iconSize.xs}px`); - expect(spinner).toHaveStyle(`height: ${defaultTheme.iconSize.xs}px`); + const progressCircle = screen.getByTestId('icon-button-progress-circle'); + expect(progressCircle).toHaveStyle({ '--width': `${defaultTheme.iconSize.xs}px` }); + expect(progressCircle).toHaveStyle({ '--height': `${defaultTheme.iconSize.xs}px` }); }); it('sets data attributes for style variants', () => { diff --git a/packages/web/src/cards/Card.tsx b/packages/web/src/cards/Card.tsx index d26005bddc..47237bd75a 100644 --- a/packages/web/src/cards/Card.tsx +++ b/packages/web/src/cards/Card.tsx @@ -17,9 +17,17 @@ 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'>; +/** + * @deprecated Use ContentCard instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const Card = memo(function Card({ children, background = 'bg', @@ -65,7 +73,6 @@ export const Card = memo(function Card({ ), [background, borderRadius, children, elevation, height, linkable, pin, props, testID, width], ); - if (isAnchor) { return ( /** * Provides an opinionated layout for the typical content of a Card: a title, description, media, and action + * + * @deprecated Use ContentCardBody instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ export const CardBody = memo(function CardBody({ testID = 'card-body', diff --git a/packages/web/src/cards/CardFooter.tsx b/packages/web/src/cards/CardFooter.tsx index 4740515388..5f5dfcb1d2 100644 --- a/packages/web/src/cards/CardFooter.tsx +++ b/packages/web/src/cards/CardFooter.tsx @@ -13,6 +13,10 @@ export type CardFooterBaseProps = Pick & export type CardFooterProps = CardFooterBaseProps & Omit, 'children'>; +/** + * @deprecated Use ContentCardFooter instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardFooter: React.FC> = memo( function CardFooter({ children, paddingBottom = 2, paddingX = gutter, testID, ...otherProps }) { return ( diff --git a/packages/web/src/cards/CardGroup.tsx b/packages/web/src/cards/CardGroup.tsx index 05c41207f7..6abd4ee8e1 100644 --- a/packages/web/src/cards/CardGroup.tsx +++ b/packages/web/src/cards/CardGroup.tsx @@ -4,10 +4,22 @@ import { Divider } from '../layout/Divider'; import type { GroupProps, RenderGroupItem } from '../layout/Group'; import { Group } from '../layout/Group'; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardGroupBaseProps = Omit; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardGroupProps = CardGroupBaseProps; export type CardGroupRenderItem = RenderGroupItem; +/** + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardGroup = memo( forwardRef(function CardGroup( { accessibilityLabel, children, direction = 'vertical', divider = Divider, ...props }, diff --git a/packages/web/src/cards/CardHeader.tsx b/packages/web/src/cards/CardHeader.tsx index b575bd0881..cc227015ce 100644 --- a/packages/web/src/cards/CardHeader.tsx +++ b/packages/web/src/cards/CardHeader.tsx @@ -7,6 +7,10 @@ import { VStack } from '../layout/VStack'; import { Avatar } from '../media/Avatar'; import { Text } from '../typography/Text'; +/** + * @deprecated Use ContentCardHeader instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const CardHeader = memo(function CardHeader({ avatar, metaData, diff --git a/packages/web/src/cards/CardMedia.tsx b/packages/web/src/cards/CardMedia.tsx index 42b70225e8..8929f0c9b9 100644 --- a/packages/web/src/cards/CardMedia.tsx +++ b/packages/web/src/cards/CardMedia.tsx @@ -26,27 +26,17 @@ const imageProps: Record = { end: defaultMediaSize, }; +/** + * @deprecated Use SpotSquare when `type` is "spotSquare", Pictogram when `type` is "pictogram", or RemoteImage when `type` is "image". This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ 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 af5cd3f2da..7e486e1d06 100644 --- a/packages/web/src/cards/LikeButton.tsx +++ b/packages/web/src/cards/LikeButton.tsx @@ -1,8 +1,8 @@ -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 { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -15,7 +15,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,17 +29,24 @@ 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 48a6ddfae0..f3c02366a1 100644 --- a/packages/web/src/carousel/Carousel.tsx +++ b/packages/web/src/carousel/Carousel.tsx @@ -68,7 +68,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 & { /** @@ -162,10 +162,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'; }; @@ -234,6 +234,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 @@ -311,14 +316,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'> & @@ -715,6 +712,7 @@ export const Carousel = memo( title, hideNavigation, hidePagination, + paginationVariant, drag = 'snap', snapMode = 'page', NavigationComponent = DefaultCarouselNavigation, @@ -735,7 +733,6 @@ export const Carousel = memo( loop, autoplay, autoplayInterval = 3000, - paginationVariant, ...props }: CarouselProps, ref: React.ForwardedRef, @@ -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 99bbe51e29..5023f21e01 100644 --- a/packages/web/src/cells/Cell.tsx +++ b/packages/web/src/cells/Cell.tsx @@ -202,7 +202,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, @@ -259,11 +276,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 } : {}), @@ -365,7 +427,7 @@ export const Cell: CellComponent = memo( ); }, [ - borderRadius, + borderProps, contentClassName, classNames?.contentContainer, classNames?.topContent, @@ -414,7 +476,7 @@ export const Cell: CellComponent = memo( accessibilityLabel, accessibilityLabelledBy, background: 'bg' as const, - borderRadius, + ...borderProps, className: cx(pressCss, insetFocusRingCss, classNames?.pressable), disabled, marginX: innerSpacingMarginX, @@ -444,7 +506,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 fb809f0fff..b3b36766b0 100644 --- a/packages/web/src/cells/CellAccessory.tsx +++ b/packages/web/src/cells/CellAccessory.tsx @@ -48,7 +48,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 5e9f5db6bb..d23c1ffd50 100644 --- a/packages/web/src/cells/ListCell.tsx +++ b/packages/web/src/cells/ListCell.tsx @@ -29,6 +29,7 @@ export const condensedInnerSpacing = { paddingY: 1, marginX: 0, } as const satisfies CellSpacing; + // no padding outside of the pressable area export const condensedOuterSpacing = { paddingX: 0, @@ -234,18 +235,22 @@ export const ListCell: ListCellComponent = memo( style, subtitle, subtitleNode, + minHeight: minHeightProp, ...props }: ListCellProps, ref?: Polymorphic.Ref, ) => { 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 a463720149..efc9054da0 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 13864d78e6..4f82d48541 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 { @@ -42,7 +42,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 */ @@ -72,7 +72,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 96b5e75bac..c5bb1543d9 100644 --- a/packages/web/src/collapsible/Collapsible.tsx +++ b/packages/web/src/collapsible/Collapsible.tsx @@ -110,16 +110,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), @@ -62,7 +75,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. */ @@ -85,10 +105,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( { @@ -111,6 +132,10 @@ const ControlWithRef = forwardRef(function ControlWithRef, ref: React.ForwardedRef, @@ -125,7 +150,7 @@ const ControlWithRef = forwardRef(function ControlWithRef(); + const internalInputRef = useRef(undefined); const inputRef = useMergeRefs(ref, internalInputRef); const iconElement = useMemo( @@ -137,10 +162,10 @@ const ControlWithRef = forwardRef(function ControlWithRef {/* eslint-disable-next-line jsx-a11y/role-supports-aria-props */} @@ -150,12 +175,13 @@ const ControlWithRef = forwardRef(function ControlWithRef @@ -209,10 +239,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 b40695a31c..29d1181fe4 100644 --- a/packages/web/src/controls/InputStack.tsx +++ b/packages/web/src/controls/InputStack.tsx @@ -147,7 +147,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( @@ -174,6 +194,10 @@ export const InputStack = memo( labelVariant = 'outside', blendStyles, inputBackground = 'bg', + className, + style, + classNames, + styles, ...props }, ref, @@ -230,10 +254,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 3721c87030..188ca6fc69 100644 --- a/packages/web/src/controls/Radio.tsx +++ b/packages/web/src/controls/Radio.tsx @@ -17,11 +17,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 ( @@ -45,9 +45,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 b32faa50bd..588963b322 100644 --- a/packages/web/src/controls/SearchInput.tsx +++ b/packages/web/src/controls/SearchInput.tsx @@ -1,27 +1,21 @@ import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react'; import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import type { IconName } from '@coinbase/cds-common/types'; -import { css } from '@linaria/core'; -import { cx } from '../cx'; import { Box } from '../layout/Box'; 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, -}; - -const baseCss = css` - height: ${scales.regular}px; -`; - -const compactCss = css` - height: ${scales.compact}px; -`; +} as const; export type SearchInputBaseProps = Pick< TextInputBaseProps, @@ -148,7 +142,6 @@ export const SearchInput = memo( 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( ({ title, diff --git a/packages/web/src/controls/Switch.tsx b/packages/web/src/controls/Switch.tsx index 96155b4803..6b279c2436 100644 --- a/packages/web/src/controls/Switch.tsx +++ b/packages/web/src/controls/Switch.tsx @@ -4,13 +4,28 @@ 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 { 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); @@ -37,12 +52,22 @@ const thumbCss = css` left: 1px; `; -export type SwitchProps = ControlBaseProps & { - /** Sets the checked/active color of the control. - * @default bgPrimary - */ - controlColor?: ThemeVars.Color; -}; +export type SwitchProps = ControlBaseProps & + StylesAndClassNames & { + /** + * Label content rendered next to the switch control. + * + * @example + * ```tsx + * Dark mode + * ``` + */ + children?: React.ReactNode; + /** Sets the checked/active color of the control. + * @default bgPrimary + */ + controlColor?: ThemeVars.Color; + }; const MotionBox = motion(Box); @@ -67,6 +92,10 @@ const SwitchWithRef = forwardRef(function SwitchW borderRadius = 1000, borderWidth, value, + className, + style, + classNames, + styles, ...props }, ref, @@ -78,9 +107,11 @@ const SwitchWithRef = forwardRef(function SwitchW 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} @@ -91,19 +122,21 @@ const SwitchWithRef = forwardRef(function SwitchW 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" > (function SwitchW ); - return children ? ( + return ( {switchNode} - ) : ( - switchNode ); }); diff --git a/packages/web/src/controls/TextInput.tsx b/packages/web/src/controls/TextInput.tsx index 5861d1ddef..477fea9093 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'; @@ -81,12 +81,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 `