diff --git a/.claude/agents/design-system-researcher.md b/.claude/agents/design-system-researcher.md index 51e13078c7..4c8f8b14a8 100644 --- a/.claude/agents/design-system-researcher.md +++ b/.claude/agents/design-system-researcher.md @@ -70,6 +70,24 @@ These are the projects you may be tasked to research: - packages/@mantine/charts/src/ - packages/@mantine/modals/src/ +### React Aria + +- Name: React Aria +- Docs: https://react-aria.adobe.com/ +- Repo: https://github.com/adobe/react-spectrum.git +- Branch: main +- Component Paths: + - packages/react-aria-components/src/ + +### Tamagui + +- Name: Tamagui +- Docs: https://tamagui.dev/docs/intro/introduction +- Repo: https://github.com/tamagui/tamagui.git +- Branch: master +- Component Paths: + - code/ui/ + ## Research Methodology Follow this systematic approach: @@ -81,19 +99,8 @@ Follow this systematic approach: - If the theme of the research goal cannot be found within the project source code, abandon the research task 2. **Environment Preparation** - - Check if the project's repository has already been cloned: - ```bash - ls temp/repo-cache | grep {{REPO NAME}} - ``` - - If the repository has already been cloned, update it to the latest commit: - ```bash - cd temp/repo-cache/{{PROJECT NAME}} && git pull --quiet - ``` - - If the repository has not been cloned, clone it: - ```bash - git clone --depth 1 {{PROJECT REPO URL}} temp/repo-cache/{{PROJECT NAME}} --quiet - ``` - - **NEVER** clone another repository outside of the SINGLE project you are researching. + - Use the `git.repo-manager` skill (`.claude/skills/git.repo-manager/SKILL.md`) to ensure the project's repository is cloned and up to date in `temp/repo-cache/`. + - Only manage the single repository you are researching. 3. **Deep Technical Analysis** - Examine actual source code implementations, not just documentation @@ -175,11 +182,13 @@ Create a comprehensive markdown report with the following structure: ## File Management -1. Create your report in the `.claude/research/` directory -2. Use a descriptive filename format: `[project]-[topic]-[date].md` - - Example: `material-ui-theming-2024-01-15.md` -3. Ensure the directory exists before writing (create if needed) -4. After completing your research and writing the report, explicitly communicate the filename to the parent agent +1. Reports should be organized in subdirectories within `.claude/research/` based on the research goal +2. Convert the research goal into a kebab-case directory name (e.g., "theming architecture" → `theming-architecture`) +3. Create your report in the research goal subdirectory: `.claude/research/[research-goal]/` +4. Use a descriptive filename format: `[project]-[date].md` + - Example: `.claude/research/theming-architecture/material-ui-2024-01-15.md` +5. Ensure the directory exists before writing (create if needed) +6. After completing your research and writing the report, explicitly communicate the full file path to the parent agent ## Quality Standards diff --git a/.claude/commands/competitor-research.md b/.claude/commands/competitor-research.md deleted file mode 100644 index 43817eae4e..0000000000 --- a/.claude/commands/competitor-research.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -argument-hint: [research goal (e.g. "theming architecture", "progress bar component API", "styling solutions")] -description: Orchestrates a comprehensive research effort across multiple design systems/component libraries -model: claude-sonnet-4-5 -disable-model-invocation: true ---- - -You are an elite Design Systems Research Orchestrator with deep expertise in open source component libraries, UI frameworks, and design system architectures. Your specialty is coordinating comprehensive, parallel research across multiple design systems to extract insights, patterns, and best practices. - -## Preparation - -Contemplate the research goal: $ARGUMENTS. If you need to clarify the research goal, ask the user for clarification. - -Use the AskUserQuestion tool if you need to clarify anything about the research goal. - -## Research Coordination - -Invoke one `design-system-researcher` sub-agenet for each of the following design systems: - -- Material UI -- Base UI -- Radix UI -- Mantine -- Ant Design - -ALWAYS use parallel execution to maximize efficiency. - -Each sub-agent will produce a report, likely in `.claude/research/`, and communicate this to you when it is finished with its research. - -## Synthesis and Analysis - -- Identify common patterns and approaches across the different systems -- Highlight unique or innovative implementations worth special attention -- Note trade-offs and different design philosophies -- Distinguish between framework-specific implementations and transferable patterns -- Extract actionable insights relevant to the CDS (Coinbase Design System) context - -## Final Report - -Your final synthesis should include: - -- Executive Summary: Key findings and overarching patterns -- System-by-System Breakdown: Detailed findings from each researched library -- Comparative Analysis: How approaches differ and why -- Recommendations: Actionable insights specific to CDS implementation -- Additional Resources: Links to relevant documentation, examples, or repos diff --git a/.claude/commands/component-docs.md b/.claude/commands/component-docs.md deleted file mode 100644 index a15a98fdff..0000000000 --- a/.claude/commands/component-docs.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -description: Creates or updates documentation for a CDS component on the docsite (apps/docs/). -argument-hint: [additional context] (e.g., "Button", "LineChart add real-time examples") -model: claude-sonnet-4-5 -disable-model-invocation: true ---- - -@.cursor/commands/component-docs.md diff --git a/.claude/commands/figma.audit-connect.md b/.claude/commands/figma.audit-connect.md deleted file mode 100644 index 5f41a582cf..0000000000 --- a/.claude/commands/figma.audit-connect.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -argument-hint: [Component name or path to component's code connect mapping file] -description: Examines a Figma Code Connect mapping file and provides a report on the mapping's accuracy and completeness -model: claude-sonnet-4-5 ---- - -CDS Component Code Connect File: $ARGUMENTS - -@.cursor/commands/figma.audit-connect.md diff --git a/.claude/commands/figma.create-connect.md b/.claude/commands/figma.create-connect.md deleted file mode 100644 index 39a1c32513..0000000000 --- a/.claude/commands/figma.create-connect.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -argument-hint: [Component name or path to component file] [Figma URL w/ Node ID] -description: Creates a new Figma Code Connect mapping file for a CDS component -model: claude-sonnet-4-5 ---- - -CDS Component: $1 -Figma URL: $2 - -@.cursor/commands/figma.create-connect.md diff --git a/.claude/commands/ktlo.md b/.claude/commands/ktlo.md deleted file mode 100644 index 51c5fda090..0000000000 --- a/.claude/commands/ktlo.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -description: Fetches assigned KTLO (Keep the Lights On) issues from Linear and prompts for agent hand-off. -model: claude-sonnet-4-5 -disable-model-invocation: true ---- - -@.cursor/commands/ktlo.md diff --git a/.claude/commands/summarize-commits.md b/.claude/commands/summarize-commits.md deleted file mode 100644 index 90a234e298..0000000000 --- a/.claude/commands/summarize-commits.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -allowed-tools: Bash(git log:*), Bash(git status:*), Bash(git show:*) -description: Reviews a git log to summarize the recent N changes ---- - -## Your task - -Review the last $1 git commits and provide a summary of the changes. In addition to `git log`, use `git show` to obtain detailed information about the changesets before making any conclusions. diff --git a/.claude/skills/cds-components/SKILL.md b/.claude/skills/cds-components/SKILL.md deleted file mode 100644 index 95871339ea..0000000000 --- a/.claude/skills/cds-components/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: cds-components -description: Use this skill when you are asked to work on a new or existing CDS React component in packages/mobile/ or packages/web/ ---- - -- For components in packages/mobile/, see: .cursor/rules/cds-mobile.mdc - -- For components in packages/web/, see: .cursor/rules/cds-web.mdc diff --git a/.claude/skills/components.best-practices/SKILL.md b/.claude/skills/components.best-practices/SKILL.md new file mode 100644 index 0000000000..bfa4cfcbd8 --- /dev/null +++ b/.claude/skills/components.best-practices/SKILL.md @@ -0,0 +1,186 @@ +--- +name: components.best-practices +description: Use this skill whenever working on CDS React components in any package. +user-invocable: false +--- + +# React Component Development Rules + +## Component Development Workflow + +1. Research similar reference components and given requirements/description +2. Optionally, ask clarifying questions about the component's requirements & behavior +3. Implement the component with unit tests & stories on web first before proceeding to mobile if both platforms were requested. +4. Never write figma code connect files unless explicitly instructed to do so. +5. Follow remaining general coding standards and guidelines you've been given. + +## Reference Components + +These high quality components demonstrate proper use of patterns/conventions: + +- **Select** (alpha/): generics, controlled/uncontrolled, compound architecture +- **Stepper**: props-based defaults, metadata generics, compound components +- **Carousel** (web): compound components, imperative handle, context + hook +- **RollingNumber**: animation config extraction, measurement patterns +- **SlideButton** (mobile): gesture handling, spring animations, accessibility actions + +## Organization + +### File Structure + +Every main CDS component should live within its own folder: + +``` +ComponentName/ +├── ComponentName.tsx # Main component file +├── SubComponent.tsx # Supporting component (if needed) +├── index.ts # Re-exports for public API +├── __stories__/ # Storybook stories +│ └── ComponentName.stories.tsx +├── __tests__/ # Unit tests +│ └── ComponentName.test.tsx +├── __figma__/ # Figma Code Connect files +│ └── ComponentName.figma.tsx +``` + +### Component Categories + +Organize components into category folders: + +- `buttons` - Button, IconButton, SlideButton +- `controls` - TextInput, Select, Checkbox, Radio, Switch +- `cards` - Card, DataCard, ContentCard +- `overlays` - Modal, Toast, Alert, Drawer +- `layout` - Box, Stack, Divider +- `typography` - Text, Heading +- `icons` - Icon +- `navigation` - Tabs, Breadcrumb + +## Component Conventions + +- **Memoize**: Always memoize components with React's memo HOC +- **refs**: All components should accept a ref via React's forwardRef pattern +- **Props documentation**: Every prop that does not have a falsy default must have JSDoc comments with `@default` tags +- **Type exports**: Export both a `*BaseProps` and `*Props` type (e.g., `ButtonBaseProps`, `ButtonProps`) +- **Style overrides**: All components MUST support a way to override styles (varries by web/mobile platform) +- **testID**: Support `testID` prop on root element for every component +- **Use design tokens**: Reference packages/common/src/core/theme.ts:57-331 as the definitive source for available token names +- **Padding over margin**: Use padding in combination with flex gap to achieve spacing instead of margin. + +## Design Token System + +### Token Categories + +Design tokens are defined in `packages/common/src/core/theme.ts`: + +- **Color**: fg, fgMuted, fgInverse, fgPrimary, bgPrimary, bgSecondary, bgNegative, bgPositive, etc. +- **Space**: 0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10 (8px base unit) +- **IconSize**: xs (12px), s (16px), m (24px), l (32px) +- **AvatarSize**: s, m, l, xl, xxl, xxxl +- **BorderWidth**: 0, 100, 200, 300, 400, 500 +- **BorderRadius**: 0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000 +- **Font**: display1-3, title1-4, headline, body, label1-2, caption, legal +- **Shadow**: elevation1, elevation2 + +### Semantic Color System + +Colors use a spectrum system with hue + step notation: + +- **Hues**: blue, green, orange, yellow, gray, indigo, pink, purple, red, teal, chartreuse +- **Steps**: 0, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100 +- **Example**: blue60 = Coinbase brand blue (#0052FF) + +Semantic tokens map to spectrum colors and adapt to light/dark mode: + +- `fgPrimary`: blue60 (light) / blue70 (dark) +- `bgPrimary`: blue60 (light) / blue70 (dark) +- `bgNegative`: red60 (both modes) +- `bgPositive`: green60 (both modes) + +### Space Scale + +```typescript +space: { + '0': 0, // 0px + '0.25': 2, // 2px + '0.5': 4, // 4px + '0.75': 6, // 6px + '1': 8, // 8px - base unit + '1.5': 12, // 12px + '2': 16, // 16px + '3': 24, // 24px + '4': 32, // 32px + '5': 40, // 40px + // ... up to 10 (80px) +} +``` + +## Component Patterns + +### Compound Components + +- Break components down into discrete subcomponents (i.e. "slots") +- Use this pattern for complex components with clear, distinct parts +- Accept optional subcomponent props with sensible defaults using `*Component`/`Default*` naming: + ```ts + NavigationComponent = DefaultCarouselNavigation, + PaginationComponent = DefaultCarouselPagination, + ``` +- The names of classNames/styles keys must line up with the name of the subcomponents (e.g. `classNames.pagination`, `styles.pagination`). +- Examples: Stepper, Carousel, Select (alpha) + +**Benefits:** + +- Complete customization without forking +- Sensible defaults for common use case +- Exported subcomponents for consumers to customize/wrap themselves + +### Context + Hook Pattern + +- Pair contexts with `use*Context()` hooks that throw descriptive errors on misuse: + ```ts + export const useCarouselContext = () => { + const context = useContext(CarouselContext); + if (!context) throw new Error('useCarouselContext must be used within Carousel'); + return context; + }; + ``` + +### Controlled/Uncontrolled Components + +- Support both patterns for input components; validate and throw if consumer mixes them (e.g., provides `value` but not `onChange`) +- Use internal state with prop override: `const open = openProp ?? openInternal;` + +### Generics for Type Safety + +- Use generics for components with dynamic value types: + ```ts + type SelectComponent = ( + props: SelectProps, + ) => React.ReactElement; + ``` +- Examples: Select (alpha), Stepper + +### BaseProps & Props + +- Component modules encapsulate two prop Types: `*BaseProps` (platform-agnostic) and `*Props` (extends BaseProps with platform and component specific properties like `className`, `classNames`, `styles`, etc.) +- Reuse other components' Types via utilities: `Pick` being preferred then secondarily `Omit`/`Exclude` +- Compose prop types using Typescript intersections (`&`) in this order: (1) full types (2) Picks (3) Omits (4) other type literal(s): + ```ts + type MyComponentProps = BoxBaseProps & + Pick & + Omit & { + propA: string; + propB: number; + }; + ``` +- When accepting components as props, define the contract types (`*Props`, `*Component`) in the main component file. These child component contracts do not use the `*BaseProps` pattern—only the main component needs BaseProps/Props separation. Default implementations can extend the contract with additional props in their own file: + + ```ts + // In MyComponent.tsx - defines the contract + type ChildProps = { id: string; label: ReactNode }; + type ChildComponent = React.FC; + + // In DefaultChild.tsx - extends for default implementation + type DefaultChildProps = SharedProps & Omit & ChildProps; + ``` diff --git a/.claude/skills/components.styles/README.md b/.claude/skills/components.styles/README.md new file mode 100644 index 0000000000..5f9f3af4dd --- /dev/null +++ b/.claude/skills/components.styles/README.md @@ -0,0 +1,12 @@ +# components.styles agent skill + +This skill may be invoked by the user following the examples below. + +**Usage:** `/components.styles [additional context]` + +Examples: + +- `/components.styles SlideButton` +- `/components.styles Button add static classnames for sub elements` +- `/components.styles Select add styles documentation` +- `/components.styles Avatar mobile only` diff --git a/.claude/skills/components.styles/SKILL.md b/.claude/skills/components.styles/SKILL.md new file mode 100644 index 0000000000..0ddedc2a42 --- /dev/null +++ b/.claude/skills/components.styles/SKILL.md @@ -0,0 +1,360 @@ +--- +name: components.styles +description: Guidelines writing styles API (styles, classNames, and static classNames) for a CDS component. Use this skill when adding customization options to a React component via `styles` or `classNames` props or when needing to update the docsite with component styles documentation. +argument-hint: [additional context] (e.g., "Button", "LineChart add real-time examples") +--- + +Goal: Add styles API (styles, classNames, and static classNames) to a CDS component and/or update the component documentation with styles documentation. + +If no component name is provided, ask the user which component they want to add styles to. + +## Step 1: Locate the Component + +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 + +> **⚠️ IMPORTANT: Adding styles/classNames props is a commitment to the component's internal structure.** +> +> Before adding styles API, carefully review the component's JSX structure: +> +> - **Flag if the component could be simplified** (e.g., unnecessary wrappers, redundant containers) +> - **Do NOT add styles to elements that may be refactored** - this creates breaking changes +> - **Ask the user** if you notice the component structure could be improved before committing to it +> +> Once published, changing or removing selectors is a **breaking change** for consumers. + +## Step 3: Identify Styleable Elements + +Review the component's JSX to identify elements that should be targetable via styles/classNames: + +- **Root element**: The outermost container element +- **Named sections**: Elements with semantic meaning (e.g., `start`, `content`, `end`, `header`, `footer`) +- **Sub-components**: Internal elements that users might want to customize +- **Conditional elements**: Elements that render based on props + +## Approved Selector Names + +> **IMPORTANT:** Before adding a new selector name not in this list, **get explicit confirmation from the user**. +> When a new selector is approved, add it to this list. + +### Approved Selectors (alphabetical) + +| Selector | Description | +| --------------------- | ----------------------------------------------------- | +| `accessory` | Accessory element (e.g., chevron, icon at end) | +| `activeIndicator` | Active indicator element (e.g., in tabs) | +| `bottomContent` | Bottom section content | +| `carousel` | Main carousel track element | +| `carouselContainer` | Outer carousel container | +| `childrenContainer` | Container wrapping children | +| `content` | Main content area | +| `contentContainer` | Container wrapping content | +| `description` | Description text element | +| `day` | Date cell in a calendar grid | +| `end` | End slot content (e.g., actions, icons) | +| `fill` | Fill/progress indicator within a track | +| `header` | Header section | +| `helperText` | Helper/assistive text below content | +| `icon` | Icon element | +| `intermediary` | Middle/intermediary element between sections | +| `label` | Label text element | +| `labels` | Container for multiple labels | +| `logo` | Logo element | +| `mainContent` | Primary content area | +| `media` | Media element (image, avatar, icon) | +| `navigation` | Navigation controls (e.g., prev/next buttons) | +| `pagination` | Pagination indicators | +| `pressable` | Pressable/interactive wrapper | +| `progress` | Progress indicator element | +| `progressBar` | ProgressBar sub-component within a composed component | +| `root` | Root/outermost container element | +| `start` | Start slot content (e.g., back button) | +| `step` | Individual step element (in steppers) | +| `substepContainer` | Container for nested sub-steps | +| `subtitle` | Subtitle text element | +| `tab` | Tab element (in tabs) | +| `tabs` | Tabs container element | +| `thumb` | Draggable thumb element (in sliders) | +| `title` | Title text element | +| `titleStack` | Stack containing title/subtitle/description | +| `titleStackContainer` | Container wrapping titleStack | +| `topContent` | Top section content | +| `track` | Track/rail element (in progress bars, sliders) | +| `trigger` | Trigger element that opens a dropdown/popover | + +## JSDoc Convention for Selector Descriptions + +Selector JSDoc comments describe **what the element is**, not what the prop does: + +- Sentence case, no trailing period +- Concise noun phrase describing the element itself +- Single-line format: `/** Description */` +- For conditional elements, append context after a comma: `/** Header element, only rendered on phone viewport */` + +**Examples:** + +```tsx +/** Root element */ +/** Title text element */ +/** Navigation controls element */ +/** Header element, only rendered on phone viewport in horizontal direction */ +``` + +## Step 4: Add Styles API (Web Components) + +For web components, add three things: + +### 4.1 Static Class Names + +Add a static classNames object with JSDoc comments. Place this before the component's type definitions: + +```tsx +/** + * Static class names for [ComponentName] component parts. + * Use these selectors to target specific elements with CSS. + */ +export const [componentName]ClassNames = { + /** Root element */ + root: 'cds-[ComponentName]', + /** [Concise element description] */ + [selectorName]: 'cds-[ComponentName]-[selectorName]', + // ... more selectors as needed +} as const; +``` + +**Naming conventions:** + +- Use `cds-` prefix for all class names +- Use PascalCase for component name: `cds-NavigationBar` +- Use camelCase for sub-elements: `cds-NavigationBar-contentWrapper`, `cds-Foo-titleStack` +- Keep names descriptive but concise + +**Example:** + +```tsx +export const fooClassNames = { + root: 'cds-Foo', + contentWrapper: 'cds-Foo-contentWrapper', + titleStack: 'cds-Foo-titleStack', + helperText: 'cds-Foo-helperText', +} as const; +``` + +### 4.2 Update Component Props Type + +Import and use the `StylesAndClassNames` utility type: + +```tsx +import type { StylesAndClassNames } from '../types'; + +export type [ComponentName]BaseProps = BoxBaseProps & { + // ... other props (without styles/classNames) +}; + +export type [ComponentName]Props = [ComponentName]BaseProps & StylesAndClassNames & Omit, 'children'>; +``` + +This automatically generates the `styles` and `classNames` props based on your static classNames object. + +### 4.3 Apply in Component Implementation + +Apply the static classNames, dynamic classNames, and styles in the component: + +```tsx +import { cx } from '../cx'; + +// In the component: + + + {children} + + +``` + +### 4.4 Add Tests for Static Class Names + +Add tests to verify that static class names are applied correctly to the component. This ensures the class names remain stable for consumers who depend on them for CSS targeting. + +**Test pattern:** + +```tsx +import { [componentName]ClassNames } from '../[ComponentName]'; + +describe('[ComponentName] static classNames', () => { + it('applies static class names to component elements', () => { + render( + <[ComponentName]WithTheme + start={
Start
} // Include props that render conditional elements + > +
Children
+ , + ); + + // Test root element + const root = screen.getByRole('[role]'); // or use testID/other selector + expect(root).toHaveClass([componentName]ClassNames.root); + + // Test sub-elements using querySelector with the static class name + expect(root.querySelector(`.${[componentName]ClassNames.start}`)).toBeInTheDocument(); + expect(root.querySelector(`.${[componentName]ClassNames.content}`)).toBeInTheDocument(); + }); +}); +``` + +**Key testing principles:** + +- Import the static classNames object from the component +- Use `toHaveClass()` for elements accessible via roles/queries +- Use `querySelector()` with the static class name for internal elements +- Test all selectors, including those on conditionally rendered elements (pass appropriate props) + +**Example from NavigationBar:** + +```tsx +import { navigationBarClassNames } from '../NavigationBar'; + +describe('NavigationBar static classNames', () => { + it('applies static class names to component elements', () => { + render( + Start}> +
Children
+
, + ); + + const nav = screen.getByRole('navigation'); + expect(nav).toHaveClass(navigationBarClassNames.root); + expect(nav.querySelector(`.${navigationBarClassNames.start}`)).toBeInTheDocument(); + expect(nav.querySelector(`.${navigationBarClassNames.content}`)).toBeInTheDocument(); + }); +}); +``` + +## Step 5: Add Styles API (Mobile Components) + +For mobile components, the pattern is simpler (no static classNames): + +### 5.1 Add styles prop type + +```tsx +export type [ComponentName]Props = { + // ... other props + /** Custom styles for individual elements of the [ComponentName] component */ + styles?: { + /** Root container element */ + root?: StyleProp; + /** [Concise element description] */ + [selectorName]?: StyleProp; + // ... more selectors as needed + }; +}; +``` + +### 5.2 Apply in Component Implementation + +```tsx + + {children} + +``` + +## Step 6: Add JSDoc Notes for Special Cases + +If any selectors have special rendering conditions, append the note after the element description with a comma: + +```tsx +styles?: { + /** Header element, only rendered on phone viewport in horizontal direction */ + header?: React.CSSProperties; +}; +``` + +Common cases to document: + +- Viewport-specific rendering (phone/tablet/desktop) +- Direction-specific rendering (horizontal/vertical) +- Conditional rendering based on props +- Elements that only render with certain data (e.g., subSteps) + +## Reference: StylesAndClassNames Utility + +The `StylesAndClassNames` utility type (from `packages/web/src/types.ts`) automatically generates: + +```tsx +// Given: +const fooClassNames = { + root: 'cds-Foo', + contentWrapper: 'cds-Foo-contentWrapper', +} as const; + +// StylesAndClassNames generates: +{ + styles?: { + root?: React.CSSProperties; + contentWrapper?: React.CSSProperties; + }; + classNames?: { + root?: string; + contentWrapper?: string; + }; +} +``` + +## Reference: NavigationBar Example + +See `packages/web/src/navigation/NavigationBar.tsx` for a complete example of the styles API pattern: + +- Lines 16-28: Static classNames with JSDoc +- Line 80: Using `StylesAndClassNames` type on regular Props (not BaseProps) +- Lines 117, 140, 149: Applying classNames with `cx()` +- Lines 125, 142, 152: Applying styles + +See `packages/web/src/navigation/__tests__/NavigationBar.test.tsx` for static classNames test example: + +- `NavigationBar static classNames` describe block: Tests all static class names are applied + +## Step 7: Update Documentation + +After adding the styles API to the component, update the documentation: + +1. **Run the docgen** to regenerate styles data: + + ```bash + yarn nx run docs:docgen + ``` + +2. **Create or update the styles documentation** use the `components.write-docs` SKILL for general knowledge on how to write component documentation: + - Create `_webStyles.mdx` with ComponentStylesTable and StylesExplorer + - Create `_mobileStyles.mdx` with ComponentStylesTable (if mobile) + - Update `index.mdx` to import and render the styles tables + +## Final Checklist + +Before completing, verify: + +- [ ] Reviewed component structure for potential simplifications (flagged to user if found) +- [ ] Selector names are from the approved list (or got user confirmation for new ones) +- [ ] Each selector has a JSDoc comment following the convention (sentence case, no trailing period, concise noun phrase) +- [ ] Class names follow `cds-ComponentName-selectorName` convention (camelCase) +- [ ] Using `StylesAndClassNames` utility type on regular Props (not BaseProps) (web) or manual styles type (mobile) +- [ ] Static classNames applied with `cx()` in component JSX (web only) +- [ ] Dynamic classNames and styles props applied correctly +- [ ] Special rendering conditions documented in JSDoc +- [ ] Tests added for static classNames (web only) - see Step 4.4 +- [ ] Ran `yarn nx run docs:docgen` to regenerate styles data +- [ ] Documentation updated to include new component styles information +- [ ] Updated this file's "Approved Selector Names" table if new selectors were added diff --git a/.claude/skills/components.write-docs/README.md b/.claude/skills/components.write-docs/README.md new file mode 100644 index 0000000000..ea47f339b5 --- /dev/null +++ b/.claude/skills/components.write-docs/README.md @@ -0,0 +1,11 @@ +# components.write-docs agent skill + +This skill may be invoked by the user following the examples below. + +**Usage:** `/component-docs [additional context]` + +Examples: + +- `/component-docs Button` +- `/component-docs LineChart add examples for real-time data updates` +- `/component-docs Avatar needs accessibility improvements` diff --git a/.claude/skills/components.write-docs/SKILL.md b/.claude/skills/components.write-docs/SKILL.md new file mode 100644 index 0000000000..60dcc343a0 --- /dev/null +++ b/.claude/skills/components.write-docs/SKILL.md @@ -0,0 +1,687 @@ +--- +name: components.write-docs +description: Guidelines for creating or updating documentation for a CDS component on the docsite (apps/docs/). Use this skill after creating or making updates to a CDS React component to write high quality documentaiton in the CDS docsite. +argument-hint: [additional context] (e.g., "Button", "LineChart add real-time examples") +model: claude-sonnet-4-6 +--- + +Goal: Create or update documentation for a CDS component on the docsite (apps/docs/). + +If no component name is provided, ask the user which component they want to document. + +## Step 1: Check for Existing Documentation + +First, check if documentation already exists for this component: + +```bash +apps/docs/docs/components/*/[ComponentName]/ +``` + +- **If docs exist**: Review the existing documentation and identify what needs to be added, updated, or improved. Consider the user's additional context if provided. +- **If docs don't exist**: Follow the full workflow below to create new documentation. + +For updates, focus on the specific areas that need improvement rather than rewriting everything. + +### Reference Components + +When creating or updating docs, reference these well-documented components to understand the documentation style and patterns: + +- **LineChart** (`apps/docs/docs/components/charts/LineChart/`) - Comprehensive example with many composed examples +- **Button** (`apps/docs/docs/components/buttons/Button/`) - Good basic component documentation +- **IconButton** (`apps/docs/docs/components/buttons/IconButton/`) - Simple component with clear examples +- **Sidebar** (`apps/docs/docs/components/navigation/Sidebar/`) - Complex component with multiple sub-components + +Review these before writing to ensure consistency in style, structure, and depth. + +### Reference Files + +When writing examples, reference these files for valid values: + +- **Icon names** (`packages/icons/src/IconName.ts`) - All valid icon names (e.g., `'checkmark'`, `'close'`, `'warning'`) +- **Design tokens** - Use the `components.best-practices` SKILL for knowledge on valid CDS design token values (Color, Space, BorderRadius, Font, etc.) + +## Step 2: Research Phase (for new docs or major updates) + +Before writing documentation, research how other popular component libraries document the same (or similar) component. Use web search to find documentation for the component in: + +- **Material UI** (mui.com) +- **Radix UI** (radix-ui.com) +- **Mantine** (mantine.dev) +- **Ant Design** (ant.design) +- **Base UI** (base-ui.com) + +Look for: + +- What examples they provide and how they're organized +- Common use cases they demonstrate +- Edge cases or patterns worth highlighting +- Accessibility guidance they include +- How they explain complex features + +Use these insights to inform your documentation structure and examples. + +## Step 3: Check Component Availability + +Verify where the component exists: + +```bash +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 + +Check if the component supports the `styles` and/or `classNames` props by looking at its type definitions. Components with these props should have a styles tab in the documentation. Look for: + +- `styles?: { ... }` prop with named style selectors +- `classNames?: { ... }` prop with named class selectors + +If the component has these props, the docgen will generate styles data that can be used for the styles doc. + +## Step 4: Required Setup Steps (for new docs only) + +Before creating the component documentation, complete these setup steps: + +### 4.1 Add to ReactLiveScope + +In `apps/docs/src/components/page/ReactLiveScope.ts`, add the component imports and add them to the scope: + +```ts +// Add imports +import { ComponentName } from '@coinbase/cds-web'; + +// Add to scope object +const ReactLiveScope = { + // ... existing scope + ComponentName, +}; +``` + +There is a chance that the component has already been imported. + +### 4.2 Update sidebars.ts + +In `apps/docs/sidebars.ts`, add the component to its category section: + +```ts +module.exports = { + docs: [ + // ... other sections + { + type: 'category', + label: 'Category', // e.g., 'Buttons', 'Layout', etc. + items: [ + // ... other components + 'components/category/ComponentName/index', + ], + }, + ], +}; +``` + +### 4.3 Update docgen.config.js + +In `apps/docs/docgen.config.js`, add the component paths to generate props data: + +```js +module.exports = { + web: { + // ... other configs + category: { + // e.g., 'buttons', 'layout', etc. + ComponentName: { + source: 'packages/web/src/category/ComponentName.tsx', + }, + }, + }, + // If component has a mobile version + mobile: { + // ... other configs + category: { + ComponentName: { + source: 'packages/mobile/src/category/ComponentName.tsx', + }, + }, + }, +}; +``` + +## Step 5: Create Directory Structure (for new docs only) + +Create the documentation directory and files based on component availability: + +```bash +apps/docs/docs/components/[docs-category]/[ComponentName]/ +├── index.mdx # Required for all components +├── webMetadata.json # If web version exists +├── _webExamples.mdx # If web version exists +├── _webPropsTable.mdx # If web version exists +├── _webStyles.mdx # If web version has styles/classNames API +├── mobileMetadata.json # If mobile version exists +├── _mobileExamples.mdx # If mobile version exists +├── _mobilePropsTable.mdx # If mobile version exists +└── _mobileStyles.mdx # If mobile version has styles/classNames API +``` + +## File Templates + +### Metadata Files + +#### webMetadata.json + +```json +{ + "import": "import { ComponentName } from '@coinbase/cds-web/[source-category]/[ComponentName]'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/[source-category]/[ComponentName].tsx", + "description": "[Component description]", + "figma": "[figma link]", + "storybook": "[storybook link]", + "relatedComponents": [ + { "label": "[componentName]", "url": "/components/[category]/[componentName]" } + ], + "dependencies": [{ "name": "[peer-dependency-name]", "version": "[version-range]" }] +} +``` + +**Notes:** + +- `description` should be the full component description - what the component is and when to use it (e.g., "A non-intrusive notification component that temporarily displays brief messages at the bottom of the screen.") +- `figma` and `storybook` fields are optional - only add if provided (check for story files in `packages/web/src/**/__stories__/`) +- `dependencies` is optional - only include if the component imports from external packages that are peer dependencies. To determine: + 1. Check the component's source file for imports from external packages (e.g., `framer-motion`) + 2. Cross-reference those imports with `peerDependencies` in `packages/web/package.json` + 3. Use the exact version range from `peerDependencies` in the package.json file +- `relatedComponents` should link to components commonly used together + +#### mobileMetadata.json + +```json +{ + "import": "import { ComponentName } from '@coinbase/cds-mobile/[source-category]/[ComponentName]'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/[source-category]/[ComponentName].tsx", + "description": "[Component description]", + "figma": "[figma link]", + "relatedComponents": [ + { "label": "[componentName]", "url": "/components/[category]/[componentName]" } + ], + "dependencies": [{ "name": "[peer-dependency-name]", "version": "[version-range]" }] +} +``` + +**Notes:** + +- `figma` is optional - only add if provided +- `dependencies` is optional - only include if the component imports from external packages that are peer dependencies. To determine: + 1. Check the component's source file for imports from external packages (e.g., `@shopify/react-native-skia`, `react-native-reanimated`, `react-native-gesture-handler`) + 2. Cross-reference those imports with `peerDependencies` in `packages/mobile/package.json` + 3. Use the exact version range from `peerDependencies` in the package.json file + +### Props Tables + +#### \_webPropsTable.mdx + +```mdx +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web/[source-category]/[ComponentName]/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + +``` + +#### \_mobilePropsTable.mdx + +```mdx +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile/[source-category]/[ComponentName]/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + +``` + +### Styles Doc + +Styles doc showcases the `styles` and `classNames` API for components that support custom styling of internal elements. Only create these files if the component has a styles/classNames API. + +#### \_webStyles.mdx (with Explorer) + +For web components, always include both the selectors table AND the interactive StylesExplorer. The StylesExplorer lets users hover or click on selectors to highlight the corresponding elements in a live example: + +```mdx +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { [ComponentName] } from '@coinbase/cds-web/[source-category]/[ComponentName]'; + +import webStylesData from ':docgen/web/[source-category]/[ComponentName]/styles-data'; + +## Explorer + + + {(classNames) => ( + <[ComponentName] {...exampleProps} classNames={classNames} /> + )} + + +## Selectors + + +``` + +If the component requires state management, bundle everything into a single exported example component. + +```mdx +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Select } from '@coinbase/cds-web/alpha/select'; + +import webStylesData from ':docgen/web/alpha/select/Select/styles-data'; + +export const SelectExample = ({ classNames }) => { + const [value, setValue] = useState('1'); + const options = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + ]; + return ( + + + + + + + + + + + + +
+
+
+ No benchmark data available. Run a benchmark to see quantitative results here. +
+
+
+ + + +
+
+

Review Complete

+

+ Your feedback has been saved. Go back to your Claude Code session and tell Claude you're + done reviewing. +

+
+ +
+
+
+ + +
+ + + + diff --git a/.claude/skills/skill-creator/references/schemas.md b/.claude/skills/skill-creator/references/schemas.md new file mode 100644 index 0000000000..effe351614 --- /dev/null +++ b/.claude/skills/skill-creator/references/schemas.md @@ -0,0 +1,423 @@ +# JSON Schemas + +This document defines the JSON schemas used by skill-creator. + +--- + +## evals.json + +Defines the evals for a skill. Located at `evals/evals.json` within the skill directory. + +```json +{ + "skill_name": "example-skill", + "evals": [ + { + "id": 1, + "prompt": "User's example prompt", + "expected_output": "Description of expected result", + "files": ["evals/files/sample1.pdf"], + "expectations": ["The output includes X", "The skill used script Y"] + } + ] +} +``` + +**Fields:** + +- `skill_name`: Name matching the skill's frontmatter +- `evals[].id`: Unique integer identifier +- `evals[].prompt`: The task to execute +- `evals[].expected_output`: Human-readable description of success +- `evals[].files`: Optional list of input file paths (relative to skill root) +- `evals[].expectations`: List of verifiable statements + +--- + +## history.json + +Tracks version progression in Improve mode. Located at workspace root. + +```json +{ + "started_at": "2026-01-15T10:30:00Z", + "skill_name": "pdf", + "current_best": "v2", + "iterations": [ + { + "version": "v0", + "parent": null, + "expectation_pass_rate": 0.65, + "grading_result": "baseline", + "is_current_best": false + }, + { + "version": "v1", + "parent": "v0", + "expectation_pass_rate": 0.75, + "grading_result": "won", + "is_current_best": false + }, + { + "version": "v2", + "parent": "v1", + "expectation_pass_rate": 0.85, + "grading_result": "won", + "is_current_best": true + } + ] +} +``` + +**Fields:** + +- `started_at`: ISO timestamp of when improvement started +- `skill_name`: Name of the skill being improved +- `current_best`: Version identifier of the best performer +- `iterations[].version`: Version identifier (v0, v1, ...) +- `iterations[].parent`: Parent version this was derived from +- `iterations[].expectation_pass_rate`: Pass rate from grading +- `iterations[].grading_result`: "baseline", "won", "lost", or "tie" +- `iterations[].is_current_best`: Whether this is the current best version + +--- + +## grading.json + +Output from the grader agent. Located at `/grading.json`. + +```json +{ + "expectations": [ + { + "text": "The output includes the name 'John Smith'", + "passed": true, + "evidence": "Found in transcript Step 3: 'Extracted names: John Smith, Sarah Johnson'" + }, + { + "text": "The spreadsheet has a SUM formula in cell B10", + "passed": false, + "evidence": "No spreadsheet was created. The output was a text file." + } + ], + "summary": { + "passed": 2, + "failed": 1, + "total": 3, + "pass_rate": 0.67 + }, + "execution_metrics": { + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8 + }, + "total_tool_calls": 15, + "total_steps": 6, + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 + }, + "timing": { + "executor_duration_seconds": 165.0, + "grader_duration_seconds": 26.0, + "total_duration_seconds": 191.0 + }, + "claims": [ + { + "claim": "The form has 12 fillable fields", + "type": "factual", + "verified": true, + "evidence": "Counted 12 fields in field_info.json" + } + ], + "user_notes_summary": { + "uncertainties": ["Used 2023 data, may be stale"], + "needs_review": [], + "workarounds": ["Fell back to text overlay for non-fillable fields"] + }, + "eval_feedback": { + "suggestions": [ + { + "assertion": "The output includes the name 'John Smith'", + "reason": "A hallucinated document that mentions the name would also pass" + } + ], + "overall": "Assertions check presence but not correctness." + } +} +``` + +**Fields:** + +- `expectations[]`: Graded expectations with evidence +- `summary`: Aggregate pass/fail counts +- `execution_metrics`: Tool usage and output size (from executor's metrics.json) +- `timing`: Wall clock timing (from timing.json) +- `claims`: Extracted and verified claims from the output +- `user_notes_summary`: Issues flagged by the executor +- `eval_feedback`: (optional) Improvement suggestions for the evals, only present when the grader identifies issues worth raising + +--- + +## metrics.json + +Output from the executor agent. Located at `/outputs/metrics.json`. + +```json +{ + "tool_calls": { + "Read": 5, + "Write": 2, + "Bash": 8, + "Edit": 1, + "Glob": 2, + "Grep": 0 + }, + "total_tool_calls": 18, + "total_steps": 6, + "files_created": ["filled_form.pdf", "field_values.json"], + "errors_encountered": 0, + "output_chars": 12450, + "transcript_chars": 3200 +} +``` + +**Fields:** + +- `tool_calls`: Count per tool type +- `total_tool_calls`: Sum of all tool calls +- `total_steps`: Number of major execution steps +- `files_created`: List of output files created +- `errors_encountered`: Number of errors during execution +- `output_chars`: Total character count of output files +- `transcript_chars`: Character count of transcript + +--- + +## timing.json + +Wall clock timing for a run. Located at `/timing.json`. + +**How to capture:** When a subagent task completes, the task notification includes `total_tokens` and `duration_ms`. Save these immediately — they are not persisted anywhere else and cannot be recovered after the fact. + +```json +{ + "total_tokens": 84852, + "duration_ms": 23332, + "total_duration_seconds": 23.3, + "executor_start": "2026-01-15T10:30:00Z", + "executor_end": "2026-01-15T10:32:45Z", + "executor_duration_seconds": 165.0, + "grader_start": "2026-01-15T10:32:46Z", + "grader_end": "2026-01-15T10:33:12Z", + "grader_duration_seconds": 26.0 +} +``` + +--- + +## benchmark.json + +Output from Benchmark mode. Located at `benchmarks//benchmark.json`. + +```json +{ + "metadata": { + "skill_name": "pdf", + "skill_path": "/path/to/pdf", + "executor_model": "claude-sonnet-4-20250514", + "analyzer_model": "most-capable-model", + "timestamp": "2026-01-15T10:30:00Z", + "evals_run": [1, 2, 3], + "runs_per_configuration": 3 + }, + + "runs": [ + { + "eval_id": 1, + "eval_name": "Ocean", + "configuration": "with_skill", + "run_number": 1, + "result": { + "pass_rate": 0.85, + "passed": 6, + "failed": 1, + "total": 7, + "time_seconds": 42.5, + "tokens": 3800, + "tool_calls": 18, + "errors": 0 + }, + "expectations": [{ "text": "...", "passed": true, "evidence": "..." }], + "notes": ["Used 2023 data, may be stale", "Fell back to text overlay for non-fillable fields"] + } + ], + + "run_summary": { + "with_skill": { + "pass_rate": { "mean": 0.85, "stddev": 0.05, "min": 0.8, "max": 0.9 }, + "time_seconds": { "mean": 45.0, "stddev": 12.0, "min": 32.0, "max": 58.0 }, + "tokens": { "mean": 3800, "stddev": 400, "min": 3200, "max": 4100 } + }, + "without_skill": { + "pass_rate": { "mean": 0.35, "stddev": 0.08, "min": 0.28, "max": 0.45 }, + "time_seconds": { "mean": 32.0, "stddev": 8.0, "min": 24.0, "max": 42.0 }, + "tokens": { "mean": 2100, "stddev": 300, "min": 1800, "max": 2500 } + }, + "delta": { + "pass_rate": "+0.50", + "time_seconds": "+13.0", + "tokens": "+1700" + } + }, + + "notes": [ + "Assertion 'Output is a PDF file' passes 100% in both configurations - may not differentiate skill value", + "Eval 3 shows high variance (50% ± 40%) - may be flaky or model-dependent", + "Without-skill runs consistently fail on table extraction expectations", + "Skill adds 13s average execution time but improves pass rate by 50%" + ] +} +``` + +**Fields:** + +- `metadata`: Information about the benchmark run + - `skill_name`: Name of the skill + - `timestamp`: When the benchmark was run + - `evals_run`: List of eval names or IDs + - `runs_per_configuration`: Number of runs per config (e.g. 3) +- `runs[]`: Individual run results + - `eval_id`: Numeric eval identifier + - `eval_name`: Human-readable eval name (used as section header in the viewer) + - `configuration`: Must be `"with_skill"` or `"without_skill"` (the viewer uses this exact string for grouping and color coding) + - `run_number`: Integer run number (1, 2, 3...) + - `result`: Nested object with `pass_rate`, `passed`, `total`, `time_seconds`, `tokens`, `errors` +- `run_summary`: Statistical aggregates per configuration + - `with_skill` / `without_skill`: Each contains `pass_rate`, `time_seconds`, `tokens` objects with `mean` and `stddev` fields + - `delta`: Difference strings like `"+0.50"`, `"+13.0"`, `"+1700"` +- `notes`: Freeform observations from the analyzer + +**Important:** The viewer reads these field names exactly. Using `config` instead of `configuration`, or putting `pass_rate` at the top level of a run instead of nested under `result`, will cause the viewer to show empty/zero values. Always reference this schema when generating benchmark.json manually. + +--- + +## comparison.json + +Output from blind comparator. Located at `/comparison-N.json`. + +```json +{ + "winner": "A", + "reasoning": "Output A provides a complete solution with proper formatting and all required fields. Output B is missing the date field and has formatting inconsistencies.", + "rubric": { + "A": { + "content": { + "correctness": 5, + "completeness": 5, + "accuracy": 4 + }, + "structure": { + "organization": 4, + "formatting": 5, + "usability": 4 + }, + "content_score": 4.7, + "structure_score": 4.3, + "overall_score": 9.0 + }, + "B": { + "content": { + "correctness": 3, + "completeness": 2, + "accuracy": 3 + }, + "structure": { + "organization": 3, + "formatting": 2, + "usability": 3 + }, + "content_score": 2.7, + "structure_score": 2.7, + "overall_score": 5.4 + } + }, + "output_quality": { + "A": { + "score": 9, + "strengths": ["Complete solution", "Well-formatted", "All fields present"], + "weaknesses": ["Minor style inconsistency in header"] + }, + "B": { + "score": 5, + "strengths": ["Readable output", "Correct basic structure"], + "weaknesses": ["Missing date field", "Formatting inconsistencies", "Partial data extraction"] + } + }, + "expectation_results": { + "A": { + "passed": 4, + "total": 5, + "pass_rate": 0.8, + "details": [{ "text": "Output includes name", "passed": true }] + }, + "B": { + "passed": 3, + "total": 5, + "pass_rate": 0.6, + "details": [{ "text": "Output includes name", "passed": true }] + } + } +} +``` + +--- + +## analysis.json + +Output from post-hoc analyzer. Located at `/analysis.json`. + +```json +{ + "comparison_summary": { + "winner": "A", + "winner_skill": "path/to/winner/skill", + "loser_skill": "path/to/loser/skill", + "comparator_reasoning": "Brief summary of why comparator chose winner" + }, + "winner_strengths": [ + "Clear step-by-step instructions for handling multi-page documents", + "Included validation script that caught formatting errors" + ], + "loser_weaknesses": [ + "Vague instruction 'process the document appropriately' led to inconsistent behavior", + "No script for validation, agent had to improvise" + ], + "instruction_following": { + "winner": { + "score": 9, + "issues": ["Minor: skipped optional logging step"] + }, + "loser": { + "score": 6, + "issues": [ + "Did not use the skill's formatting template", + "Invented own approach instead of following step 3" + ] + } + }, + "improvement_suggestions": [ + { + "priority": "high", + "category": "instructions", + "suggestion": "Replace 'process the document appropriately' with explicit steps", + "expected_impact": "Would eliminate ambiguity that caused inconsistent behavior" + } + ], + "transcript_insights": { + "winner_execution_pattern": "Read skill -> Followed 5-step process -> Used validation script", + "loser_execution_pattern": "Read skill -> Unclear on approach -> Tried 3 different methods" + } +} +``` diff --git a/.claude/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-313.pyc b/.claude/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-313.pyc new file mode 100644 index 0000000000..2ef503d5d5 Binary files /dev/null and b/.claude/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-313.pyc differ diff --git a/.claude/skills/skill-creator/scripts/aggregate_benchmark.py b/.claude/skills/skill-creator/scripts/aggregate_benchmark.py new file mode 100755 index 0000000000..3e66e8c105 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/aggregate_benchmark.py @@ -0,0 +1,401 @@ +#!/usr/bin/env python3 +""" +Aggregate individual run results into benchmark summary statistics. + +Reads grading.json files from run directories and produces: +- run_summary with mean, stddev, min, max for each metric +- delta between with_skill and without_skill configurations + +Usage: + python aggregate_benchmark.py + +Example: + python aggregate_benchmark.py benchmarks/2026-01-15T10-30-00/ + +The script supports two directory layouts: + + Workspace layout (from skill-creator iterations): + / + └── eval-N/ + ├── with_skill/ + │ ├── run-1/grading.json + │ └── run-2/grading.json + └── without_skill/ + ├── run-1/grading.json + └── run-2/grading.json + + Legacy layout (with runs/ subdirectory): + / + └── runs/ + └── eval-N/ + ├── with_skill/ + │ └── run-1/grading.json + └── without_skill/ + └── run-1/grading.json +""" + +import argparse +import json +import math +import sys +from datetime import datetime, timezone +from pathlib import Path + + +def calculate_stats(values: list[float]) -> dict: + """Calculate mean, stddev, min, max for a list of values.""" + if not values: + return {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0} + + n = len(values) + mean = sum(values) / n + + if n > 1: + variance = sum((x - mean) ** 2 for x in values) / (n - 1) + stddev = math.sqrt(variance) + else: + stddev = 0.0 + + return { + "mean": round(mean, 4), + "stddev": round(stddev, 4), + "min": round(min(values), 4), + "max": round(max(values), 4) + } + + +def load_run_results(benchmark_dir: Path) -> dict: + """ + Load all run results from a benchmark directory. + + Returns dict keyed by config name (e.g. "with_skill"/"without_skill", + or "new_skill"/"old_skill"), each containing a list of run results. + """ + # Support both layouts: eval dirs directly under benchmark_dir, or under runs/ + runs_dir = benchmark_dir / "runs" + if runs_dir.exists(): + search_dir = runs_dir + elif list(benchmark_dir.glob("eval-*")): + search_dir = benchmark_dir + else: + print(f"No eval directories found in {benchmark_dir} or {benchmark_dir / 'runs'}") + return {} + + results: dict[str, list] = {} + + for eval_idx, eval_dir in enumerate(sorted(search_dir.glob("eval-*"))): + metadata_path = eval_dir / "eval_metadata.json" + if metadata_path.exists(): + try: + with open(metadata_path) as mf: + eval_id = json.load(mf).get("eval_id", eval_idx) + except (json.JSONDecodeError, OSError): + eval_id = eval_idx + else: + try: + eval_id = int(eval_dir.name.split("-")[1]) + except ValueError: + eval_id = eval_idx + + # Discover config directories dynamically rather than hardcoding names + for config_dir in sorted(eval_dir.iterdir()): + if not config_dir.is_dir(): + continue + # Skip non-config directories (inputs, outputs, etc.) + if not list(config_dir.glob("run-*")): + continue + config = config_dir.name + if config not in results: + results[config] = [] + + for run_dir in sorted(config_dir.glob("run-*")): + run_number = int(run_dir.name.split("-")[1]) + grading_file = run_dir / "grading.json" + + if not grading_file.exists(): + print(f"Warning: grading.json not found in {run_dir}") + continue + + try: + with open(grading_file) as f: + grading = json.load(f) + except json.JSONDecodeError as e: + print(f"Warning: Invalid JSON in {grading_file}: {e}") + continue + + # Extract metrics + result = { + "eval_id": eval_id, + "run_number": run_number, + "pass_rate": grading.get("summary", {}).get("pass_rate", 0.0), + "passed": grading.get("summary", {}).get("passed", 0), + "failed": grading.get("summary", {}).get("failed", 0), + "total": grading.get("summary", {}).get("total", 0), + } + + # Extract timing — check grading.json first, then sibling timing.json + timing = grading.get("timing", {}) + result["time_seconds"] = timing.get("total_duration_seconds", 0.0) + timing_file = run_dir / "timing.json" + if result["time_seconds"] == 0.0 and timing_file.exists(): + try: + with open(timing_file) as tf: + timing_data = json.load(tf) + result["time_seconds"] = timing_data.get("total_duration_seconds", 0.0) + result["tokens"] = timing_data.get("total_tokens", 0) + except json.JSONDecodeError: + pass + + # Extract metrics if available + metrics = grading.get("execution_metrics", {}) + result["tool_calls"] = metrics.get("total_tool_calls", 0) + if not result.get("tokens"): + result["tokens"] = metrics.get("output_chars", 0) + result["errors"] = metrics.get("errors_encountered", 0) + + # Extract expectations — viewer requires fields: text, passed, evidence + raw_expectations = grading.get("expectations", []) + for exp in raw_expectations: + if "text" not in exp or "passed" not in exp: + print(f"Warning: expectation in {grading_file} missing required fields (text, passed, evidence): {exp}") + result["expectations"] = raw_expectations + + # Extract notes from user_notes_summary + notes_summary = grading.get("user_notes_summary", {}) + notes = [] + notes.extend(notes_summary.get("uncertainties", [])) + notes.extend(notes_summary.get("needs_review", [])) + notes.extend(notes_summary.get("workarounds", [])) + result["notes"] = notes + + results[config].append(result) + + return results + + +def aggregate_results(results: dict) -> dict: + """ + Aggregate run results into summary statistics. + + Returns run_summary with stats for each configuration and delta. + """ + run_summary = {} + configs = list(results.keys()) + + for config in configs: + runs = results.get(config, []) + + if not runs: + run_summary[config] = { + "pass_rate": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "time_seconds": {"mean": 0.0, "stddev": 0.0, "min": 0.0, "max": 0.0}, + "tokens": {"mean": 0, "stddev": 0, "min": 0, "max": 0} + } + continue + + pass_rates = [r["pass_rate"] for r in runs] + times = [r["time_seconds"] for r in runs] + tokens = [r.get("tokens", 0) for r in runs] + + run_summary[config] = { + "pass_rate": calculate_stats(pass_rates), + "time_seconds": calculate_stats(times), + "tokens": calculate_stats(tokens) + } + + # Calculate delta between the first two configs (if two exist) + if len(configs) >= 2: + primary = run_summary.get(configs[0], {}) + baseline = run_summary.get(configs[1], {}) + else: + primary = run_summary.get(configs[0], {}) if configs else {} + baseline = {} + + delta_pass_rate = primary.get("pass_rate", {}).get("mean", 0) - baseline.get("pass_rate", {}).get("mean", 0) + delta_time = primary.get("time_seconds", {}).get("mean", 0) - baseline.get("time_seconds", {}).get("mean", 0) + delta_tokens = primary.get("tokens", {}).get("mean", 0) - baseline.get("tokens", {}).get("mean", 0) + + run_summary["delta"] = { + "pass_rate": f"{delta_pass_rate:+.2f}", + "time_seconds": f"{delta_time:+.1f}", + "tokens": f"{delta_tokens:+.0f}" + } + + return run_summary + + +def generate_benchmark(benchmark_dir: Path, skill_name: str = "", skill_path: str = "") -> dict: + """ + Generate complete benchmark.json from run results. + """ + results = load_run_results(benchmark_dir) + run_summary = aggregate_results(results) + + # Build runs array for benchmark.json + runs = [] + for config in results: + for result in results[config]: + runs.append({ + "eval_id": result["eval_id"], + "configuration": config, + "run_number": result["run_number"], + "result": { + "pass_rate": result["pass_rate"], + "passed": result["passed"], + "failed": result["failed"], + "total": result["total"], + "time_seconds": result["time_seconds"], + "tokens": result.get("tokens", 0), + "tool_calls": result.get("tool_calls", 0), + "errors": result.get("errors", 0) + }, + "expectations": result["expectations"], + "notes": result["notes"] + }) + + # Determine eval IDs from results + eval_ids = sorted(set( + r["eval_id"] + for config in results.values() + for r in config + )) + + benchmark = { + "metadata": { + "skill_name": skill_name or "", + "skill_path": skill_path or "", + "executor_model": "", + "analyzer_model": "", + "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + "evals_run": eval_ids, + "runs_per_configuration": 3 + }, + "runs": runs, + "run_summary": run_summary, + "notes": [] # To be filled by analyzer + } + + return benchmark + + +def generate_markdown(benchmark: dict) -> str: + """Generate human-readable benchmark.md from benchmark data.""" + metadata = benchmark["metadata"] + run_summary = benchmark["run_summary"] + + # Determine config names (excluding "delta") + configs = [k for k in run_summary if k != "delta"] + config_a = configs[0] if len(configs) >= 1 else "config_a" + config_b = configs[1] if len(configs) >= 2 else "config_b" + label_a = config_a.replace("_", " ").title() + label_b = config_b.replace("_", " ").title() + + lines = [ + f"# Skill Benchmark: {metadata['skill_name']}", + "", + f"**Model**: {metadata['executor_model']}", + f"**Date**: {metadata['timestamp']}", + f"**Evals**: {', '.join(map(str, metadata['evals_run']))} ({metadata['runs_per_configuration']} runs each per configuration)", + "", + "## Summary", + "", + f"| Metric | {label_a} | {label_b} | Delta |", + "|--------|------------|---------------|-------|", + ] + + a_summary = run_summary.get(config_a, {}) + b_summary = run_summary.get(config_b, {}) + delta = run_summary.get("delta", {}) + + # Format pass rate + a_pr = a_summary.get("pass_rate", {}) + b_pr = b_summary.get("pass_rate", {}) + lines.append(f"| Pass Rate | {a_pr.get('mean', 0)*100:.0f}% ± {a_pr.get('stddev', 0)*100:.0f}% | {b_pr.get('mean', 0)*100:.0f}% ± {b_pr.get('stddev', 0)*100:.0f}% | {delta.get('pass_rate', '—')} |") + + # Format time + a_time = a_summary.get("time_seconds", {}) + b_time = b_summary.get("time_seconds", {}) + lines.append(f"| Time | {a_time.get('mean', 0):.1f}s ± {a_time.get('stddev', 0):.1f}s | {b_time.get('mean', 0):.1f}s ± {b_time.get('stddev', 0):.1f}s | {delta.get('time_seconds', '—')}s |") + + # Format tokens + a_tokens = a_summary.get("tokens", {}) + b_tokens = b_summary.get("tokens", {}) + lines.append(f"| Tokens | {a_tokens.get('mean', 0):.0f} ± {a_tokens.get('stddev', 0):.0f} | {b_tokens.get('mean', 0):.0f} ± {b_tokens.get('stddev', 0):.0f} | {delta.get('tokens', '—')} |") + + # Notes section + if benchmark.get("notes"): + lines.extend([ + "", + "## Notes", + "" + ]) + for note in benchmark["notes"]: + lines.append(f"- {note}") + + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser( + description="Aggregate benchmark run results into summary statistics" + ) + parser.add_argument( + "benchmark_dir", + type=Path, + help="Path to the benchmark directory" + ) + parser.add_argument( + "--skill-name", + default="", + help="Name of the skill being benchmarked" + ) + parser.add_argument( + "--skill-path", + default="", + help="Path to the skill being benchmarked" + ) + parser.add_argument( + "--output", "-o", + type=Path, + help="Output path for benchmark.json (default: /benchmark.json)" + ) + + args = parser.parse_args() + + if not args.benchmark_dir.exists(): + print(f"Directory not found: {args.benchmark_dir}") + sys.exit(1) + + # Generate benchmark + benchmark = generate_benchmark(args.benchmark_dir, args.skill_name, args.skill_path) + + # Determine output paths + output_json = args.output or (args.benchmark_dir / "benchmark.json") + output_md = output_json.with_suffix(".md") + + # Write benchmark.json + with open(output_json, "w") as f: + json.dump(benchmark, f, indent=2) + print(f"Generated: {output_json}") + + # Write benchmark.md + markdown = generate_markdown(benchmark) + with open(output_md, "w") as f: + f.write(markdown) + print(f"Generated: {output_md}") + + # Print summary + run_summary = benchmark["run_summary"] + configs = [k for k in run_summary if k != "delta"] + delta = run_summary.get("delta", {}) + + print(f"\nSummary:") + for config in configs: + pr = run_summary[config]["pass_rate"]["mean"] + label = config.replace("_", " ").title() + print(f" {label}: {pr*100:.1f}% pass rate") + print(f" Delta: {delta.get('pass_rate', '—')}") + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/generate_report.py b/.claude/skills/skill-creator/scripts/generate_report.py new file mode 100755 index 0000000000..959e30a001 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/generate_report.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Generate an HTML report from run_loop.py output. + +Takes the JSON output from run_loop.py and generates a visual HTML report +showing each description attempt with check/x for each test case. +Distinguishes between train and test queries. +""" + +import argparse +import html +import json +import sys +from pathlib import Path + + +def generate_html(data: dict, auto_refresh: bool = False, skill_name: str = "") -> str: + """Generate HTML report from loop output data. If auto_refresh is True, adds a meta refresh tag.""" + history = data.get("history", []) + holdout = data.get("holdout", 0) + title_prefix = html.escape(skill_name + " \u2014 ") if skill_name else "" + + # Get all unique queries from train and test sets, with should_trigger info + train_queries: list[dict] = [] + test_queries: list[dict] = [] + if history: + for r in history[0].get("train_results", history[0].get("results", [])): + train_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + if history[0].get("test_results"): + for r in history[0].get("test_results", []): + test_queries.append({"query": r["query"], "should_trigger": r.get("should_trigger", True)}) + + refresh_tag = ' \n' if auto_refresh else "" + + html_parts = [""" + + + +""" + refresh_tag + """ """ + title_prefix + """Skill Description Optimization + + + + + + +

""" + title_prefix + """Skill Description Optimization

+
+ Optimizing your skill's description. This page updates automatically as Claude tests different versions of your skill's description. Each row is an iteration — a new description attempt. The columns show test queries: green checkmarks mean the skill triggered correctly (or correctly didn't trigger), red crosses mean it got it wrong. The "Train" score shows performance on queries used to improve the description; the "Test" score shows performance on held-out queries the optimizer hasn't seen. When it's done, Claude will apply the best-performing description to your skill. +
+"""] + + # Summary section + best_test_score = data.get('best_test_score') + best_train_score = data.get('best_train_score') + html_parts.append(f""" +
+

Original: {html.escape(data.get('original_description', 'N/A'))}

+

Best: {html.escape(data.get('best_description', 'N/A'))}

+

Best Score: {data.get('best_score', 'N/A')} {'(test)' if best_test_score else '(train)'}

+

Iterations: {data.get('iterations_run', 0)} | Train: {data.get('train_size', '?')} | Test: {data.get('test_size', '?')}

+
+""") + + # Legend + html_parts.append(""" +
+ Query columns: + Should trigger + Should NOT trigger + Train + Test +
+""") + + # Table header + html_parts.append(""" +
+ + + + + + + +""") + + # Add column headers for train queries + for qinfo in train_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + # Add column headers for test queries (different color) + for qinfo in test_queries: + polarity = "positive-col" if qinfo["should_trigger"] else "negative-col" + html_parts.append(f' \n') + + html_parts.append(""" + + +""") + + # Find best iteration for highlighting + if test_queries: + best_iter = max(history, key=lambda h: h.get("test_passed") or 0).get("iteration") + else: + best_iter = max(history, key=lambda h: h.get("train_passed", h.get("passed", 0))).get("iteration") + + # Add rows for each iteration + for h in history: + iteration = h.get("iteration", "?") + train_passed = h.get("train_passed", h.get("passed", 0)) + train_total = h.get("train_total", h.get("total", 0)) + test_passed = h.get("test_passed") + test_total = h.get("test_total") + description = h.get("description", "") + train_results = h.get("train_results", h.get("results", [])) + test_results = h.get("test_results", []) + + # Create lookups for results by query + train_by_query = {r["query"]: r for r in train_results} + test_by_query = {r["query"]: r for r in test_results} if test_results else {} + + # Compute aggregate correct/total runs across all retries + def aggregate_runs(results: list[dict]) -> tuple[int, int]: + correct = 0 + total = 0 + for r in results: + runs = r.get("runs", 0) + triggers = r.get("triggers", 0) + total += runs + if r.get("should_trigger", True): + correct += triggers + else: + correct += runs - triggers + return correct, total + + train_correct, train_runs = aggregate_runs(train_results) + test_correct, test_runs = aggregate_runs(test_results) + + # Determine score classes + def score_class(correct: int, total: int) -> str: + if total > 0: + ratio = correct / total + if ratio >= 0.8: + return "score-good" + elif ratio >= 0.5: + return "score-ok" + return "score-bad" + + train_class = score_class(train_correct, train_runs) + test_class = score_class(test_correct, test_runs) + + row_class = "best-row" if iteration == best_iter else "" + + html_parts.append(f""" + + + + +""") + + # Add result for each train query + for qinfo in train_queries: + r = train_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + # Add result for each test query (with different background) + for qinfo in test_queries: + r = test_by_query.get(qinfo["query"], {}) + did_pass = r.get("pass", False) + triggers = r.get("triggers", 0) + runs = r.get("runs", 0) + + icon = "✓" if did_pass else "✗" + css_class = "pass" if did_pass else "fail" + + html_parts.append(f' \n') + + html_parts.append(" \n") + + html_parts.append(""" +
IterTrainTestDescription{html.escape(qinfo["query"])}{html.escape(qinfo["query"])}
{iteration}{train_correct}/{train_runs}{test_correct}/{test_runs}{html.escape(description)}{icon}{triggers}/{runs}{icon}{triggers}/{runs}
+
+""") + + html_parts.append(""" + + +""") + + return "".join(html_parts) + + +def main(): + parser = argparse.ArgumentParser(description="Generate HTML report from run_loop output") + parser.add_argument("input", help="Path to JSON output from run_loop.py (or - for stdin)") + parser.add_argument("-o", "--output", default=None, help="Output HTML file (default: stdout)") + parser.add_argument("--skill-name", default="", help="Skill name to include in the report title") + args = parser.parse_args() + + if args.input == "-": + data = json.load(sys.stdin) + else: + data = json.loads(Path(args.input).read_text()) + + html_output = generate_html(data, skill_name=args.skill_name) + + if args.output: + Path(args.output).write_text(html_output) + print(f"Report written to {args.output}", file=sys.stderr) + else: + print(html_output) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/improve_description.py b/.claude/skills/skill-creator/scripts/improve_description.py new file mode 100755 index 0000000000..06bcec7612 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/improve_description.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +"""Improve a skill description based on eval results. + +Takes eval results (from run_eval.py) and generates an improved description +by calling `claude -p` as a subprocess (same auth pattern as run_eval.py — +uses the session's Claude Code auth, no separate ANTHROPIC_API_KEY needed). +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def _call_claude(prompt: str, model: str | None, timeout: int = 300) -> str: + """Run `claude -p` with the prompt on stdin and return the text response. + + Prompt goes over stdin (not argv) because it embeds the full SKILL.md + body and can easily exceed comfortable argv length. + """ + cmd = ["claude", "-p", "--output-format", "text"] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. Same pattern as run_eval.py. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + result = subprocess.run( + cmd, + input=prompt, + capture_output=True, + text=True, + env=env, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError( + f"claude -p exited {result.returncode}\nstderr: {result.stderr}" + ) + return result.stdout + + +def improve_description( + skill_name: str, + skill_content: str, + current_description: str, + eval_results: dict, + history: list[dict], + model: str, + test_results: dict | None = None, + log_dir: Path | None = None, + iteration: int | None = None, +) -> str: + """Call Claude to improve the description based on eval results.""" + failed_triggers = [ + r for r in eval_results["results"] + if r["should_trigger"] and not r["pass"] + ] + false_triggers = [ + r for r in eval_results["results"] + if not r["should_trigger"] and not r["pass"] + ] + + # Build scores summary + train_score = f"{eval_results['summary']['passed']}/{eval_results['summary']['total']}" + if test_results: + test_score = f"{test_results['summary']['passed']}/{test_results['summary']['total']}" + scores_summary = f"Train: {train_score}, Test: {test_score}" + else: + scores_summary = f"Train: {train_score}" + + prompt = f"""You are optimizing a skill description for a Claude Code skill called "{skill_name}". A "skill" is sort of like a prompt, but with progressive disclosure -- there's a title and description that Claude sees when deciding whether to use the skill, and then if it does use the skill, it reads the .md file which has lots more details and potentially links to other resources in the skill folder like helper files and scripts and additional documentation or examples. + +The description appears in Claude's "available_skills" list. When a user sends a query, Claude decides whether to invoke the skill based solely on the title and on this description. Your goal is to write a description that triggers for relevant queries, and doesn't trigger for irrelevant ones. + +Here's the current description: + +"{current_description}" + + +Current scores ({scores_summary}): + +""" + if failed_triggers: + prompt += "FAILED TO TRIGGER (should have triggered but didn't):\n" + for r in failed_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if false_triggers: + prompt += "FALSE TRIGGERS (triggered but shouldn't have):\n" + for r in false_triggers: + prompt += f' - "{r["query"]}" (triggered {r["triggers"]}/{r["runs"]} times)\n' + prompt += "\n" + + if history: + prompt += "PREVIOUS ATTEMPTS (do NOT repeat these — try something structurally different):\n\n" + for h in history: + train_s = f"{h.get('train_passed', h.get('passed', 0))}/{h.get('train_total', h.get('total', 0))}" + test_s = f"{h.get('test_passed', '?')}/{h.get('test_total', '?')}" if h.get('test_passed') is not None else None + score_str = f"train={train_s}" + (f", test={test_s}" if test_s else "") + prompt += f'\n' + prompt += f'Description: "{h["description"]}"\n' + if "results" in h: + prompt += "Train results:\n" + for r in h["results"]: + status = "PASS" if r["pass"] else "FAIL" + prompt += f' [{status}] "{r["query"][:80]}" (triggered {r["triggers"]}/{r["runs"]})\n' + if h.get("note"): + prompt += f'Note: {h["note"]}\n' + prompt += "\n\n" + + prompt += f""" + +Skill content (for context on what the skill does): + +{skill_content} + + +Based on the failures, write a new and improved description that is more likely to trigger correctly. When I say "based on the failures", it's a bit of a tricky line to walk because we don't want to overfit to the specific cases you're seeing. So what I DON'T want you to do is produce an ever-expanding list of specific queries that this skill should or shouldn't trigger for. Instead, try to generalize from the failures to broader categories of user intent and situations where this skill would be useful or not useful. The reason for this is twofold: + +1. Avoid overfitting +2. The list might get loooong and it's injected into ALL queries and there might be a lot of skills, so we don't want to blow too much space on any given description. + +Concretely, your description should not be more than about 100-200 words, even if that comes at the cost of accuracy. There is a hard limit of 1024 characters — descriptions over that will be truncated, so stay comfortably under it. + +Here are some tips that we've found to work well in writing these descriptions: +- The skill should be phrased in the imperative -- "Use this skill for" rather than "this skill does" +- The skill description should focus on the user's intent, what they are trying to achieve, vs. the implementation details of how the skill works. +- The description competes with other skills for Claude's attention — make it distinctive and immediately recognizable. +- If you're getting lots of failures after repeated attempts, change things up. Try different sentence structures or wordings. + +I'd encourage you to be creative and mix up the style in different iterations since you'll have multiple opportunities to try different approaches and we'll just grab the highest-scoring one at the end. + +Please respond with only the new description text in tags, nothing else.""" + + text = _call_claude(prompt, model) + + match = re.search(r"(.*?)", text, re.DOTALL) + description = match.group(1).strip().strip('"') if match else text.strip().strip('"') + + transcript: dict = { + "iteration": iteration, + "prompt": prompt, + "response": text, + "parsed_description": description, + "char_count": len(description), + "over_limit": len(description) > 1024, + } + + # Safety net: the prompt already states the 1024-char hard limit, but if + # the model blew past it anyway, make one fresh single-turn call that + # quotes the too-long version and asks for a shorter rewrite. (The old + # SDK path did this as a true multi-turn; `claude -p` is one-shot, so we + # inline the prior output into the new prompt instead.) + if len(description) > 1024: + shorten_prompt = ( + f"{prompt}\n\n" + f"---\n\n" + f"A previous attempt produced this description, which at " + f"{len(description)} characters is over the 1024-character hard limit:\n\n" + f'"{description}"\n\n' + f"Rewrite it to be under 1024 characters while keeping the most " + f"important trigger words and intent coverage. Respond with only " + f"the new description in tags." + ) + shorten_text = _call_claude(shorten_prompt, model) + match = re.search(r"(.*?)", shorten_text, re.DOTALL) + shortened = match.group(1).strip().strip('"') if match else shorten_text.strip().strip('"') + + transcript["rewrite_prompt"] = shorten_prompt + transcript["rewrite_response"] = shorten_text + transcript["rewrite_description"] = shortened + transcript["rewrite_char_count"] = len(shortened) + description = shortened + + transcript["final_description"] = description + + if log_dir: + log_dir.mkdir(parents=True, exist_ok=True) + log_file = log_dir / f"improve_iter_{iteration or 'unknown'}.json" + log_file.write_text(json.dumps(transcript, indent=2)) + + return description + + +def main(): + parser = argparse.ArgumentParser(description="Improve a skill description based on eval results") + parser.add_argument("--eval-results", required=True, help="Path to eval results JSON (from run_eval.py)") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--history", default=None, help="Path to history JSON (previous attempts)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print thinking to stderr") + args = parser.parse_args() + + skill_path = Path(args.skill_path) + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + eval_results = json.loads(Path(args.eval_results).read_text()) + history = [] + if args.history: + history = json.loads(Path(args.history).read_text()) + + name, _, content = parse_skill_md(skill_path) + current_description = eval_results["description"] + + if args.verbose: + print(f"Current: {current_description}", file=sys.stderr) + print(f"Score: {eval_results['summary']['passed']}/{eval_results['summary']['total']}", file=sys.stderr) + + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=eval_results, + history=history, + model=args.model, + ) + + if args.verbose: + print(f"Improved: {new_description}", file=sys.stderr) + + # Output as JSON with both the new description and updated history + output = { + "description": new_description, + "history": history + [{ + "description": current_description, + "passed": eval_results["summary"]["passed"], + "failed": eval_results["summary"]["failed"], + "total": eval_results["summary"]["total"], + "results": eval_results["results"], + }], + } + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/package_skill.py b/.claude/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000000..f48eac4446 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import fnmatch +import sys +import zipfile +from pathlib import Path +from scripts.quick_validate import validate_skill + +# Patterns to exclude when packaging skills. +EXCLUDE_DIRS = {"__pycache__", "node_modules"} +EXCLUDE_GLOBS = {"*.pyc"} +EXCLUDE_FILES = {".DS_Store"} +# Directories excluded only at the skill root (not when nested deeper). +ROOT_EXCLUDE_DIRS = {"evals"} + + +def should_exclude(rel_path: Path) -> bool: + """Check if a path should be excluded from packaging.""" + parts = rel_path.parts + if any(part in EXCLUDE_DIRS for part in parts): + return True + # rel_path is relative to skill_path.parent, so parts[0] is the skill + # folder name and parts[1] (if present) is the first subdir. + if len(parts) > 1 and parts[1] in ROOT_EXCLUDE_DIRS: + return True + name = rel_path.name + if name in EXCLUDE_FILES: + return True + return any(fnmatch.fnmatch(name, pat) for pat in EXCLUDE_GLOBS) + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory, excluding build artifacts + for file_path in skill_path.rglob('*'): + if not file_path.is_file(): + continue + arcname = file_path.relative_to(skill_path.parent) + if should_exclude(arcname): + print(f" Skipped: {arcname}") + continue + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"❌ Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/quick_validate.py b/.claude/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 0000000000..ed8e1dddce --- /dev/null +++ b/.claude/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +import yaml +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter_text = match.group(1) + + # Parse YAML frontmatter + try: + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + return False, "Frontmatter must be a YAML dictionary" + except yaml.YAMLError as e: + return False, f"Invalid YAML in frontmatter: {e}" + + # Define allowed properties + ALLOWED_PROPERTIES = {'name', 'description', 'license', 'allowed-tools', 'metadata', 'compatibility'} + + # Check for unexpected properties (excluding nested keys under metadata) + unexpected_keys = set(frontmatter.keys()) - ALLOWED_PROPERTIES + if unexpected_keys: + return False, ( + f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(sorted(unexpected_keys))}. " + f"Allowed properties are: {', '.join(sorted(ALLOWED_PROPERTIES))}" + ) + + # Check required fields + if 'name' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name = frontmatter.get('name', '') + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name = name.strip() + if name: + # Check naming convention (kebab-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be kebab-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + # Check name length (max 64 characters per spec) + if len(name) > 64: + return False, f"Name is too long ({len(name)} characters). Maximum is 64 characters." + + # Extract and validate description + description = frontmatter.get('description', '') + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description = description.strip() + if description: + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + # Check description length (max 1024 characters per spec) + if len(description) > 1024: + return False, f"Description is too long ({len(description)} characters). Maximum is 1024 characters." + + # Validate compatibility field if present (optional) + compatibility = frontmatter.get('compatibility', '') + if compatibility: + if not isinstance(compatibility, str): + return False, f"Compatibility must be a string, got {type(compatibility).__name__}" + if len(compatibility) > 500: + return False, f"Compatibility is too long ({len(compatibility)} characters). Maximum is 500 characters." + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/.claude/skills/skill-creator/scripts/run_eval.py b/.claude/skills/skill-creator/scripts/run_eval.py new file mode 100755 index 0000000000..e58c70bea3 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/run_eval.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Run trigger evaluation for a skill description. + +Tests whether a skill's description causes Claude to trigger (read the skill) +for a set of queries. Outputs results as JSON. +""" + +import argparse +import json +import os +import select +import subprocess +import sys +import time +import uuid +from concurrent.futures import ProcessPoolExecutor, as_completed +from pathlib import Path + +from scripts.utils import parse_skill_md + + +def find_project_root() -> Path: + """Find the project root by walking up from cwd looking for .claude/. + + Mimics how Claude Code discovers its project root, so the command file + we create ends up where claude -p will look for it. + """ + current = Path.cwd() + for parent in [current, *current.parents]: + if (parent / ".claude").is_dir(): + return parent + return current + + +def run_single_query( + query: str, + skill_name: str, + skill_description: str, + timeout: int, + project_root: str, + model: str | None = None, +) -> bool: + """Run a single query and return whether the skill was triggered. + + Creates a command file in .claude/commands/ so it appears in Claude's + available_skills list, then runs `claude -p` with the raw query. + Uses --include-partial-messages to detect triggering early from + stream events (content_block_start) rather than waiting for the + full assistant message, which only arrives after tool execution. + """ + unique_id = uuid.uuid4().hex[:8] + clean_name = f"{skill_name}-skill-{unique_id}" + project_commands_dir = Path(project_root) / ".claude" / "commands" + command_file = project_commands_dir / f"{clean_name}.md" + + try: + project_commands_dir.mkdir(parents=True, exist_ok=True) + # Use YAML block scalar to avoid breaking on quotes in description + indented_desc = "\n ".join(skill_description.split("\n")) + command_content = ( + f"---\n" + f"description: |\n" + f" {indented_desc}\n" + f"---\n\n" + f"# {skill_name}\n\n" + f"This skill handles: {skill_description}\n" + ) + command_file.write_text(command_content) + + cmd = [ + "claude", + "-p", query, + "--output-format", "stream-json", + "--verbose", + "--include-partial-messages", + ] + if model: + cmd.extend(["--model", model]) + + # Remove CLAUDECODE env var to allow nesting claude -p inside a + # Claude Code session. The guard is for interactive terminal conflicts; + # programmatic subprocess usage is safe. + env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE"} + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + cwd=project_root, + env=env, + ) + + triggered = False + start_time = time.time() + buffer = "" + # Track state for stream event detection + pending_tool_name = None + accumulated_json = "" + + try: + while time.time() - start_time < timeout: + if process.poll() is not None: + remaining = process.stdout.read() + if remaining: + buffer += remaining.decode("utf-8", errors="replace") + break + + ready, _, _ = select.select([process.stdout], [], [], 1.0) + if not ready: + continue + + chunk = os.read(process.stdout.fileno(), 8192) + if not chunk: + break + buffer += chunk.decode("utf-8", errors="replace") + + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + if not line: + continue + + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + + # Early detection via stream events + if event.get("type") == "stream_event": + se = event.get("event", {}) + se_type = se.get("type", "") + + if se_type == "content_block_start": + cb = se.get("content_block", {}) + if cb.get("type") == "tool_use": + tool_name = cb.get("name", "") + if tool_name in ("Skill", "Read"): + pending_tool_name = tool_name + accumulated_json = "" + else: + return False + + elif se_type == "content_block_delta" and pending_tool_name: + delta = se.get("delta", {}) + if delta.get("type") == "input_json_delta": + accumulated_json += delta.get("partial_json", "") + if clean_name in accumulated_json: + return True + + elif se_type in ("content_block_stop", "message_stop"): + if pending_tool_name: + return clean_name in accumulated_json + if se_type == "message_stop": + return False + + # Fallback: full assistant message + elif event.get("type") == "assistant": + message = event.get("message", {}) + for content_item in message.get("content", []): + if content_item.get("type") != "tool_use": + continue + tool_name = content_item.get("name", "") + tool_input = content_item.get("input", {}) + if tool_name == "Skill" and clean_name in tool_input.get("skill", ""): + triggered = True + elif tool_name == "Read" and clean_name in tool_input.get("file_path", ""): + triggered = True + return triggered + + elif event.get("type") == "result": + return triggered + finally: + # Clean up process on any exit path (return, exception, timeout) + if process.poll() is None: + process.kill() + process.wait() + + return triggered + finally: + if command_file.exists(): + command_file.unlink() + + +def run_eval( + eval_set: list[dict], + skill_name: str, + description: str, + num_workers: int, + timeout: int, + project_root: Path, + runs_per_query: int = 1, + trigger_threshold: float = 0.5, + model: str | None = None, +) -> dict: + """Run the full eval set and return results.""" + results = [] + + with ProcessPoolExecutor(max_workers=num_workers) as executor: + future_to_info = {} + for item in eval_set: + for run_idx in range(runs_per_query): + future = executor.submit( + run_single_query, + item["query"], + skill_name, + description, + timeout, + str(project_root), + model, + ) + future_to_info[future] = (item, run_idx) + + query_triggers: dict[str, list[bool]] = {} + query_items: dict[str, dict] = {} + for future in as_completed(future_to_info): + item, _ = future_to_info[future] + query = item["query"] + query_items[query] = item + if query not in query_triggers: + query_triggers[query] = [] + try: + query_triggers[query].append(future.result()) + except Exception as e: + print(f"Warning: query failed: {e}", file=sys.stderr) + query_triggers[query].append(False) + + for query, triggers in query_triggers.items(): + item = query_items[query] + trigger_rate = sum(triggers) / len(triggers) + should_trigger = item["should_trigger"] + if should_trigger: + did_pass = trigger_rate >= trigger_threshold + else: + did_pass = trigger_rate < trigger_threshold + results.append({ + "query": query, + "should_trigger": should_trigger, + "trigger_rate": trigger_rate, + "triggers": sum(triggers), + "runs": len(triggers), + "pass": did_pass, + }) + + passed = sum(1 for r in results if r["pass"]) + total = len(results) + + return { + "skill_name": skill_name, + "description": description, + "results": results, + "summary": { + "total": total, + "passed": passed, + "failed": total - passed, + }, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run trigger evaluation for a skill description") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override description to test") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--model", default=None, help="Model to use for claude -p (default: user's configured model)") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, original_description, content = parse_skill_md(skill_path) + description = args.description or original_description + project_root = find_project_root() + + if args.verbose: + print(f"Evaluating: {description}", file=sys.stderr) + + output = run_eval( + eval_set=eval_set, + skill_name=name, + description=description, + num_workers=args.num_workers, + timeout=args.timeout, + project_root=project_root, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + model=args.model, + ) + + if args.verbose: + summary = output["summary"] + print(f"Results: {summary['passed']}/{summary['total']} passed", file=sys.stderr) + for r in output["results"]: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:70]}", file=sys.stderr) + + print(json.dumps(output, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/run_loop.py b/.claude/skills/skill-creator/scripts/run_loop.py new file mode 100755 index 0000000000..30a263d674 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/run_loop.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +"""Run the eval + improve loop until all pass or max iterations reached. + +Combines run_eval.py and improve_description.py in a loop, tracking history +and returning the best description found. Supports train/test split to prevent +overfitting. +""" + +import argparse +import json +import random +import sys +import tempfile +import time +import webbrowser +from pathlib import Path + +from scripts.generate_report import generate_html +from scripts.improve_description import improve_description +from scripts.run_eval import find_project_root, run_eval +from scripts.utils import parse_skill_md + + +def split_eval_set(eval_set: list[dict], holdout: float, seed: int = 42) -> tuple[list[dict], list[dict]]: + """Split eval set into train and test sets, stratified by should_trigger.""" + random.seed(seed) + + # Separate by should_trigger + trigger = [e for e in eval_set if e["should_trigger"]] + no_trigger = [e for e in eval_set if not e["should_trigger"]] + + # Shuffle each group + random.shuffle(trigger) + random.shuffle(no_trigger) + + # Calculate split points + n_trigger_test = max(1, int(len(trigger) * holdout)) + n_no_trigger_test = max(1, int(len(no_trigger) * holdout)) + + # Split + test_set = trigger[:n_trigger_test] + no_trigger[:n_no_trigger_test] + train_set = trigger[n_trigger_test:] + no_trigger[n_no_trigger_test:] + + return train_set, test_set + + +def run_loop( + eval_set: list[dict], + skill_path: Path, + description_override: str | None, + num_workers: int, + timeout: int, + max_iterations: int, + runs_per_query: int, + trigger_threshold: float, + holdout: float, + model: str, + verbose: bool, + live_report_path: Path | None = None, + log_dir: Path | None = None, +) -> dict: + """Run the eval + improvement loop.""" + project_root = find_project_root() + name, original_description, content = parse_skill_md(skill_path) + current_description = description_override or original_description + + # Split into train/test if holdout > 0 + if holdout > 0: + train_set, test_set = split_eval_set(eval_set, holdout) + if verbose: + print(f"Split: {len(train_set)} train, {len(test_set)} test (holdout={holdout})", file=sys.stderr) + else: + train_set = eval_set + test_set = [] + + history = [] + exit_reason = "unknown" + + for iteration in range(1, max_iterations + 1): + if verbose: + print(f"\n{'='*60}", file=sys.stderr) + print(f"Iteration {iteration}/{max_iterations}", file=sys.stderr) + print(f"Description: {current_description}", file=sys.stderr) + print(f"{'='*60}", file=sys.stderr) + + # Evaluate train + test together in one batch for parallelism + all_queries = train_set + test_set + t0 = time.time() + all_results = run_eval( + eval_set=all_queries, + skill_name=name, + description=current_description, + num_workers=num_workers, + timeout=timeout, + project_root=project_root, + runs_per_query=runs_per_query, + trigger_threshold=trigger_threshold, + model=model, + ) + eval_elapsed = time.time() - t0 + + # Split results back into train/test by matching queries + train_queries_set = {q["query"] for q in train_set} + train_result_list = [r for r in all_results["results"] if r["query"] in train_queries_set] + test_result_list = [r for r in all_results["results"] if r["query"] not in train_queries_set] + + train_passed = sum(1 for r in train_result_list if r["pass"]) + train_total = len(train_result_list) + train_summary = {"passed": train_passed, "failed": train_total - train_passed, "total": train_total} + train_results = {"results": train_result_list, "summary": train_summary} + + if test_set: + test_passed = sum(1 for r in test_result_list if r["pass"]) + test_total = len(test_result_list) + test_summary = {"passed": test_passed, "failed": test_total - test_passed, "total": test_total} + test_results = {"results": test_result_list, "summary": test_summary} + else: + test_results = None + test_summary = None + + history.append({ + "iteration": iteration, + "description": current_description, + "train_passed": train_summary["passed"], + "train_failed": train_summary["failed"], + "train_total": train_summary["total"], + "train_results": train_results["results"], + "test_passed": test_summary["passed"] if test_summary else None, + "test_failed": test_summary["failed"] if test_summary else None, + "test_total": test_summary["total"] if test_summary else None, + "test_results": test_results["results"] if test_results else None, + # For backward compat with report generator + "passed": train_summary["passed"], + "failed": train_summary["failed"], + "total": train_summary["total"], + "results": train_results["results"], + }) + + # Write live report if path provided + if live_report_path: + partial_output = { + "original_description": original_description, + "best_description": current_description, + "best_score": "in progress", + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + live_report_path.write_text(generate_html(partial_output, auto_refresh=True, skill_name=name)) + + if verbose: + def print_eval_stats(label, results, elapsed): + pos = [r for r in results if r["should_trigger"]] + neg = [r for r in results if not r["should_trigger"]] + tp = sum(r["triggers"] for r in pos) + pos_runs = sum(r["runs"] for r in pos) + fn = pos_runs - tp + fp = sum(r["triggers"] for r in neg) + neg_runs = sum(r["runs"] for r in neg) + tn = neg_runs - fp + total = tp + tn + fp + fn + precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0 + recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0 + accuracy = (tp + tn) / total if total > 0 else 0.0 + print(f"{label}: {tp+tn}/{total} correct, precision={precision:.0%} recall={recall:.0%} accuracy={accuracy:.0%} ({elapsed:.1f}s)", file=sys.stderr) + for r in results: + status = "PASS" if r["pass"] else "FAIL" + rate_str = f"{r['triggers']}/{r['runs']}" + print(f" [{status}] rate={rate_str} expected={r['should_trigger']}: {r['query'][:60]}", file=sys.stderr) + + print_eval_stats("Train", train_results["results"], eval_elapsed) + if test_summary: + print_eval_stats("Test ", test_results["results"], 0) + + if train_summary["failed"] == 0: + exit_reason = f"all_passed (iteration {iteration})" + if verbose: + print(f"\nAll train queries passed on iteration {iteration}!", file=sys.stderr) + break + + if iteration == max_iterations: + exit_reason = f"max_iterations ({max_iterations})" + if verbose: + print(f"\nMax iterations reached ({max_iterations}).", file=sys.stderr) + break + + # Improve the description based on train results + if verbose: + print(f"\nImproving description...", file=sys.stderr) + + t0 = time.time() + # Strip test scores from history so improvement model can't see them + blinded_history = [ + {k: v for k, v in h.items() if not k.startswith("test_")} + for h in history + ] + new_description = improve_description( + skill_name=name, + skill_content=content, + current_description=current_description, + eval_results=train_results, + history=blinded_history, + model=model, + log_dir=log_dir, + iteration=iteration, + ) + improve_elapsed = time.time() - t0 + + if verbose: + print(f"Proposed ({improve_elapsed:.1f}s): {new_description}", file=sys.stderr) + + current_description = new_description + + # Find the best iteration by TEST score (or train if no test set) + if test_set: + best = max(history, key=lambda h: h["test_passed"] or 0) + best_score = f"{best['test_passed']}/{best['test_total']}" + else: + best = max(history, key=lambda h: h["train_passed"]) + best_score = f"{best['train_passed']}/{best['train_total']}" + + if verbose: + print(f"\nExit reason: {exit_reason}", file=sys.stderr) + print(f"Best score: {best_score} (iteration {best['iteration']})", file=sys.stderr) + + return { + "exit_reason": exit_reason, + "original_description": original_description, + "best_description": best["description"], + "best_score": best_score, + "best_train_score": f"{best['train_passed']}/{best['train_total']}", + "best_test_score": f"{best['test_passed']}/{best['test_total']}" if test_set else None, + "final_description": current_description, + "iterations_run": len(history), + "holdout": holdout, + "train_size": len(train_set), + "test_size": len(test_set), + "history": history, + } + + +def main(): + parser = argparse.ArgumentParser(description="Run eval + improve loop") + parser.add_argument("--eval-set", required=True, help="Path to eval set JSON file") + parser.add_argument("--skill-path", required=True, help="Path to skill directory") + parser.add_argument("--description", default=None, help="Override starting description") + parser.add_argument("--num-workers", type=int, default=10, help="Number of parallel workers") + parser.add_argument("--timeout", type=int, default=30, help="Timeout per query in seconds") + parser.add_argument("--max-iterations", type=int, default=5, help="Max improvement iterations") + parser.add_argument("--runs-per-query", type=int, default=3, help="Number of runs per query") + parser.add_argument("--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold") + parser.add_argument("--holdout", type=float, default=0.4, help="Fraction of eval set to hold out for testing (0 to disable)") + parser.add_argument("--model", required=True, help="Model for improvement") + parser.add_argument("--verbose", action="store_true", help="Print progress to stderr") + parser.add_argument("--report", default="auto", help="Generate HTML report at this path (default: 'auto' for temp file, 'none' to disable)") + parser.add_argument("--results-dir", default=None, help="Save all outputs (results.json, report.html, log.txt) to a timestamped subdirectory here") + args = parser.parse_args() + + eval_set = json.loads(Path(args.eval_set).read_text()) + skill_path = Path(args.skill_path) + + if not (skill_path / "SKILL.md").exists(): + print(f"Error: No SKILL.md found at {skill_path}", file=sys.stderr) + sys.exit(1) + + name, _, _ = parse_skill_md(skill_path) + + # Set up live report path + if args.report != "none": + if args.report == "auto": + timestamp = time.strftime("%Y%m%d_%H%M%S") + live_report_path = Path(tempfile.gettempdir()) / f"skill_description_report_{skill_path.name}_{timestamp}.html" + else: + live_report_path = Path(args.report) + # Open the report immediately so the user can watch + live_report_path.write_text("

Starting optimization loop...

") + webbrowser.open(str(live_report_path)) + else: + live_report_path = None + + # Determine output directory (create before run_loop so logs can be written) + if args.results_dir: + timestamp = time.strftime("%Y-%m-%d_%H%M%S") + results_dir = Path(args.results_dir) / timestamp + results_dir.mkdir(parents=True, exist_ok=True) + else: + results_dir = None + + log_dir = results_dir / "logs" if results_dir else None + + output = run_loop( + eval_set=eval_set, + skill_path=skill_path, + description_override=args.description, + num_workers=args.num_workers, + timeout=args.timeout, + max_iterations=args.max_iterations, + runs_per_query=args.runs_per_query, + trigger_threshold=args.trigger_threshold, + holdout=args.holdout, + model=args.model, + verbose=args.verbose, + live_report_path=live_report_path, + log_dir=log_dir, + ) + + # Save JSON output + json_output = json.dumps(output, indent=2) + print(json_output) + if results_dir: + (results_dir / "results.json").write_text(json_output) + + # Write final HTML report (without auto-refresh) + if live_report_path: + live_report_path.write_text(generate_html(output, auto_refresh=False, skill_name=name)) + print(f"\nReport: {live_report_path}", file=sys.stderr) + + if results_dir and live_report_path: + (results_dir / "report.html").write_text(generate_html(output, auto_refresh=False, skill_name=name)) + + if results_dir: + print(f"Results saved to: {results_dir}", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/.claude/skills/skill-creator/scripts/utils.py b/.claude/skills/skill-creator/scripts/utils.py new file mode 100644 index 0000000000..51b6a07dd5 --- /dev/null +++ b/.claude/skills/skill-creator/scripts/utils.py @@ -0,0 +1,47 @@ +"""Shared utilities for skill-creator scripts.""" + +from pathlib import Path + + + +def parse_skill_md(skill_path: Path) -> tuple[str, str, str]: + """Parse a SKILL.md file, returning (name, description, full_content).""" + content = (skill_path / "SKILL.md").read_text() + lines = content.split("\n") + + if lines[0].strip() != "---": + raise ValueError("SKILL.md missing frontmatter (no opening ---)") + + end_idx = None + for i, line in enumerate(lines[1:], start=1): + if line.strip() == "---": + end_idx = i + break + + if end_idx is None: + raise ValueError("SKILL.md missing frontmatter (no closing ---)") + + name = "" + description = "" + frontmatter_lines = lines[1:end_idx] + i = 0 + while i < len(frontmatter_lines): + line = frontmatter_lines[i] + if line.startswith("name:"): + name = line[len("name:"):].strip().strip('"').strip("'") + elif line.startswith("description:"): + value = line[len("description:"):].strip() + # Handle YAML multiline indicators (>, |, >-, |-) + if value in (">", "|", ">-", "|-"): + continuation_lines: list[str] = [] + i += 1 + while i < len(frontmatter_lines) and (frontmatter_lines[i].startswith(" ") or frontmatter_lines[i].startswith("\t")): + continuation_lines.append(frontmatter_lines[i].strip()) + i += 1 + description = " ".join(continuation_lines) + continue + else: + description = value.strip('"').strip("'") + i += 1 + + return name, description, content diff --git a/.codeflow.yml b/.codeflow.yml index acb51e1fdd..4885bbdc81 100644 --- a/.codeflow.yml +++ b/.codeflow.yml @@ -97,14 +97,6 @@ build: expire_keep_tags_after_days: 1 expire_tmp_tags_after_days: 1 expire_all_after_days: 2 - - BaldurNode: - name: package-ui-mobile-visreg - path: ./packages/ui-mobile-visreg/publish.Dockerfile - autobuild_files: - - packages/ui-mobile-visreg/package.json - expire_keep_tags_after_days: 1 - expire_tmp_tags_after_days: 1 - expire_all_after_days: 2 - BaldurNode: name: package-ui-scorecard path: ./packages/ui-scorecard/publish.Dockerfile diff --git a/.cursor/commands/component-docs.md b/.cursor/commands/component-docs.md deleted file mode 100644 index 7b3a4e64fa..0000000000 --- a/.cursor/commands/component-docs.md +++ /dev/null @@ -1,556 +0,0 @@ -# Component Documentation - -Create or update documentation for a CDS component on the docsite (apps/docs/). - -**Usage:** `/component-docs [additional context]` - -Examples: - -- `/component-docs Button` -- `/component-docs LineChart add examples for real-time data updates` -- `/component-docs Avatar needs accessibility improvements` - -If no component name is provided, ask the user which component they want to document. - -## Step 1: Check for Existing Documentation - -First, check if documentation already exists for this component: - -```bash -apps/docs/docs/components/*/[ComponentName]/ -``` - -- **If docs exist**: Review the existing documentation and identify what needs to be added, updated, or improved. Consider the user's additional context if provided. -- **If docs don't exist**: Follow the full workflow below to create new documentation. - -For updates, focus on the specific areas that need improvement rather than rewriting everything. - -### Reference Components - -When creating or updating docs, reference these well-documented components to understand the documentation style and patterns: - -- **LineChart** (`apps/docs/docs/components/graphs/LineChart/`) - Comprehensive example with many composed examples -- **Button** (`apps/docs/docs/components/buttons/Button/`) - Good basic component documentation -- **IconButton** (`apps/docs/docs/components/buttons/IconButton/`) - Simple component with clear examples -- **Sidebar** (`apps/docs/docs/components/navigation/Sidebar/`) - Complex component with multiple sub-components - -Review these before writing to ensure consistency in style, structure, and depth. - -### Reference Files - -When writing examples, reference these files for valid values: - -- **Icon names** (`packages/icons/src/IconName.ts`) - All valid icon names for the `icon` prop (e.g., `'checkmark'`, `'close'`, `'warning'`) - -## Step 2: Research Phase (for new docs or major updates) - -Before writing documentation, research how other popular component libraries document the same (or similar) component. Use web search to find documentation for the component in: - -- **Material UI** (mui.com) -- **Radix UI** (radix-ui.com) -- **Mantine** (mantine.dev) -- **Ant Design** (ant.design) -- **Base UI** (base-ui.com) - -Look for: - -- What examples they provide and how they're organized -- Common use cases they demonstrate -- Edge cases or patterns worth highlighting -- Accessibility guidance they include -- How they explain complex features - -Use these insights to inform your documentation structure and examples. - -## Step 3: Check Component Availability - -Verify where the component exists: - -```bash -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/...` - -## Step 4: Required Setup Steps (for new docs only) - -Before creating the component documentation, complete these setup steps: - -### 4.1 Add to ReactLiveScope - -In `apps/docs/src/components/page/ReactLiveScope.ts`, add the component imports and add them to the scope: - -```ts -// Add imports -import { ComponentName } from '@coinbase/cds-web'; - -// Add to scope object -const ReactLiveScope = { - // ... existing scope - ComponentName, -}; -``` - -There is a chance that the component has already been imported. - -### 4.2 Update sidebars.ts - -In `apps/docs/sidebars.ts`, add the component to its category section: - -```ts -module.exports = { - docs: [ - // ... other sections - { - type: 'category', - label: 'Category', // e.g., 'Buttons', 'Layout', etc. - items: [ - // ... other components - 'components/category/ComponentName/index', - ], - }, - ], -}; -``` - -### 4.3 Update docgen.config.js - -In `apps/docs/docgen.config.js`, add the component paths to generate props data: - -```js -module.exports = { - web: { - // ... other configs - category: { - // e.g., 'buttons', 'layout', etc. - ComponentName: { - source: 'packages/web/src/category/ComponentName.tsx', - }, - }, - }, - // If component has a mobile version - mobile: { - // ... other configs - category: { - ComponentName: { - source: 'packages/mobile/src/category/ComponentName.tsx', - }, - }, - }, -}; -``` - -## Step 5: Create Directory Structure (for new docs only) - -Create the documentation directory and files based on component availability: - -```bash -apps/docs/docs/components/[docs-category]/[ComponentName]/ -├── index.mdx # Required for all components -├── webMetadata.json # If web version exists -├── _webExamples.mdx # If web version exists -├── _webPropsTable.mdx # If web version exists -├── mobileMetadata.json # If mobile version exists -├── _mobileExamples.mdx # If mobile version exists -└── _mobilePropsTable.mdx # If mobile version exists -``` - -## File Templates - -### Metadata Files - -#### webMetadata.json - -```json -{ - "import": "import { ComponentName } from '@coinbase/cds-web/[source-category]/[ComponentName]'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/[source-category]/[ComponentName].tsx", - "description": "[Component description]", - "figma": "[figma link]", - "storybook": "[storybook link]", - "relatedComponents": [ - { "label": "[componentName]", "url": "/components/[category]/[componentName]" } - ], - "dependencies": [{ "name": "[peer-dependency-name]", "version": "[version-range]" }] -} -``` - -**Notes:** - -- `description` should be the full component description - what the component is and when to use it (e.g., "A non-intrusive notification component that temporarily displays brief messages at the bottom of the screen.") -- `figma` and `storybook` fields are optional - only add if provided -- `dependencies` is optional - only include if the component imports from external packages that are peer dependencies. To determine: - 1. Check the component's source file for imports from external packages (e.g., `framer-motion`) - 2. Cross-reference those imports with `peerDependencies` in `packages/web/package.json` - 3. Use the exact version range from `peerDependencies` in the package.json file -- `relatedComponents` should link to components commonly used together - -#### mobileMetadata.json - -```json -{ - "import": "import { ComponentName } from '@coinbase/cds-mobile/[source-category]/[ComponentName]'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/[source-category]/[ComponentName].tsx", - "description": "[Component description]", - "figma": "[figma link]", - "relatedComponents": [ - { "label": "[componentName]", "url": "/components/[category]/[componentName]" } - ], - "dependencies": [{ "name": "[peer-dependency-name]", "version": "[version-range]" }] -} -``` - -**Notes:** - -- `figma` is optional - only add if provided -- `dependencies` is optional - only include if the component imports from external packages that are peer dependencies. To determine: - 1. Check the component's source file for imports from external packages (e.g., `@shopify/react-native-skia`, `react-native-reanimated`, `react-native-gesture-handler`) - 2. Cross-reference those imports with `peerDependencies` in `packages/mobile/package.json` - 3. Use the exact version range from `peerDependencies` in the package.json file - -### Props Tables - -#### \_webPropsTable.mdx - -```mdx -import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; -import webPropsData from ':docgen/web/[source-category]/[ComponentName]/data'; -import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; -import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; - - -``` - -#### \_mobilePropsTable.mdx - -```mdx -import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; -import mobilePropsData from ':docgen/mobile/[source-category]/[ComponentName]/data'; -import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; -import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; - - -``` - -### Main Documentation (index.mdx) - -#### For Web-Only Components - -```mdx ---- -id: [component-id] -title: [ComponentName] -platform_switcher_options: { web: true, mobile: false } -hide_title: true ---- - -import { VStack } from '@coinbase/cds-web/layout'; -import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; -import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; - -import webPropsToc from ':docgen/web/[source-category]/[ComponentName]/toc-props'; -import WebPropsTable from './_webPropsTable.mdx'; -import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; -import webMetadata from './webMetadata.json'; - - - - } - webExamples={} - webExamplesToc={webExamplesToc} - webPropsToc={webPropsToc} - /> - -``` - -#### For Mobile-Only Components - -```mdx ---- -id: [component-id] -title: [ComponentName] -platform_switcher_options: { web: false, mobile: true } -hide_title: true ---- - -import { VStack } from '@coinbase/cds-web/layout'; -import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; -import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; - -import mobilePropsToc from ':docgen/mobile/[source-category]/[ComponentName]/toc-props'; -import MobilePropsTable from './_mobilePropsTable.mdx'; -import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; -import mobileMetadata from './mobileMetadata.json'; - - - - } - mobileExamples={} - mobileExamplesToc={mobileExamplesToc} - mobilePropsToc={mobilePropsToc} - /> - -``` - -#### For Cross-Platform Components - -```mdx ---- -id: [component-id] -title: [ComponentName] -platform_switcher_options: { web: true, mobile: true } -hide_title: true ---- - -import { VStack } from '@coinbase/cds-web/layout'; -import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; -import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; - -import webPropsToc from ':docgen/web/[source-category]/[ComponentName]/toc-props'; -import mobilePropsToc from ':docgen/mobile/[source-category]/[ComponentName]/toc-props'; -import WebPropsTable from './_webPropsTable.mdx'; -import MobilePropsTable from './_mobilePropsTable.mdx'; -import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; -import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; -import webMetadata from './webMetadata.json'; -import mobileMetadata from './mobileMetadata.json'; - - - - } - webExamples={} - mobilePropsTable={} - mobileExamples={} - webExamplesToc={webExamplesToc} - mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} - mobilePropsToc={mobilePropsToc} - /> - -``` - -### Examples - -#### Example Structure Guidelines - -Examples should follow this recommended structure: - -1. **Brief intro** - A short functional note (NOT the full description - that goes in metadata). Mention what the component uses/wraps or key dependencies. -2. **Basics** - Simplest usage explaining how to use the core API -3. **Feature sections** - Group related functionality (Data, Interaction, Styling, etc.) -4. **Accessibility** - How to make the component accessible -5. **Composed Examples** - Real-world use cases combining multiple features - -**Important:** Do NOT repeat the full component description from metadata in the examples. The examples should focus on _how_ to use the component, not _what_ it is. - -#### \_webExamples.mdx (Live Examples) - -Web examples use `jsx live` blocks which render interactively in the browser. For short, incomplete code snippets that are meant to illustrate a concept rather than be runnable, you may use plain `jsx` blocks instead. - -````mdx -[ComponentName] uses [dependency/wrapper] to [brief functional note]. [Any key setup requirement in one sentence]. - -## Basics - -[Explain how to use the component's core API - e.g., "Call `toast.show()` with a message string to display a toast."] - -```jsx live -<[ComponentName] - requiredProp="value" -/> -``` - -## [Feature Category] - -[Brief explanation of this feature category] - -### [Specific Feature] - -```jsx live -<[ComponentName] - featureProp="value" -/> -``` - -## Accessibility - -Use `accessibilityLabel` to provide context for screen readers. When [specific scenario], also consider [accessibility guidance]. - -```jsx live -<[ComponentName] - accessibilityLabel="Descriptive label for screen readers" - requiredProp="value" -/> -``` - -## Composed Examples - -### [Real-World Use Case Name] - -[Brief description of what this example demonstrates] - -```jsx live -function [UseCaseName]() { - // Use hooks at the top - const [state, setState] = useState(initialValue); - - // Memoize expensive computations - const computedValue = useMemo(() => { - return expensiveComputation(state); - }, [state]); - - // Memoize callbacks passed to children - const handleEvent = useCallback(() => { - // handle event - }, []); - - return ( - <[ComponentName] - prop={computedValue} - onEvent={handleEvent} - /> - ); -} -``` -```` - -#### \_mobileExamples.mdx (Static Examples) - -Mobile examples use static `jsx` blocks only. **Do not use `jsx live`** - React Native cannot run in the browser. - -````mdx -[ComponentName] uses [dependency/wrapper] to [brief functional note]. [Any mobile-specific behavior in one sentence, e.g., "On mobile, toasts can be swiped away."] - -## Basics - -[Explain how to use the component's core API - e.g., "Call `toast.show()` with a message string to display a toast."] - -```jsx -<[ComponentName] - requiredProp="value" -/> -``` - -## [Feature Category] - -[Brief explanation of this feature category] - -### [Specific Feature] - -```jsx -<[ComponentName] - featureProp="value" -/> -``` - -## Accessibility - -Use `accessibilityLabel` to provide context for screen readers. - -```jsx -<[ComponentName] - accessibilityLabel="Descriptive label for screen readers" - requiredProp="value" -/> -``` - -## Composed Examples - -### [Real-World Use Case Name] - -[Brief description of what this example demonstrates] - -```jsx -function [UseCaseName]() { - const [state, setState] = useState(initialValue); - - const computedValue = useMemo(() => { - return expensiveComputation(state); - }, [state]); - - return ( - <[ComponentName] - prop={computedValue} - /> - ); -} -``` -```` - -## Best Practices for Examples - -### Code Quality - -- **Use named functions** for complex examples that need state or effects -- **Memoize with `useMemo`** for expensive computations or computed styles -- **Memoize with `useCallback`** for event handlers passed as props -- **Include accessibility labels** in interactive examples -- **Format values for display** using `Intl.NumberFormat`, `Intl.DateTimeFormat`, etc. - -### Documentation Quality - -- **Start with introductory prose** explaining what the component does before any code -- **Progress from simple to complex** - basic examples first, composed examples last -- **Cross-reference related components** using markdown links: `[ComponentName](/components/category/ComponentName)` -- **Explain the "why"** not just the "how" - help users understand when to use each feature -- **Show edge cases** like empty states, loading states, error states, missing data - -### Common Sections to Consider - -Depending on the component, consider including these sections: - -- **Setup** - Prerequisites or providers needed (especially for mobile) -- **Data** - How to pass and format data -- **Interaction** - User interaction patterns (click, hover, touch, etc.) -- **Animations** - Motion and transition options -- **Styling** - Customization and theming options -- **Sizing** - Responsive and fixed sizing options -- **Composed Examples** - Real-world use cases - -## Final Checklist - -Before completing, verify: - -- [ ] Researched similar components in other libraries for inspiration -- [ ] Verified component existence in web/mobile -- [ ] Created only necessary platform-specific files -- [ ] Set correct `platform_switcher_options` -- [ ] Metadata files have correct package imports -- [ ] Added `dependencies` field if component has peer dependencies -- [ ] Props tables import from correct package with correct variable names -- [ ] Examples start with introductory prose -- [ ] Examples include accessibility guidance -- [ ] Examples progress from basic to composed -- [ ] Web examples use `jsx live` (or `jsx` for short snippets); mobile examples use `jsx` only (no `live`) -- [ ] ComponentTabsContainer includes only existing platform props -- [ ] All imports use correct source categories -- [ ] Component description is clear and helpful -- [ ] Added optional storybook/figma links if provided - -## Additional Notes - -1. Source category might differ from docs category -2. Add storybook and figma links to metadata if provided -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/.cursor/commands/figma.audit-connect.md b/.cursor/commands/figma.audit-connect.md deleted file mode 100644 index a772380300..0000000000 --- a/.cursor/commands/figma.audit-connect.md +++ /dev/null @@ -1,60 +0,0 @@ -## Task: Audit Figma Code Connect Mapping - -Audit the specificed Figma Code Connect mapping file. - -ALWAYS refresh your memory of the React Code Connect documentation here: https://developers.figma.com/docs/code-connect/react/ - -### Inputs - -You will be provided with a name or path to a Figma Code Connect mapping file. -Code Connect files (`.figma.tsx`) are colocated with their corresponding components in this repo, typically within the component's local `__figma__` directory. - -Search for the mapping file and end your task if you cannot find it. - -Within the current mapping file: - -- Study all the property mappings defined in `props: { ... }` of the code connect mapping file. -- Study the figma variants covered (indicated by use of `variant: { ... }`) - - variants should be defined as separate `figma.connect` calls with the same component. - -### Steps - -1. **Retrieve Figma component data** - - ALWAYS call Figma MCP: `get_metadata` to understand the actual component structure: - - What are the actual property names? (they often have spaces: "show start") - - What are property values vs separate properties? - - Is this a component or a component set with variants? - - Then call Figma MCP: `get_design_context` with the code connect disabled option enabled to get even more metadata - -2. **Identify Property Types Correctly** - Before analyzing mappings, study the Figma metadata you found: - - **ALWAYS** reference the guidelines for writing code connect mappings here: .cursor/rules/code-connect.mdc - - **Component Properties**: Boolean toggles, dropdowns/enums in the properties panel - - **Property Values**: Options within enum properties (e.g., "disabled" is a value of "state") - - **Text Layers**: Named text layers that need `figma.textContent()` - - **Instance Layers**: Named instances that need `figma.instance()` or `figma.children()` - - **Nested Properties**: Properties exposed from child layers (marked with ↳ in Figma) - -3. **Read the React component source** - - Find and read the component's TypeScript source file, including any of its sub-components' source files - - Study the React props for the component(s) - -4. **Analyze Property Coverage** - Create a mapping analysis table, where each row is a property from the Figma `get_metadata` structure: - - | Figma Property | Related React Prop(s) | Mapped? | Mapping Method | Notes | - | -------------- | --------------------- | ------- | -------------- | ----- | - - For each Figma property, indicate: - - ✅ Fully mapped - - ⚠️ Partially mapped (explain gap) - - ❌ Not mapped (explain why it should/shouldn't be) - -5. **Generate Report** - Provide a summary with: - - **Coverage**: X/Y properties mapped - - **Missing Mappings**: List any unmapped Figma properties that should be mapped - - **Missing Variants**: List any component variants that are not covered by the current state of the mapping file. - - **Incorrect Mappings**: List any mappings whose type doesn't match the actual property type from the Figma metadata - - **Unnecessary Mappings**: Any mappings that don't correspond to Figma properties - - **Recommended Changes**: Prioritized list of improvements with code snippets. Before suggesting any specific code changes, ensure you have read the latest React Code Connect documentation, linked above. diff --git a/.cursor/commands/figma.create-connect.md b/.cursor/commands/figma.create-connect.md deleted file mode 100644 index 28bc608e5a..0000000000 --- a/.cursor/commands/figma.create-connect.md +++ /dev/null @@ -1,74 +0,0 @@ -## Task: Create Figma Code Connect Mapping - -Objective: Create a new Code Connect mapping file for a specificed CDS component. - -ALWAYS refresh your memory of the React Code Connect documentation here: https://developers.figma.com/docs/code-connect/react/ before starting this task. - -### Inputs - -You must be provided two pieces of information: - - 1. a name or reference to a CDS React component - 2. a Figma URL - -If you do not have either, MUST NEVER proceed with the task. - -### Steps - -1. **Retrieve Figma component data** - - ALWAYS call the Figma MCP: `get_metadata` tool with the Figma URL you were provided - - Also cal Figma MCP: `get_design_context` with the code connect disabled option enabled to get even more metadata - - Study all Figma properties and variants - - Before continuing: - - Summarize & list the Component/Variants you found - - Summarize & list the Properties you found for the Component/Variants - -2. **Read the React component source** - - Find and read the component's TypeScript source file, including any of its sub-components' source files - - Study the React props for the component(s) - -3. **Generate Code Connect Mapping File** - - ALWAYS reference the guidelines for writing code connect mappings here: .cursor/rules/code-connect.mdc - - Create the mapping file for the component - - Provide a brief description of the mappings you created when you are done. - -## Code Connect Best Practices - -In this repo, it is convention for Code Connect files (`*.figma.tsx`) to be colocated with their corresponding components, within a `__figma__` directory. - -Example: - -``` -MyComponent/ - __tests__/ - __figma__/ - MyComponent.figma.tsx - MyComponent.tsx - index.ts -``` - -## Typical Code Connect Template - -**Note**: NEVER use relative imports for components used in code connect examples. ALWAYS use the package import paths. - -Template Code Connect file: - -```tsx -import { figma } from '@figma/code-connect'; -// Add React import for mobile components only -// import React from 'react'; - -import { ComponentName } from '@coinbase/package-name/path/to/ComponentName'; - -figma.connect( - ComponentName, - // FIGMA URL HERE, - { - imports: ["import { ComponentName } from '@coinbase/cds-package-name/path/to/ComponentName';"], - props: { - // MAP FIGMA PROPERTIES TO COMPONENT PROPS USING FIGMA CODE CONNECT API HERE - }, - example: (props) => , - }, -); -``` diff --git a/.cursor/commands/ktlo.md b/.cursor/commands/ktlo.md deleted file mode 100644 index a0438c5c1c..0000000000 --- a/.cursor/commands/ktlo.md +++ /dev/null @@ -1,9 +0,0 @@ -Use Linear MCP server to get my assigned issues in the active cycle. - -Present them to me as a list of options. It is possible that I have no issues assigned to me in the active cycle. - -Also remind me to check the Jira Bug Sprint board to any bugs that may be assigned to me as they are still not tracked in Linear. - -If I have any issues assigned to me in the active cycle, ask for the issue id/name/etc. that I may want to work on. If provided, fetch the rest of the issue's details and think about the best way to implement the feature/bug/etc. If there is not enough context on the issue, ask me clarifying questions. - -You must always execute on designated issue in PLAN MODE. Never start coding a solution to the issue without consent from me on a well thought out plan. diff --git a/.cursor/rules/cds-mobile.mdc b/.cursor/rules/cds-mobile.mdc deleted file mode 100644 index d2a8825e2e..0000000000 --- a/.cursor/rules/cds-mobile.mdc +++ /dev/null @@ -1,189 +0,0 @@ ---- -description: USE THIS when asked to work on a new or existing (MOBILE) CDS React component in packages/web -alwaysApply: false ---- - - - -# CDS Mobile Package Guidelines - -Mobile-specific patterns for `@coinbase/cds-mobile`. - -## Styling with StyleSheet - -Use `StyleSheet.create` for static styles and `useTheme()` for dynamic values: - -```tsx -import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'; - -const styles = StyleSheet.create({ - container: { position: 'relative', width: '100%' }, -}); - -// Dynamic styles via theme hook -const theme = useTheme(); -const dynamicStyle = { - backgroundColor: theme.color.bgPrimary, - padding: theme.space[2], -}; - -; -``` - -### Inline styles - -- Mobile components should all expose a `style` and `styles` object props for overriding default styles. -- As styling is a concern of that specific component, the `style` and `styles` props should never be on the `*BaseProps` type. -- `styles` can be used for granular overrides on child elements within the component. -- **Always** merge styles into a react-native style array with `useMemo` in the correct order (default styles => `style` prop => `styles[ELEMENT_NAME]` prop). - -**Example:** - -```tsx -type ComponentProps = ComponentBaseProps & { - style?: StyleProp; - styles?: { - root?: StyleProp; - label?: StyleProp; - }; -}; - -const theme = useTheme(); -const containerStyles = useMemo( - () => [ - { backgroundColor: theme.color.bgPrimary }, // default styles - style, // from props - styles.root, // from props - ], - [theme.color.bgPrimary, animatedStyles, style] -); - -// Apply to component - -``` - -## Animation - -### React Native Reanimated - -```tsx -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; - -const opacity = useSharedValue(0); -const animatedStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - transform: [{ translateY: withTiming(opacity.value * -8) }], -})); - -; -``` - -We DO NOT use React-Spring anymore for animations on mobile. - -## Gesture Handling - -Use `react-native-gesture-handler`: - -```tsx -import { Gesture, GestureDetector } from 'react-native-gesture-handler'; - -const panGesture = useMemo( - () => - Gesture.Pan() - .onStart(() => { - /* ... */ - }) - .onUpdate(({ translationX }) => { - /* ... */ - }) - .onEnd(({ translationX, velocityX }) => { - /* ... */ - }) - .withTestId(testID) - .runOnJS(true), - [dependencies], -); - - - {/* ... */} -; -``` - -## Layout Measurement - -Use `onLayout` callback instead of ResizeObserver: - -```tsx -const [size, onLayout] = useLayout(); - - -// Or inline - setHeight(e.nativeEvent.layout.height)} /> -``` - -## Accessibility - -- Use appropriate accessibilityLabel, accessibilityHint, and accessibilityRole, accessibilityState props -- Support screen readers (VoiceOver and TalkBack) -- Ensure touch targets meet minimum size requirements (44x44 points) - -**Example:** Use React Native accessibility props: - -```tsx - - - -``` - -### Screen Reader Content - -```tsx -// Hide visual content from screen readers - - {/* Animated/visual content */} - - -// Provide accessible alternative - - {accessibleLabel} - -``` - -## Native Module Integration - -Example with date picker: - -```tsx -import NativeDatePicker from 'react-native-date-picker'; - -; -``` - -## Reference Components - -- **SlideButton**: gesture handling, spring animations, accessibility actions -- **RollingNumber**: Reanimated, measurement patterns, screen reader content -- **Select** (alpha/): controlled/uncontrolled, Drawer integration -- **Stepper**: direction-based defaults, shared logic from cds-common -- **Tour**: animations, complexity -- **DatePicker**: complexity, native modules diff --git a/.cursor/rules/cds-web.mdc b/.cursor/rules/cds-web.mdc deleted file mode 100644 index 4016bf640e..0000000000 --- a/.cursor/rules/cds-web.mdc +++ /dev/null @@ -1,148 +0,0 @@ ---- -description: USE THIS when asked to work on a new or existing (WEB) CDS React component in packages/web -alwaysApply: false ---- - - - -# CDS Web Package Guidelines - -## CSS with Linaria - -Use Linaria for zero-runtime CSS. **Always use CDS theme CSS variables** for colors, spacing, typography, and other design tokens. -Reference packages/web/src/core/theme.ts:53-119 for the CSS variable naming pattern. - -```tsx -import { css, cx } from '@linaria/core'; - -const containerCss = css` - /* Spacing tokens */ - padding: var(--space-2); - gap: var(--space-1); - - /* Color tokens */ - background: var(--color-bgPrimary); - color: var(--color-fgPrimary); - border: 1px solid var(--color-line); - - /* Border radius tokens */ - border-radius: var(--borderRadius-400); - - /* Typography tokens */ - font-size: var(--fontSize-body); - - &:hover { - background: var(--color-bgPrimaryHover); - } -`; - -// Merge classNames with cx utility **in CORRECT ORDER** -
; -``` - -**IMPORTANT:** Using CSS variables ensures components respond correctly to theme changes (light/dark mode, brand themes). - -### CSS Variable Naming - -Design tokens from `packages/common/src/core/theme.ts` map to CSS variables: - -- Colors: `--color-{tokenName}` (e.g., `--color-bgPrimary`, `--color-fgMuted`) -- Spacing: `--space-{scale}` (e.g., `--space-2` = 16px, `--space-3` = 24px) -- Typography: `--fontSize-{font}`, `--fontWeight-{font}`, `--lineHeight-{font}`, `--fontFamily-{font}` -- Border: `--borderRadius-{size}`, `--borderWidth-{size}` -- Sizing: `--iconSize-{size}`, `--avatarSize-{size}`, `--controlSize-{name}` -- Shadows: `--shadow-{level}` - -### Responsive Breakpoints - -Reference `packages/web/src/styles/media.ts` for breakpoint values: - -- **phone**: 0-767px -- **tablet**: 768-1279px -- **desktop**: 1280px+ - -Use the `Box` component's responsive prop API for responsive values: - -```tsx - -``` - -### Granular classNames - -- Components can expose a `classNames` object prop for granular overrides on child elements within the component. -- Since the keys of the `classNames` object are specific to the component they should never be on the `*BaseProps` type - -### data-attributes - -- Use data attributes for state-based styling: `data-active`, `data-disabled`, `data-variant`, `data-filled` - -## Inline styles - -- Web components should all expose a `style` and `styles` object props for overriding inline styles. -- As styling is a concern of that specific component, the `style` and `styles` props should never be on the `*BaseProps` type. -- Styles should be merged into a single object with a `useMemo` hook and applied in the correct order (deafult styles => `style` prop => `styles[ELEMENT_NAME]` prop). - -**Example:** - -```tsx -type ComponentProps = ComponentBaseProps & { - style?: React.CSSProperties; - styles?: { root?: React.CSSProperties; label?: React.CSSProperties }; -}; -``` - -## Animation - -Use Framer Motion for complex animations: - -```tsx -import { m as motion, AnimatePresence } from 'framer-motion'; - - - {visible && ( - - )} -; -``` - -For simple transitions, prefer CSS transitions in Linaria. - -## Accessibility - -Use ARIA attributes: - -```tsx -
-
-``` - -- Implement keyboard navigation (Arrow keys, Home, End) for interactive components. -- Provide descriptive labels for all interactive elements -- Associate form inputs with helper text using aria-describedby -- Use semantic HTML elements whenever possible -- Follow WCAG 2.1 AA standards for color contrast -- Support screen readers by providing descriptive labels and instructions - -## Web-Specific Props - -- `className?: string` - CSS class always applied to root element -- `style?: React.CSSProperties` - inline styles always applied to root elemenet -- Polymorphic `as` prop for element type (where applicable e.g. see Box) - -## Reference Components - -- **Carousel**: compound components, imperative handle, context pattern -- **Select** (alpha/): generics, controlled/uncontrolled -- **RollingNumber**: animation config, measurement patterns diff --git a/.cursor/rules/code-connect.mdc b/.cursor/rules/code-connect.mdc deleted file mode 100644 index 2eefc76ddc..0000000000 --- a/.cursor/rules/code-connect.mdc +++ /dev/null @@ -1,206 +0,0 @@ ---- -globs: *.figma.tsx -alwaysApply: false ---- - -# Guidelines for writing Figma Code Connect mappings - -## Property Mapping Guidelines - -- figma.enum() - For dropdowns/variants - -```tsx -variant: figma.enum('variant', { - 'Figma Display Name': 'codeValue', - 'Primary': 'primary', - 'Secondary': 'secondary', -}), -``` - -- figma.boolean() - For boolean properties - -```tsx -disabled: figma.boolean('disabled'), -loading: figma.boolean('loading'), -``` - -- figma.boolean() for conditional properties\ - -In some cases, you only want to render a certain prop if it matches some value in Figma. -You can do this either by passing a partial mapping object, or setting the value to undefined. - -```tsx -// Don't render the prop if 'Has label' in figma is `false` -figma.boolean('has label', { - true: figma.string('label'), - false: undefined, -}); -``` - -- figma.string() - For text properties (component properties with text values) - -```tsx -label: figma.string('label'), -``` - -- figma.textContent() - For text layer content (when text is stored in a layer, not a property) - -A common pattern in Figma design systems is to override text content directly on instances rather than using component properties. Use `figma.textContent()` to extract the actual text from a named text layer. - -```tsx -// Extract text from a layer named 'Title' -title: figma.textContent('Title'), -``` - -**Key difference**: Use `figma.string()` when text is controlled by a Figma component property. Use `figma.textContent()` when text lives as content in a text layer that designers override directly. - -- figma.instance() - For instance-swap properties (component slots) - -Use -figma.instance() returns the JSX from another figma.connect() call that you can use in the example. -This is useful for components that accept a node of another React component as a prop. - -In the example below, Button accepts an instance of Icon as the icon prop. -We would need to have another call to figma.connect() for the `Icon` component somewhere in our code connect setup. - -```tsx -figma.connect(Button, 'https://...', { - props: { - icon: figma.instance('Icon'), - }, - example: ({ icon }) => { - return ; - }, -}); -``` - -- figma.children() - For child instances by layer name - -Use this property mapping when your React component accepts children. `figma.children` maps a Figma layer name to the `children` prop. - -```tsx -// Maps child instances that aren't bound to an instance-swap prop -icon: figma.children('IconLayer'), -``` - -- figma.nestedProps() - For accessing properties from child component layers - -```tsx -// Access properties from a nested instance layer named 'Avatar' -avatar: figma.nestedProps('Avatar', { - size: figma.enum('size', { ... }), - src: figma.string('src'), -}), -// In example: use avatar.size, avatar.src -``` - -## Understanding Nested Properties (Important) - -In Figma's properties panel, you may see properties with the `↳` symbol (e.g., `↳ subtitle`). This indicates the property is **exposed from a child layer**, not defined directly on the parent component. - -**Why this matters:** The Code Connect validation run during `figma connect publish` has limited coverage. It only validates these prop kinds: - -- `figma.boolean()`, `figma.enum()`, `figma.string()` - validates the property name exists -- `figma.children()` - validates the layer name exists - -These prop kinds are **NOT validated** at all: - -- `figma.nestedProps()` - layer name and inner property mappings are not checked -- `figma.instance()` - layer/instance name is not checked -- `figma.textContent()` - layer name is not checked - -Additionally, validation does **not recurse** into boolean `true`/`false` branch values. - -This can result in technically incorrect mappings being published to Figma withoug being caught during validation. - -**Incorrect approach** (will pass validation but fail at runtime): - -```tsx -// ❌ Wrong: 'subtitle' should be a nested property, not a direct component property -subtitle: figma.boolean('show subtitle', { - true: figma.string('subtitle'), - false: undefined, -}), -``` - -**Correct approach using figma.nestedProps():** - -```tsx -// ✅ Correct: Use nestedProps to access properties from the child layer -subtitle: figma.boolean('show subtitle', { - true: figma.nestedProps('subtitle', { - text: figma.string('subtitle'), - }), - false: { text: undefined }, -}), -// In example: use subtitle.text -``` - -**Tip:** When in doubt about whether a property is direct or nested, check if it has the `↳` symbol in Figma's properties panel. If it does, you likely need `figma.nestedProps()` or `figma.textContent()`. - -## Multi-Variant Support - -For components with multiple variants in Figma, create separate figma.connect() calls: - -```tsx -// Default variant -figma.connect(ComponentName, 'figma-url', { - /* props */ -}); - -// Specific variant -figma.connect(ComponentName, 'figma-url', { - variant: { 'show suffix': true }, - props: { - /* variant-specific props */ - }, - example: (props) => , -}); -``` - -## Common Mapping Mistakes - -### 1. Text Content vs Text Properties - -**Problem**: Using `figma.string()` when the text is a layer name, not a property. - -```tsx -// ❌ Wrong: 'value' is a text layer name, not a property -children: figma.string('value'); - -// ✅ Correct: Use textContent for text layers -children: figma.textContent('value'); -``` - -### 2. Property Values vs Properties - -**Problem**: Treating an enum property value as a separate property. - -```tsx -// ❌ Wrong: 'disabled' might be a value of 'state', not its own property -disabled: figma.boolean('disabled'); - -// ✅ Correct: Map from the state enum -disabled: figma.enum('state', { - disabled: true, - default: false, - focused: false, - hovered: false, - pressed: false, -}); -``` - -### 3. Property Name Formatting - -**Problem**: Property names in Figma often have spaces and must match exactly. - -```tsx -// ❌ Wrong: camelCase doesn't match Figma property name -showStart: figma.boolean('showStart'); - -// ✅ Correct: Use exact Figma property name with spaces -start: figma.boolean('show start', { - true: figma.instance('start'), - false: undefined, -}); -``` diff --git a/.cursor/rules/code-review.mdc b/.cursor/rules/code-review.mdc deleted file mode 100644 index e37dcdbd2c..0000000000 --- a/.cursor/rules/code-review.mdc +++ /dev/null @@ -1,14 +0,0 @@ ---- -description: Use these rules when asked to review CDS React component code -alwaysApply: false ---- - -# CDS React Component Code Reviews - -Check for the following: - -- Structure: does the component's file follow the correct structure of imports, styles, types, and component? -- Type safety: were any changes made backwards compatible? Were there any breaking changes? Are props clearly typed and following best practices? Are there any props that are unused / missing? -- Styling: does the component rely on css variables and theme tokens where possible? Are there any missing className or styles props? -- Accessibility: does the component follow best practices for accessibility for the platform? -- Tests & stories: is the component used in a meaningful way by Jest tests and have story examples? diff --git a/.cursor/rules/react-component-development.mdc b/.cursor/rules/react-component-development.mdc deleted file mode 100644 index 3269f6db33..0000000000 --- a/.cursor/rules/react-component-development.mdc +++ /dev/null @@ -1,175 +0,0 @@ ---- -description: USE THIS whenever you are asked to work on CDS React components -alwaysApply: false ---- - -# React Component Development Rules - -## Component Development Workflow - -1. Research similar reference components and given requirements/description -2. Optionally, ask clarifying questions about the component's requirements & behavior -3. Implement the component with unit tests & stories on web first before proceeding to mobile if both platforms were requested. -4. Never write figma code connect files unless explicitly instructed to do so. -5. Follow remaining general coding standards and guidelines you've been given. - -## Reference Components - -These high quality components demonstrate proper use of patterns/conventions: - -- **Select** (alpha/): generics, controlled/uncontrolled, compound architecture -- **Stepper**: props-based defaults, metadata generics, compound components -- **Carousel** (web): compound components, imperative handle, context + hook -- **RollingNumber**: animation config extraction, measurement patterns -- **SlideButton** (mobile): gesture handling, spring animations, accessibility actions - -## Organization - -### File Structure - -Every main CDS component should live within its own folder: - -``` -ComponentName/ -├── ComponentName.tsx # Main component file -├── SubComponent.tsx # Supporting component (if needed) -├── index.ts # Re-exports for public API -├── __stories__/ # Storybook stories -│ └── ComponentName.stories.tsx -├── __tests__/ # Unit tests -│ └── ComponentName.test.tsx -├── __figma__/ # Figma Code Connect files -│ └── ComponentName.figma.tsx -``` - -### Component Categories - -Organize components into category folders: - -- `buttons` - Button, IconButton, SlideButton -- `controls` - TextInput, Select, Checkbox, Radio, Switch -- `cards` - Card, DataCard, ContentCard -- `overlays` - Modal, Toast, Alert, Drawer -- `layout` - Box, Stack, Divider -- `typography` - Text, Heading -- `icons` - Icon -- `navigation` - Tabs, Breadcrumb - -## Component Conventions - -- **Memoize**: Always memoize components with React's memo HOC -- **refs**: All components should accept a ref via React's forwardRef pattern -- **Props documentation**: Every prop must have JSDoc comments with `@default` tags where applicable -- **Type exports**: Export both a `*BaseProps` and `*Props` type (e.g., `ButtonBaseProps`, `ButtonProps`) -- **Style overrides**: All components MUST support a way to override styles (varries by web/mobile platform) -- **testID**: Support `testID` prop on root element for every component -- **Use design tokens**: Reference packages/common/src/core/theme.ts:57-331 as the definitive source for available token names -- **Padding over margin**: Use padding in combination with flex gap to achieve spacing instead of margin. - -## Design Token System - -### Token Categories - -Design tokens are defined in `packages/common/src/core/theme.ts`: - -- **Color**: fg, fgMuted, fgInverse, fgPrimary, bgPrimary, bgSecondary, bgNegative, bgPositive, etc. -- **Space**: 0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10 (8px base unit) -- **IconSize**: xs (12px), s (16px), m (24px), l (32px) -- **AvatarSize**: s, m, l, xl, xxl, xxxl -- **BorderWidth**: 0, 100, 200, 300, 400, 500 -- **BorderRadius**: 0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000 -- **Font**: display1-3, title1-4, headline, body, label1-2, caption, legal -- **Shadow**: elevation1, elevation2 - -### Semantic Color System - -Colors use a spectrum system with hue + step notation: - -- **Hues**: blue, green, orange, yellow, gray, indigo, pink, purple, red, teal, chartreuse -- **Steps**: 0, 5, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100 -- **Example**: blue60 = Coinbase brand blue (#0052FF) - -Semantic tokens map to spectrum colors and adapt to light/dark mode: - -- `fgPrimary`: blue60 (light) / blue70 (dark) -- `bgPrimary`: blue60 (light) / blue70 (dark) -- `bgNegative`: red60 (both modes) -- `bgPositive`: green60 (both modes) - -### Space Scale - -```typescript -space: { - '0': 0, // 0px - '0.25': 2, // 2px - '0.5': 4, // 4px - '0.75': 6, // 6px - '1': 8, // 8px - base unit - '1.5': 12, // 12px - '2': 16, // 16px - '3': 24, // 24px - '4': 32, // 32px - '5': 40, // 40px - // ... up to 10 (80px) -} -``` - -## Component Patterns - -### Compound Components - -- Break components down into discrete subcomponents (i.e. "slots") -- Use this pattern for complex components with clear, distinct parts -- Accept optional subcomponent props with sensible defaults using `*Component`/`Default*` naming: - ```ts - NavigationComponent = DefaultCarouselNavigation, - PaginationComponent = DefaultCarouselPagination, - ``` -- The names of classNames/styles keys must line up with the name of the subcomponents (e.g. `classNames.pagination`, `styles.pagination`). -- Examples: Stepper, Carousel, Select (alpha) - -**Benefits:** - -- Complete customization without forking -- Sensible defaults for common use case -- Exported subcomponents for consumers to customize/wrap themselves - -### Context + Hook Pattern - -- Pair contexts with `use*Context()` hooks that throw descriptive errors on misuse: - ```ts - export const useCarouselContext = () => { - const context = useContext(CarouselContext); - if (!context) throw new Error('useCarouselContext must be used within Carousel'); - return context; - }; - ``` - -### Controlled/Uncontrolled Components - -- Support both patterns for input components; validate and throw if consumer mixes them (e.g., provides `value` but not `onChange`) -- Use internal state with prop override: `const open = openProp ?? openInternal;` - -### Generics for Type Safety - -- Use generics for components with dynamic value types: - ```ts - type SelectComponent = ( - props: SelectProps, - ) => React.ReactElement; - ``` -- Examples: Select (alpha), Stepper - -### BaseProps & Props - -- Component modules encapsulate two prop Types: `*BaseProps` (platform-agnostic) and `*Props` (extends BaseProps with platform and component specific properties like `className`, `classNames`, `styles`, etc.) -- Reuse other components' Types via utilities: `Pick` being preferred then secondarily `Omit`/`Exclude` -- Compose prop types using Typescript intersections (`&`) in this order: (1) full types (2) Picks (3) Omits (4) other type literal(s): - ```ts - type MyComponentProps = BoxBaseProps & - Pick & - Omit & { - propA: string; - propB: number; - }; - ``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 63bac74b0d..36b061af2e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,10 +1,2 @@ #~ {"path":"*","teams":{"@coinbase/ui-systems-eng-team":{"required_reviews":1}}} * @coinbase/ui-systems-eng-team - -# Illustrations owners -#~ {"path":"packages/illustrations/manifest.json","teams":{"@coinbase/ui-systems-illustrators":{"required_reviews":1}}} -packages/illustrations/manifest.json @coinbase/ui-systems-illustrators - -# Icons owners -#~ {"path":"packages/icons/manifest.json","teams":{"@coinbase/ui-systems-illustrators":{"required_reviews":1}}} -packages/icons/manifest.json @coinbase/ui-systems-illustrators diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 77a031f1e9..41004653b7 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,4 +1,4 @@ - + ## What changed? Why? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4546361b1e..fd1b790daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,13 @@ name: CI on: workflow_dispatch: # Allow manual triggering to refresh cached baseline results push: - branches: master + branches: + - master + - 'cds-v[0-9]*' pull_request: - branches: master + branches: + - master + - 'cds-v[0-9]*' concurrency: group: CI-${{github.ref_name}}-${{github.event_name == 'pull_request' && github.event.pull_request.number || github.sha}} @@ -89,6 +93,26 @@ jobs: - name: Test run: yarn nx affected --target=test --base=$NX_BASE --head=$NX_HEAD + test-storybook: + name: Storybook A11y Tests + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 100 # TODO: This needs to include the merge-base + - uses: ./.github/actions/setup + - name: Install Playwright + run: | + cd apps/storybook + yarn playwright install + cd ../../ + - name: Test Storybook + run: yarn nx run storybook:test-a11y + typecheck: name: Typecheck runs-on: ubuntu-latest @@ -122,6 +146,8 @@ jobs: depcheck: name: Depcheck runs-on: ubuntu-latest + # Only run on master pushes (to cache baseline) or PRs targeting master (to compare) + if: github.ref_name == 'master' || (github.event_name == 'pull_request' && github.base_ref == 'master') steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -217,6 +243,8 @@ jobs: bundle-stats: name: Bundle Stats runs-on: ubuntu-latest + # Only run on master pushes (to cache baseline) or PRs targeting master (to compare) + if: github.ref_name == 'master' || (github.event_name == 'pull_request' && github.base_ref == 'master') steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 diff --git a/.github/workflows/debug-workflow.yml b/.github/workflows/debug-workflow.yml new file mode 100644 index 0000000000..6c3c03c6f6 --- /dev/null +++ b/.github/workflows/debug-workflow.yml @@ -0,0 +1,32 @@ +# HOW TO USE THIS WORKFLOW FOR BRANCH TESTING +# +# GitHub only shows workflow_dispatch workflows that exist on the default branch +# (master), but when you trigger them manually you can choose which branch to +# run against — GitHub will use the workflow file from that branch. +# +# Steps to test a workflow on your feature branch: +# 1. In your branch, replace the contents of this file with the workflow you +# want to test (keep the `workflow_dispatch` trigger so it stays triggerable). +# 2. Push your branch. +# 3. Go to Actions → "Debug Workflow (replace me in your branch)" → "Run workflow". +# 4. Select your branch from the dropdown and click "Run workflow". +# +# The contents of this file on master are intentionally left as a no-op stub. +# Do NOT merge your debug changes back to master. + +name: Debug Workflow (replace me in your branch) + +on: + # Manual trigger for testing + workflow_dispatch: + +jobs: + test-local: + runs-on: [small, default-config] + steps: + - uses: actions/checkout@v4 + + # Test the published action + # - name: New CDS Action + # uses: [fill this in on new branch] + # with: [fill this in on new branch] diff --git a/.github/workflows/figma.yml b/.github/workflows/figma.yml index a16c301f9b..140bb8ba87 100644 --- a/.github/workflows/figma.yml +++ b/.github/workflows/figma.yml @@ -15,7 +15,7 @@ jobs: validate-code-connect-web: name: Validate Code Connect (Web) runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -23,6 +23,20 @@ jobs: egress-policy: audit - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: ./.github/actions/setup + - name: Check Figma Token + env: + FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} + run: | + if [ -z "$FIGMA_ACCESS_TOKEN" ]; then + echo "Error: FIGMA_ACCESS_TOKEN environment variable is not set or is empty." + echo "" + echo "To fix this:" + echo " 1. Ensure FIGMA_ACCESS_TOKEN is set in your GitHub repository secrets" + echo " 2. Verify the secret is being passed to the workflow step via the env block" + echo "" + exit 1 + fi + echo "✓ FIGMA_ACCESS_TOKEN is set" - name: Validate Code Connect (dry-run) env: FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} @@ -32,7 +46,7 @@ jobs: validate-code-connect-mobile: name: Validate Code Connect (Mobile) runs-on: ubuntu-latest - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -40,6 +54,20 @@ jobs: egress-policy: audit - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: ./.github/actions/setup + - name: Check Figma Token + env: + FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} + run: | + if [ -z "$FIGMA_ACCESS_TOKEN" ]; then + echo "Error: FIGMA_ACCESS_TOKEN environment variable is not set or is empty." + echo "" + echo "To fix this:" + echo " 1. Ensure FIGMA_ACCESS_TOKEN is set in your GitHub repository secrets" + echo " 2. Verify the secret is being passed to the workflow step via the env block" + echo "" + exit 1 + fi + echo "✓ FIGMA_ACCESS_TOKEN is set" - name: Validate Code Connect (dry-run) env: FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} @@ -59,6 +87,20 @@ jobs: with: fetch-depth: 100 # TODO: This needs to include the merge-base - uses: ./.github/actions/setup + - name: Check Figma Token + env: + FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} + run: | + if [ -z "$FIGMA_ACCESS_TOKEN" ]; then + echo "Error: FIGMA_ACCESS_TOKEN environment variable is not set or is empty." + echo "" + echo "To fix this:" + echo " 1. Ensure FIGMA_ACCESS_TOKEN is set in your GitHub repository secrets" + echo " 2. Verify the secret is being passed to the workflow step via the env block" + echo "" + exit 1 + fi + echo "✓ FIGMA_ACCESS_TOKEN is set" - name: Publish Code Connect env: FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} @@ -67,7 +109,14 @@ jobs: audit-figma-integrations: name: Audit Figma Integrations runs-on: ubuntu-latest - if: github.ref_name == 'master' && github.event_name == 'push' + if: github.event_name == 'workflow_dispatch' || (github.ref_name == 'master' && github.event_name == 'push') + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 @@ -80,4 +129,15 @@ jobs: - name: Run Audit env: FIGMA_ACCESS_TOKEN: ${{ secrets.FIGMA_ACCESS_TOKEN }} - run: yarn audit-figma-integration + run: yarn audit-figma-integration --html + - name: Prepare Pages directory + run: find temp/ -name "figma-audit-*.html" -exec cp {} temp/index.html \; + - name: Setup Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + - name: Upload audit report + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: temp/ + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/guard-debug-workflow.yml b/.github/workflows/guard-debug-workflow.yml new file mode 100644 index 0000000000..55bc04babc --- /dev/null +++ b/.github/workflows/guard-debug-workflow.yml @@ -0,0 +1,41 @@ +# HOW TO USE THIS WORKFLOW FOR BRANCH TESTING +# +# GitHub only shows workflow_dispatch workflows that exist on the default branch +# (master), but when you trigger them manually you can choose which branch to +# run against — GitHub will use the workflow file from that branch. +# +# Steps to test a workflow on your feature branch: +# 1. In your branch, replace the contents of this file with the workflow you +# want to test (keep the `workflow_dispatch` trigger so it stays triggerable). +# 2. Push your branch. +# 3. Go to Actions → "Debug Workflow (replace me in your branch)" → "Run workflow". +# 4. Select your branch from the dropdown and click "Run workflow". +# +# The contents of this file on master are intentionally left as a no-op stub. +# Do NOT merge your debug changes back to master. + +name: Debug Workflow (replace me in your branch) + +on: + # Manual trigger for testing + workflow_dispatch: + +permissions: + contents: read + +jobs: + test-local: + runs-on: [small, default-config] + steps: + - 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 + # uses: [fill this in on new branch] + # with: [fill this in on new branch] 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..d5c492ebd4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,5 @@ --- +# Publishes @coinbase/* packages to public npm registry (https://registry.npmjs.org). name: Publish Packages to NPM on: @@ -24,6 +25,9 @@ on: paths: - 'packages/*/package.json' +permissions: + contents: read + jobs: # Determine which packages to build/publish detect-changes: @@ -192,6 +196,12 @@ jobs: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: ./.github/actions/setup + - name: Update npm + run: | + # Pinned: Node 22.22.2's bundled npm breaks `npm i -g npm@latest` (promise-retry). + # See https://github.com/npm/cli/issues/9151 and https://github.com/nodejs/node/issues/62425 + npm install -g npm@11.11.0 + - name: Check if version exists on npm id: version-check run: | @@ -259,7 +269,9 @@ jobs: - name: Update npm run: | - npm install -g npm@latest + # Pinned: Node 22.22.2's bundled npm breaks `npm i -g npm@latest` (promise-retry). + # See https://github.com/npm/cli/issues/9151 and https://github.com/nodejs/node/issues/62425 + npm install -g npm@11.11.0 - name: Check if version exists on npm id: version-check @@ -379,3 +391,28 @@ jobs: else echo "❌ Live publish: **FAILED**" >> $GITHUB_STEP_SUMMARY fi + + notify-slack: + name: Notify Slack + runs-on: ubuntu-latest + needs: [detect-changes, dry-run-publish, publish] + if: >- + always() && + vars.SLACK_PUBLISH_ALERTS_ENABLED == 'true' && + (github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && + github.event.inputs.dry-run != 'true')) && + (needs.detect-changes.result == 'failure' || + needs.publish.result == 'failure') + steps: + - name: Post failure to Slack + uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844 # v1.23.0 + with: + payload: | + { + "username": "CDS Publish", + "text": ":red_circle: Failed to publish NPM packages, <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|see details here>.\ncc @design-systems-eng-oncall" + } + env: + # Accessible from https://api.slack.com/apps + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/slack-pull-request.yml b/.github/workflows/slack-pull-request.yml new file mode 100644 index 0000000000..708ca599f1 --- /dev/null +++ b/.github/workflows/slack-pull-request.yml @@ -0,0 +1,54 @@ +# Notifies Slack when a PR is ready for review or merged (any base branch). +# Gated by org variable SLACK_PR_NOTIFICATIONS_ENABLED; skips pull requests from forks. +name: Slack Pull Request Notifications + +on: + pull_request: + types: [ready_for_review, closed] + +permissions: + contents: read + +jobs: + notify-slack: + name: Notify Slack + runs-on: ubuntu-latest + if: >- + vars.SLACK_PR_NOTIFICATIONS_ENABLED == 'true' && + github.event.pull_request.head.repo.full_name == github.repository && + (github.event.action == 'ready_for_review' || + (github.event.action == 'closed' && github.event.pull_request.merged == true)) + steps: + - name: Build Slack payload + env: + EVENT_ACTION: ${{ github.event.action }} + PR_MERGED: ${{ github.event.pull_request.merged }} + PR_NUM: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_URL: ${{ github.event.pull_request.html_url }} + run: | + set -euo pipefail + jq -n \ + --arg username "CDS PR" \ + --arg action "${EVENT_ACTION}" \ + --arg merged "${PR_MERGED}" \ + --arg num "${PR_NUM}" \ + --arg title "${PR_TITLE}" \ + --arg url "${PR_URL}" \ + ' + (if $action == "ready_for_review" then + ":eyes: *PR #" + $num + " ready for review:* " + $title + "\n<" + $url + "|View PR>" + elif $action == "closed" and ($merged | ascii_downcase) == "true" then + ":white_check_mark: *PR #" + $num + " merged:* " + $title + "\n<" + $url + "|View PR>" + else + error("unexpected pull_request event for Slack job") + end) as $text | + {username: $username, text: $text} + ' >"${GITHUB_WORKSPACE}/slack-pr-payload.json" + + - name: Post to Slack + uses: slackapi/slack-github-action@007b2c3c751a190b6f0f040e47ed024deaa72844 # v1.23.0 + with: + payload-file-path: slack-pr-payload.json + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/.github/workflows/visreg-mobile.yml b/.github/workflows/visreg-mobile.yml new file mode 100644 index 0000000000..fcc1e78de0 --- /dev/null +++ b/.github/workflows/visreg-mobile.yml @@ -0,0 +1,313 @@ +name: Mobile Visreg + +on: + push: + branches: [master] + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: [master] + workflow_dispatch: + inputs: + branch: + description: 'Percy branch name override (defaults to current git branch)' + required: false + default: '' + +concurrency: + group: Visreg-Mobile-${{ github.ref_name }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + actions: read + pull-requests: write + +env: + CI: true + +jobs: + check: + name: Check if visreg should run + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.evaluate.outputs.should_run }} + content_hash: ${{ steps.hash.outputs.hash }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 100 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .nvmrc + + - name: Fetch master for merge-base + if: github.event_name == 'pull_request' + run: git fetch -f --no-tags origin master:master --depth=100 + + - name: Compute visreg content hash + id: hash + if: github.event_name == 'pull_request' + run: | + HASH=$(git ls-tree -r HEAD -- packages/common packages/mobile packages/mobile-visualization | sha256sum | cut -d' ' -f1) + echo "hash=$HASH" >> "$GITHUB_OUTPUT" + + - name: Check visreg result cache + id: cache-check + if: github.event_name == 'pull_request' + uses: actions/cache/restore@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .visreg-cache-marker + key: mobile-visreg-v1-${{ steps.hash.outputs.hash }} + lookup-only: true + + - name: Evaluate should run + id: evaluate + run: | + if [[ "${{ github.event_name }}" != "pull_request" ]]; then + echo "Non-PR event, always running visreg" + echo "should_run=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ contains(github.event.pull_request.labels.*.name, 'visreg-mobile') }}" == "true" ]]; then + echo "visreg-mobile label present, running visreg" + echo "should_run=true" >> "$GITHUB_OUTPUT" + elif [[ "${{ steps.cache-check.outputs.cache-hit }}" == "true" ]]; then + echo "Cache hit - visreg already passed with these exact files, skipping" + echo "should_run=false" >> "$GITHUB_OUTPUT" + elif node packages/mobile-visreg/scripts/shouldRunVisreg.mjs; then + echo "Relevant changes detected, running visreg" + echo "should_run=true" >> "$GITHUB_OUTPUT" + else + echo "No relevant changes, skipping visreg" + echo "should_run=false" >> "$GITHUB_OUTPUT" + fi + + ios: + name: Visreg iOS + needs: [check] + if: needs.check.outputs.should_run == 'true' + runs-on: macos-latest + environment: production + outputs: + percy_url: ${{ steps.percy-upload.outputs.percy_url }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + fetch-depth: 1 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + - uses: ./.github/actions/setup + + - name: Set Percy branch + run: | + BRANCH_INPUT="${{ inputs.branch }}" + if [[ -n "$BRANCH_INPUT" ]]; then + echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" + elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" + else + echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" + fi + + - name: Install Maestro + run: node packages/mobile-visreg/src/setup.mjs + + - name: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + - name: Prepare iOS app (extract prebuild + patch JS bundle) + run: yarn nx run mobile-app:patch-bundle-ios + + - name: Boot iOS simulator + run: | + STATE=$(xcrun simctl list devices available --json | jq -r '.devices[] | .[] | select(.name == "iPhone 16") | .state' | head -n 1) + if [ "$STATE" != "Booted" ]; then + xcrun simctl boot "iPhone 16" + fi + xcrun simctl bootstatus booted + sleep 30 + + - name: Install app on simulator + run: xcrun simctl install booted apps/mobile-app/prebuilds/ios-release-hermes.app + + - name: Capture screenshots + run: yarn nx run mobile-visreg:ios + env: + # Give the XCUITest driver extra time to attach on cold CI runners. + MAESTRO_DRIVER_STARTUP_TIMEOUT: 180000 + + - name: Upload Maestro test report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: maestro-report-ios + path: | + packages/mobile-visreg/maestro-report.html + packages/mobile-visreg/maestro-test-output/ + if-no-files-found: ignore + + - name: Upload to Percy + id: percy-upload + if: always() + run: | + OUTPUT=$(yarn nx run mobile-visreg:upload 2>&1) + EXIT_CODE=$? + echo "$OUTPUT" + PERCY_URL=$(echo "$OUTPUT" | grep -oE 'https://percy\.io[^[:space:]]+' | head -1) + echo "percy_url=$PERCY_URL" >> "$GITHUB_OUTPUT" + exit $EXIT_CODE + env: + PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} + PERCY_BRANCH: ${{ env.PERCY_BRANCH }} + PERCY_PARALLEL_NONCE: ${{ github.run_id }} + PERCY_PARALLEL_TOTAL: 1 + + - name: Create visreg cache marker + if: success() && github.event_name == 'pull_request' + run: touch .visreg-cache-marker + + - name: Save visreg result cache + if: success() && github.event_name == 'pull_request' + uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: .visreg-cache-marker + key: mobile-visreg-v1-${{ needs.check.outputs.content_hash }} + + # android: + # name: Visreg Android + # runs-on: ubuntu-latest + # environment: production + # if: > + # github.event_name == 'push' || + # github.event_name == 'workflow_dispatch' || + # contains(github.event.pull_request.labels.*.name, 'visreg-mobile') + # steps: + # - name: Harden the runner (Audit all outbound calls) + # uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + # with: + # egress-policy: audit + # - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + # with: + # fetch-depth: 1 + # ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + + # - uses: ./.github/actions/setup + + # - name: Set Percy branch + # run: | + # BRANCH_INPUT="${{ inputs.branch }}" + # if [[ -n "$BRANCH_INPUT" ]]; then + # echo "PERCY_BRANCH=$BRANCH_INPUT" >> "$GITHUB_ENV" + # elif [[ "${{ github.event_name }}" == "pull_request" ]]; then + # echo "PERCY_BRANCH=${{ github.head_ref }}" >> "$GITHUB_ENV" + # else + # echo "PERCY_BRANCH=${{ github.ref_name }}" >> "$GITHUB_ENV" + # fi + + # - name: Install Maestro + # run: node packages/mobile-visreg/src/setup.mjs + + # - name: Add Maestro to PATH + # run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH + + # - name: Prepare Android app (extract prebuild + patch JS bundle) + # run: yarn nx run mobile-app:patch-bundle-android + + # # Enable KVM hardware acceleration for the Android emulator. + # # Without this, the emulator runs in software emulation mode, which takes 6+ minutes to boot + # # and is significantly more flaky. Ubuntu GHA runners support KVM but it must be explicitly + # # unlocked via udev rules before use. + # # Ref: https://github.com/marketplace/actions/android-emulator-runner + # - name: Enable KVM + # run: | + # echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + # sudo udevadm control --reload-rules + # sudo udevadm trigger --name-match=kvm + + # - name: Start Android emulator + run visreg + # uses: reactivecircus/android-emulator-runner@v2 + # with: + # api-level: 30 + # arch: x86_64 + # profile: pixel_7_pro + # avd-name: cds_detox + # # -no-window -gpu swiftshader_indirect: headless software rendering (no display available in CI) + # # -no-boot-anim -noaudio -camera-back none: disable unused subsystems to speed up boot + # # -no-snapshot: disable snapshot load and save entirely (clean state every run) + # emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + # disable-animations: true + # script: | + # # Enable Demo Mode to freeze status bar (avoids false Percy diffs) + # adb shell settings put global sysui_demo_allowed 1 + # adb shell am broadcast -a com.android.systemui.demo -e command enter + # adb shell am broadcast -a com.android.systemui.demo -e command clock --es hhmm 1200 + # adb shell am broadcast -a com.android.systemui.demo -e command battery --es level 100 --es plugged false + # adb shell am broadcast -a com.android.systemui.demo -e command network --es mobile show --es level 4 --es wifi show + + # # sys.boot_completed=1 fires before all services are ready; wait for + # # the package manager specifically before attempting install. + # while ! adb shell pm list packages > /dev/null 2>&1; do echo "Waiting for package manager..."; sleep 1; done + + # adb install -r apps/mobile-app/prebuilds/android-release-hermes/binary.apk + + # # Copy Maestro debug artifacts after the run so they can be uploaded after the emulator shuts down + # yarn nx run mobile-visreg:android; cp -r ~/.maestro/tests /tmp/maestro-debug || true + + # - name: Upload Maestro debug artifacts + # if: always() + # uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # with: + # name: maestro-debug-android + # path: /tmp/maestro-debug/ + # if-no-files-found: ignore + + # - name: Upload to Percy + # if: always() + # run: yarn nx run mobile-visreg:visreg-upload + # env: + # PERCY_TOKEN: ${{ secrets.PERCY_TOKEN_MOBILE }} + # PERCY_BRANCH: ${{ env.PERCY_BRANCH }} + # PERCY_PARALLEL_NONCE: ${{ github.run_id }} + # PERCY_PARALLEL_TOTAL: 2 + + comment-pr: + name: Comment Percy Link + needs: [ios] + if: always() && github.event_name == 'pull_request' && needs.ios.result == 'success' && 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 }} + PERCY_URL: ${{ needs.ios.outputs.percy_url }} + run: | + EXISTING=$(gh api \ + repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments \ + --jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("https://percy.io")))] | last | .body // ""') + + if [ "$EXISTING" = "$PERCY_URL" ]; then + echo "Percy URL unchanged, skipping comment." + exit 0 + fi + + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body "$PERCY_URL" \ + --edit-last 2>/dev/null || \ + gh pr comment ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --body "$PERCY_URL" diff --git a/.gitignore b/.gitignore index 941e9c7306..65cc125f92 100644 --- a/.gitignore +++ b/.gitignore @@ -147,8 +147,7 @@ npm-debug.* web-build/ ios android -# percy screenshots. defined in ui-mobile-visreg -artifacts/playground-screenshots + # failed detox test screenshots apps/mobile-app/artifacts apps/mobile-app/prebuilds/android-* @@ -176,6 +175,8 @@ cds-biweekly-update.md missing-files.json barrel-files.md base-props.json +component-peer-dependencies.md +component-peer-dependencies.json # temp directory created when running podium scripts. this gets deleted after the script is done, but for the sake of your sanity, let's not track it **/.podium/ diff --git a/.nxignore b/.nxignore new file mode 100644 index 0000000000..4fadbc2af8 --- /dev/null +++ b/.nxignore @@ -0,0 +1 @@ +.claude/worktrees \ No newline at end of file diff --git a/.percy.js b/.percy.js index 553b54c9af..63e6f67f30 100644 --- a/.percy.js +++ b/.percy.js @@ -3,6 +3,7 @@ module.exports = { storybook: { // Useful for isolating Percy diffs when running from the command line exclude: [ + 'Accessibility', 'Core Components/AccessibilityAnnouncer', 'Interactive/Table', 'Interactive/TabNavigation', @@ -17,7 +18,9 @@ module.exports = { 'Components/SparklineInteractive: Fallback Positive', 'Components/LottieStatusAnimation: Default', 'Components/Loaders/MaterialSpinner: Material Spinner Default', - 'Components/Chart/LineChart: Transitions', + 'Components/Chart/CartesianChart: Transitions', + // Visreg tested in other story, this is for manual testing + 'Components/Chart/CartesianChart: Advanced', ], include: [ // 'Core Components/SparklineInteractive:*', diff --git a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch b/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch index e8f544c190..25dd517fa3 100644 --- a/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch +++ b/.yarn/patches/@expo-cli-npm-0.18.29-f58906fdfb.patch @@ -24,3 +24,29 @@ index 06d6d1e11802ed88388444b10acd83834e079f50..c4409c566377897eacdb78aea4a8fd78 if (bundleId) { return bundleId; } +diff --git a/build/src/utils/npm.js b/build/src/utils/npm.js +index a49faac0ba81f0a00d19e8427434cf013c9b4769..cb9c81ae46f1e2637bbb5410412b578225064818 100644 +--- a/build/src/utils/npm.js ++++ b/build/src/utils/npm.js +@@ -171,7 +171,7 @@ async function extractNpmTarballAsync(stream, props) { + transformStream.on("data", (chunk)=>{ + hash.update(chunk); + }); +- await pipeline(stream, transformStream, _tar().default.extract({ ++ await pipeline(stream, transformStream, (_tar().default ?? _tar()).extract({ + cwd, + filter, + onentry: (0, _createFileTransform.createEntryResolver)(name), +diff --git a/build/src/utils/tar.js b/build/src/utils/tar.js +index 8bf12d812646724089f6cd265b16093e8f518570..9575eaeae41fe05105ab0c2af2ee3d11dce659bf 100644 +--- a/build/src/utils/tar.js ++++ b/build/src/utils/tar.js +@@ -86,7 +86,7 @@ async function extractAsync(input, output) { + debug(`Extracting ${input} to ${output} using JS tar module`); + // tar node module has previously had problems with big files, and seems to + // be slower, so only use it as a backup. +- await _tar().default.extract({ ++ await (_tar().default ?? _tar()).extract({ + file: input, + cwd: output + }); diff --git a/.yarn/patches/depcheck-npm-1.4.7-d4cc813cc3.patch b/.yarn/patches/depcheck-npm-1.4.7-d4cc813cc3.patch new file mode 100644 index 0000000000..2d39f3e755 --- /dev/null +++ b/.yarn/patches/depcheck-npm-1.4.7-d4cc813cc3.patch @@ -0,0 +1,13 @@ +diff --git a/dist/check.js b/dist/check.js +index 2d506595069bb495af834ce2e9985330ef44ecae..01f4f43433bd080850bcc19090c2c3cf31c11d06 100644 +--- a/dist/check.js ++++ b/dist/check.js +@@ -143,7 +143,7 @@ function checkFile({ + parsers + }) { + (0, _debug.default)('depcheck:checkFile')(filename); +- const targets = (0, _lodash.default)(parsers).keys().filter(glob => (0, _minimatch.default)(filename, glob, { ++ const targets = (0, _lodash.default)(parsers).keys().filter(glob => (0, (_minimatch.default && typeof _minimatch.default === "function" ? _minimatch.default : _minimatch.minimatch))(filename, glob, { + dot: true + })).map(key => parsers[key]).flatten().value(); + return targets.map(parser => getDependencies({ diff --git a/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch b/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch new file mode 100644 index 0000000000..d45460a62a --- /dev/null +++ b/.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch @@ -0,0 +1,41 @@ +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/AGENTS.md b/AGENTS.md index 88154a5b5b..8e6e41a6bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Runtime: NodeJS (see .nvmrc for version) - NEVER make commits without being instructed to do so directly - IMPORTANT: After you are done writing code, ALWAYS perform these tasks: - 1. run the unit for the **specific file(s)** you modified + 1. run the unit tests for the **specific file(s)** you modified 2. run typecheck on the **specific package(s)** you modified 3. run the formatter - For complex tasks, ask clarifying questions to the user before executing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2c2e8b1ec..abb3d16855 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ If you encounter a bug, have a feature request, or notice something that could b 1. [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) 2. Follow the [README setup instructions](README.md#setup) +3. [Setup a GPG key](https://docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key) for signing commits ## Making Changes @@ -39,6 +40,32 @@ yarn nx format:write ## Submitting a Pull Request +### From a Forked Repository + +1. Ensure your fork is up to date with the upstream `master` branch +2. Create a new branch from `master` for your changes +3. Push your branch to your fork +4. Open a pull request from your fork's branch to `coinbase/cds:master` +5. Fill out the PR template completely + +For detailed instructions, see [GitHub's guide on creating a pull request from a fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request-from-a-fork). + +### PR Title Convention + +PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/) format: + +``` +(): +``` + +**Examples:** + +- `feat: add new Button variant` +- `fix: resolve ListCell tap handler issue on mobile` +- `chore: update dependencies` + +### PR Requirements + Fill out the [pull request template](https://github.com/coinbase/cds/blob/master/.github/PULL_REQUEST_TEMPLATE.md) completely, including: - What changed and why @@ -49,6 +76,8 @@ Fill out the [pull request template](https://github.com/coinbase/cds/blob/master Before requesting review, update the version and changelog: +Use the [Versioning section in README](README.md#versioning) when choosing whether a change is major, minor, or patch. + ```sh # Update changelog and bump version yarn bump-version diff --git a/README.md b/README.md index 711120a365..802c816002 100644 --- a/README.md +++ b/README.md @@ -52,13 +52,23 @@ yarn nx run docs:dev ### Mobile App ```sh -yarn nx run mobile-app:start +# Launch local debug builds +yarn nx run mobile-app:launch:ios-debug +yarn nx run mobile-app:launch:android-debug ``` ## Contributing We welcome contributions to the Coinbase Design System! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our development process, coding standards, and how to submit pull requests. +## Versioning + +CDS generally follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +We aim to preserve type/public API compatibility across minor and patch releases. +Visual changes are allowed in minor releases. +Review changelog entries and validate UI when upgrading. + ## Security For information about reporting security vulnerabilities, please see our [Security Policy](SECURITY.md). 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 5b18c03441..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 @@ -105,6 +140,45 @@ const getPropsTable = (dirPath, platform, docgenPath) => { } }; +/** + * Get styles table content for components + * @param {string} dirPath - Component directory + * @param {string} platform - 'web' or 'mobile' + * @param {string} docgenPath - Path to docgen output + * @returns {string|null} - Styles table markdown or null + */ +const getStylesTable = (dirPath, platform, docgenPath) => { + const stylesFile = path.join(dirPath, `_${platform}Styles.mdx`); + if (!fs.existsSync(stylesFile)) { + return null; + } + + try { + const stylesContent = fs.readFileSync(stylesFile, 'utf-8'); + const matchResult = stylesContent.match( + new RegExp(`${platform}StylesData from ':docgen/(.*)'`), + ); + + if (!matchResult) { + return null; + } + + const [, dirtyPath] = matchResult[0].split(':docgen/'); + const cleanPath = dirtyPath.slice(0, -1); + const stylesDataFile = path.join(docgenPath, `${cleanPath}.js`); + + if (!fs.existsSync(stylesDataFile)) { + return null; + } + + const stylesData = require(stylesDataFile); + return generateStylesTableMarkdown(stylesData); + } catch (error) { + console.error(`Error reading styles table: ${error.message}`); + return null; + } +}; + /** * Generate props table markdown from docgen data */ @@ -132,6 +206,35 @@ const generatePropsTableMarkdown = (propsData, docgenPath) => { return `${headerRow}${separatorRow}${dataRows}`; }; +/** + * Generate styles table markdown from docgen data + */ +const generateStylesTableMarkdown = (stylesData) => { + const selectors = stylesData?.selectors || []; + + if (selectors.length === 0) { + return null; + } + + const headers = ['Selector', 'Static class name', 'Description']; + const rows = selectors.map((selector) => { + const { selector: name = '', className = '', description = '' } = selector; + const classNameStr = className || '-'; + const descriptionStr = description || '-'; + return [`\`${name}\``, classNameStr ? `\`${classNameStr}\`` : '-', descriptionStr]; + }); + + const escapeString = (str) => + str.replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\n/g, ' '); + const headerRow = `| ${headers.join(' | ')} |\n`; + const separatorRow = `| ${headers.map(() => '---').join(' | ')} |\n`; + const dataRows = rows + .map((row) => `| ${row.map((v) => escapeString(v)).join(' | ')} |\n`) + .join(''); + + return `${headerRow}${separatorRow}${dataRows}`; +}; + /** * Resolve prop types from docgen common types */ @@ -165,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 @@ -177,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`; } @@ -214,6 +329,12 @@ const generateDoc = (platform, docPath, options = {}) => { if (propsTable) { content += `## Props\n\n${propsTable}\n\n`; } + + // Try to find and add styles table (for components with style selectors) + const stylesTable = getStylesTable(docPath, platform, docgenPath); + if (stylesTable) { + content += `## Styles\n\n${stylesTable}\n\n`; + } } return content; diff --git a/apps/docs/ai-doc-generator/generateRoutesContent.cjs b/apps/docs/ai-doc-generator/generateRoutesContent.cjs index 8a4ea850e6..12dd6188e7 100644 --- a/apps/docs/ai-doc-generator/generateRoutesContent.cjs +++ b/apps/docs/ai-doc-generator/generateRoutesContent.cjs @@ -103,6 +103,28 @@ const generateRoutesContent = (platform, siteDir) => { sections.push({ name: 'Hooks', routes: hookRoutes }); } + const guideRoutes = []; + const guides = globSync('guides/*', { cwd: docsDir }); + + for (const guidePath of guides) { + const fullPath = path.join(docsDir, guidePath); + const content = generateDoc(platform, fullPath); + if (!content) continue; + + const name = path.basename(guidePath, '.mdx'); + const metadata = getMetadata(fullPath, platform); + + guideRoutes.push({ + name, + description: metadata?.description, + url: `/llms/${platform}/guides/${name}.txt`, + }); + } + + if (guideRoutes.length > 0) { + sections.push({ name: 'Guides', routes: guideRoutes }); + } + const content = `# CDS Routes ${sections diff --git a/apps/docs/ai-doc-generator/generator.cjs b/apps/docs/ai-doc-generator/generator.cjs index 7959317af1..5f2d34cd87 100644 --- a/apps/docs/ai-doc-generator/generator.cjs +++ b/apps/docs/ai-doc-generator/generator.cjs @@ -129,6 +129,30 @@ const generateDocs = (outputPath) => { } sections.push({ name: 'Hooks', routes: hookRoutes }); + const guidesOutputPath = path.join(platformOutputPath, 'guides'); + fs.mkdirSync(guidesOutputPath, { recursive: true }); + const guideRoutes = []; + + const guides = globSync(`${__dirname}/../docs/guides/*`); + for (const guidePath of guides) { + const content = generateDoc(platform, guidePath); + if (!content) continue; + + const name = path.basename(guidePath, '.mdx'); + const guideFile = `${name}.txt`; + const guideDocPath = path.join(guidesOutputPath, guideFile); + + fs.writeFileSync(guideDocPath, content); + + const metadata = getMetadata(guidePath, platform); + guideRoutes.push({ + name, + description: metadata?.description, + path: guideDocPath, + }); + } + sections.push({ name: 'Guides', routes: guideRoutes }); + generateRoutesDoc(sections, platformOutputPath); } }; diff --git a/apps/docs/ai-doc-generator/resolveDoc.cjs b/apps/docs/ai-doc-generator/resolveDoc.cjs index b65f4e5d40..70daefd291 100644 --- a/apps/docs/ai-doc-generator/resolveDoc.cjs +++ b/apps/docs/ai-doc-generator/resolveDoc.cjs @@ -6,7 +6,7 @@ const { generateDoc } = require('./generateDoc.cjs'); /** * Resolve the file path for a doc and generate its content * @param {string} platform - 'web' or 'mobile' - * @param {string} docType - 'components', 'hooks', or 'getting-started' + * @param {string} docType - 'components', 'hooks', 'getting-started', or 'guides' * @param {string} docName - The name of the doc (e.g., 'Button', 'useTheme', 'installation') * @param {string} siteDir - The Docusaurus site directory * @returns {string|null} - The generated doc content, or null if not found diff --git a/apps/docs/ai-doc-generator/validate.cjs b/apps/docs/ai-doc-generator/validate.cjs index cda820cef1..48096db9d8 100644 --- a/apps/docs/ai-doc-generator/validate.cjs +++ b/apps/docs/ai-doc-generator/validate.cjs @@ -24,6 +24,7 @@ const DOC_SECTIONS = { components: 'docs/components/**/*.mdx', hooks: 'docs/hooks/**/*.mdx', 'getting-started': 'docs/getting-started/**/*.mdx', + guides: 'docs/guides/**/*.mdx', }; /** diff --git a/apps/docs/babel.config.cjs b/apps/docs/babel.config.cjs index 453d0e9668..ff2ed3a4ef 100644 --- a/apps/docs/babel.config.cjs +++ b/apps/docs/babel.config.cjs @@ -1,5 +1,9 @@ const docusaurusPreset = require('@docusaurus/core/lib/babel/preset'); +const isTestEnv = process.env.NODE_ENV === 'test'; + module.exports = { - presets: [docusaurusPreset], + presets: isTestEnv + ? [['@babel/preset-env', { modules: 'commonjs' }], '@babel/preset-typescript'] + : [docusaurusPreset], }; diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js index 6f389d3626..41202d5910 100644 --- a/apps/docs/docgen.config.js +++ b/apps/docs/docgen.config.js @@ -9,8 +9,7 @@ const onProcessDoc = require('./src/utils/onProcessDocgen'); module.exports = { docsDir: path.join(__dirname, './docs/components'), /** - * Determines if plugin should run. If plugin is too slow in development, - * you can either increase watchInterval or set this to false. + * Determines if plugin should run. Set to false to disable docgen entirely. * @default true */ enabled: true, @@ -33,18 +32,13 @@ module.exports = { return name.replace('cds-', ''); }, onProcessDoc, - /** - * How frequently (in minutes) should plugin run after it was last run. - * This is typically triggered via on save of project file. - * @default 5 - */ - watchInterval: 20, /** * Any source files relative to entryPoints above that you want docgen to parse. * Plese add sourceFiles in alphabetical order. */ sourceFiles: [ 'alpha/combobox/Combobox', + 'alpha/data-card/DataCard', 'alpha/select/Select', 'alpha/select-chip/SelectChip', 'alpha/tabbed-chips/TabbedChips', @@ -65,6 +59,8 @@ module.exports = { 'cards/ContentCard/ContentCardBody', 'cards/ContentCard/ContentCardFooter', 'cards/FloatingAssetCard', + 'cards/MediaCard/index', + 'cards/MessagingCard/index', 'cards/NudgeCard', 'cards/UpsellCard', 'carousel/Carousel', @@ -74,7 +70,9 @@ module.exports = { 'cells/ListCell', 'chart/area/AreaChart', 'chart/bar/BarChart', + 'chart/bar/PercentageBarChart', 'chart/CartesianChart', + 'chart/legend/Legend', 'chart/line/LineChart', 'chart/line/ReferenceLine', 'chart/axis/XAxis', @@ -99,6 +97,7 @@ module.exports = { 'controls/Select', 'controls/SelectOption', 'controls/SearchInput', + 'controls/SegmentedControl', 'controls/Switch', 'controls/TextInput', 'dates/Calendar', @@ -155,6 +154,7 @@ module.exports = { 'overlays/modal/FullscreenModalLayout', 'overlays/modal/FullscreenModalHeader', 'overlays/overlay/Overlay', + 'overlays/popover/PopoverPanel', 'overlays/PortalProvider', 'overlays/Toast', 'overlays/tray/Tray', diff --git a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json index 7db05149a3..1ba88778e5 100644 --- a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json +++ b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json @@ -8,5 +8,10 @@ "url": "/components/animation/Lottie" } ], - "dependencies": [] + "dependencies": [ + { + "name": "lottie-react-native", + "version": "^6.7.0" + } + ] } diff --git a/apps/docs/docs/components/cards/ContainedAssetCard/mobileMetadata.json b/apps/docs/docs/components/cards/ContainedAssetCard/mobileMetadata.json index 777ab65973..d88d22375a 100644 --- a/apps/docs/docs/components/cards/ContainedAssetCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/ContainedAssetCard/mobileMetadata.json @@ -1,12 +1,13 @@ { "import": "import { ContainedAssetCard } from '@coinbase/cds-mobile/cards/ContainedAssetCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/ContainedAssetCard.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=10084-2760&t=DIcYU9WAXkBUimkN-0", - "description": "Asset Cards display current and potential future assets, offering a straightforward method to view and manage a customer's holdings. They provide a clear visual and informative overview, simplifying asset management and investment considerations.", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6814&t=EIOPhI0X8y2FmZOa-4", + "description": "Asset Cards display current and potential future assets, offering a straightforward method to view and manage a customer's holdings.", + "warning": "This component is deprecated. Please use MediaCard instead.", "relatedComponents": [ { - "label": "FloatingAssetCard", - "url": "/components/cards/FloatingAssetCard/" + "label": "MediaCard", + "url": "/components/cards/MediaCard/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/cards/ContainedAssetCard/webMetadata.json b/apps/docs/docs/components/cards/ContainedAssetCard/webMetadata.json index fe4ab9da8b..443b9961df 100644 --- a/apps/docs/docs/components/cards/ContainedAssetCard/webMetadata.json +++ b/apps/docs/docs/components/cards/ContainedAssetCard/webMetadata.json @@ -2,12 +2,13 @@ "import": "import { ContainedAssetCard } from '@coinbase/cds-web/cards/ContainedAssetCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/ContainedAssetCard.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-containedassetcard--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=10084-2760&t=DIcYU9WAXkBUimkN-0", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6814&t=EIOPhI0X8y2FmZOa-4", "description": "A card component for displaying and managing asset holdings.", + "warning": "This component is deprecated. Please use MediaCard instead.", "relatedComponents": [ { - "label": "FloatingAssetCard", - "url": "/components/cards/FloatingAssetCard/" + "label": "MediaCard", + "url": "/components/cards/MediaCard/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx index 3bf32c1fab..7d5d1c194d 100644 --- a/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCard/_mobileExamples.mdx @@ -1,81 +1,122 @@ -### Text only +ContentCard is a flexible, composable card component built with `ContentCardHeader`, `ContentCardBody`, and `ContentCardFooter` sub-components. It can display various content layouts including text, media, and interactive elements. -These are our most basic Content Cards. +## Basic Examples + +ContentCard uses sub-components for flexible layout. Combine `ContentCardHeader`, `ContentCardBody`, and `ContentCardFooter` to create your card structure. ```jsx function Example() { return ( - + - } - meta={ - - - ・News・5 hrs - - + thumbnail={assets.eth.imageUrl} + title="CoinDesk" + subtitle="News" + actions={ + + + + } - title="Description" /> - - BTC + + $3,081.01 - - ↗ 5.12% + + ↗ 6.37% } /> + + + + + + + + + ); +} +``` + +## Media Placement + +Use the `mediaPlacement` prop on `ContentCardBody` to control where media is positioned relative to the content. + +```jsx +function Example() { + const exampleMedia = ( + + ); + + return ( + + mediaPlacement: top (default) - - - ・News・5 hrs - -
- } - title="Brian Armstrong" + + + + + mediaPlacement: bottom + + + + + + mediaPlacement: end + + - - BTC - - - ↗ 5.12% - - - } - media={ - - } - mediaPosition="right" + title="Media at end" + description="The media appears to the right of the text content." + media={exampleMedia} + mediaPlacement="end" + /> + + + mediaPlacement: start + + + @@ -83,79 +124,110 @@ function Example() { } ``` -### Rewards Content Card +## With Background -This is an example of Content Cards being used for rewards. +Apply a background color to the card using the `background` prop. When using a background, consider using `variant="tertiary"` on buttons. ```jsx function Example() { - const opIcon = ( - - - - + ); + + return ( + + + + - + + + + + + + + + + + - - - - - - - + + + + + + + + + ); +} +``` + +## Rewards Card Example + +Example showing a rewards-style content card with claim button. + +```jsx +function Example() { return ( - + + title={ + Bitcoin Network Shatters Records With Hashrate Climbing to 464 EH/s } label={ - + BTC - + ↗ 5.12% } media={ - } /> - {opIcon} + - + Reward - - - +$15 ACS + +$15 ACS - + ); } ``` + +**Key points:** + +- Use `accessible={false}` on the Pressable to remove it from the accessibility tree, allowing VoiceOver to focus on child elements individually +- Add a `Button` in the footer that performs the same action as the card press for VoiceOver users + +:::warning Avoid Nested Interactive Elements +When ContentCard is wrapped in an interactive Pressable, avoid placing too many interactive elements inside the card. Each interactive element should have a clear, distinct purpose. If the card has many actions, consider using a non-interactive card layout instead. +::: + +### Color Contrast + +When customizing card backgrounds, ensure sufficient color contrast between text and background colors. WCAG AA requires a minimum contrast ratio of 4.5:1 for normal text. diff --git a/apps/docs/docs/components/cards/ContentCard/_mobileStyles.mdx b/apps/docs/docs/components/cards/ContentCard/_mobileStyles.mdx new file mode 100644 index 0000000000..f92d59b714 --- /dev/null +++ b/apps/docs/docs/components/cards/ContentCard/_mobileStyles.mdx @@ -0,0 +1,16 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileHeaderStylesData from ':docgen/mobile/cards/ContentCard/ContentCardHeader/styles-data'; +import mobileBodyStylesData from ':docgen/mobile/cards/ContentCard/ContentCardBody/styles-data'; + +## ContentCardHeader + +### Selectors + + + +## ContentCardBody + +### Selectors + + diff --git a/apps/docs/docs/components/cards/ContentCard/_webExamples.mdx b/apps/docs/docs/components/cards/ContentCard/_webExamples.mdx index dd1c1d2650..931fb0af22 100644 --- a/apps/docs/docs/components/cards/ContentCard/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCard/_webExamples.mdx @@ -1,81 +1,151 @@ -### Text only +ContentCard is a flexible, composable card component built with `ContentCardHeader`, `ContentCardBody`, and `ContentCardFooter` sub-components. It can display various content layouts including text, media, and interactive elements. -These are our most basic Content Cards. +:::note Semantic HTML +ContentCard and its sub-components render semantic HTML elements by default: + +- `ContentCard` renders as `
` +- `ContentCardHeader` renders as `
` +- `ContentCardFooter` renders as `
` + +You can override these defaults using the `as` prop on each component. +::: + +## Basic Examples + +ContentCard uses sub-components for flexible layout. Combine `ContentCardHeader`, `ContentCardBody`, and `ContentCardFooter` to create your card structure. ```jsx live function Example() { return ( - + - } - meta={ - - - ・News・5 hrs - - + thumbnail={} + title="CoinDesk" + subtitle="News" + actions={ + + + + } - title="Description" /> - - BTC + + $3,081.01 - - ↗ 5.12% + + ↗ 6.37% } /> + + + + + + + + + ); +} +``` + +## Media Placement + +Use the `mediaPlacement` prop on `ContentCardBody` to control where media is positioned relative to the content. + +```jsx live +function Example() { + const exampleMedia = ( + + ); + + return ( + + + mediaPlacement: top (default) + - - ・News・5 hrs - - - } - title="Brian Armstrong" + thumbnail={} + title="CoinDesk" + subtitle="News" /> - - BTC - - - ↗ 5.12% - - - } - media={ - - } - mediaPosition="right" + title="Media at top" + description="The media appears above the text content." + media={exampleMedia} + mediaPlacement="top" + /> + + + + mediaPlacement: bottom + + + } + title="CoinDesk" + subtitle="News" + /> + + + + + mediaPlacement: end + + + } + title="CoinDesk" + subtitle="News" + /> + + + + + mediaPlacement: start + + + } + title="CoinDesk" + subtitle="News" + /> + @@ -83,72 +153,111 @@ function Example() { } ``` -### Rewards Content Card +## With Background -This is an example of Content Cards being used for rewards. +Apply a background color to the card using the `background` prop. When using a background, consider using `variant="tertiary"` on buttons. ```jsx live function Example() { - const opIcon = ( - - - - - - - - - - - - + const exampleMedia = ( + ); + return ( - + + + } + title="CoinDesk" + subtitle="News" + /> + + + + + + + + + + + + } + title="CoinDesk" + subtitle="News" + /> + + + + + + + + + ); +} +``` + +## Rewards Card Example + +Example showing a rewards-style content card with claim button. + +```jsx live +function Example() { + return ( + Bitcoin Network Shatters Records With Hashrate Climbing to 464 EH/s } label={ - + BTC - + ↗ 5.12% } media={ - } /> - + - {opIcon} + Reward @@ -168,88 +277,71 @@ function Example() { } ``` -### Custom +## Accessibility + +### Interactive Cards + +When making ContentCard interactive, wrap it in a `Pressable` component and handle accessibility carefully to avoid nested interactive elements. -Example of a content card with a tweet. +**The Problem**: If you wrap ContentCard in a `Pressable` and also have interactive elements inside (like buttons), the card becomes a clickable container with nested interactive elements. This creates accessibility issues for screen reader users. + +**The Solution**: Use `as="div"` on the Pressable wrapper and add a separate action button inside the card. This allows: + +- Regular users to click anywhere on the card +- Screen reader users to navigate through card content and focus on individual interactive elements +- Keyboard users to tab to the action button ```jsx live -function Example() { - const anotherContentCard = ( - alert('Card clicked - navigating...')} + width="fit-content" > - - } - meta={ - - - @matdryhurst・7 mo - - - } - title="Mat Dryhurst" - /> - - - ); - return ( - - + - - ・News・5 hrs - - - } - title="Description" - end={ - - } + subtitle="News" + thumbnail={} + title="CoinDesk" /> - - BTC - - - ↗ 5.12% - - - } - paddingBottom={3} - paddingStart={5} - > - {anotherContentCard} - - - - - + title="Accessible Interactive Card" + description="Card with both card-level click and internal action button" + /> + + + 2 hours ago + + - + ); } ``` + +**Key points:** + +- Use `as="div"` on the Pressable to avoid rendering as a semantic button +- When using `as="div"`, the Pressable remains keyboard focusable. Set `tabIndex={-1}` to remove it from the tab order if needed +- Call `event.stopPropagation()` at the beginning of the event handler method passed into the `onClick` prop for action buttons. This will prevent two click events from firing if the user directly clicks the action button. + +:::warning Avoid Nested Interactive Elements +When ContentCard is wrapped in an interactive Pressable, avoid placing too many interactive elements inside the card. Each interactive element should have a clear, distinct purpose. If the card has many actions, consider using a non-interactive card layout instead. +::: + +### Color Contrast + +When customizing card backgrounds, ensure sufficient color contrast between text and background colors. Use tools like the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify your color combinations meet WCAG guidelines. diff --git a/apps/docs/docs/components/cards/ContentCard/_webStyles.mdx b/apps/docs/docs/components/cards/ContentCard/_webStyles.mdx new file mode 100644 index 0000000000..1da718a8ee --- /dev/null +++ b/apps/docs/docs/components/cards/ContentCard/_webStyles.mdx @@ -0,0 +1,87 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { + ContentCard, + ContentCardHeader, + ContentCardBody, + ContentCardFooter, +} from '@coinbase/cds-web/cards/ContentCard'; +import { RemoteImage } from '@coinbase/cds-web/media'; +import { IconButton, Button } from '@coinbase/cds-web/buttons'; +import { HStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import webHeaderStylesData from ':docgen/web/cards/ContentCard/ContentCardHeader/styles-data'; +import webBodyStylesData from ':docgen/web/cards/ContentCard/ContentCardBody/styles-data'; + +## ContentCardHeader + +### Explorer + + + {(classNames) => ( + + } + title="CoinDesk" + subtitle="News" + actions={ + + + + + } + /> + + )} + + +### Selectors + + + +## ContentCardBody + +### Explorer + +#### Vertical Layout (default) + + + {(classNames) => ( + + } + /> + + )} + + +#### Horizontal Layout + + + {(classNames) => ( + + } + mediaPlacement="end" + /> + + )} + + +### Selectors + + diff --git a/apps/docs/docs/components/cards/ContentCard/index.mdx b/apps/docs/docs/components/cards/ContentCard/index.mdx index 4d8e60810a..77a03cc03b 100644 --- a/apps/docs/docs/components/cards/ContentCard/index.mdx +++ b/apps/docs/docs/components/cards/ContentCard/index.mdx @@ -15,6 +15,8 @@ import mobilePropsToc from ':docgen/mobile/cards/ContentCard/ContentCard/toc-pro import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; @@ -24,13 +26,17 @@ import mobileMetadata from './mobileMetadata.json'; } - webExamples={} - mobilePropsTable={} mobileExamples={} - webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/cards/ContentCard/mobileMetadata.json b/apps/docs/docs/components/cards/ContentCard/mobileMetadata.json index ef1e35aac5..ade45d341d 100644 --- a/apps/docs/docs/components/cards/ContentCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/ContentCard/mobileMetadata.json @@ -1,8 +1,8 @@ { "import": "import { ContentCard } from '@coinbase/cds-mobile/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/ContentCard/ContentCard.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?m=auto&node-id=99-1230&t=K73gadcWiDv2aYlS-1", - "description": "A flexible card component for displaying content with header, body, and footer sections.", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", + "description": "A flexible, composable card component for displaying rich content with customizable header, body, and footer sections. Use with ContentCardHeader, ContentCardBody, and ContentCardFooter sub-components.", "relatedComponents": [ { "label": "ContentCardHeader", @@ -15,6 +15,14 @@ { "label": "ContentCardFooter", "url": "/components/cards/ContentCardFooter/" + }, + { + "label": "MediaCard", + "url": "/components/cards/MediaCard/" + }, + { + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/cards/ContentCard/webMetadata.json b/apps/docs/docs/components/cards/ContentCard/webMetadata.json index 86260c8f68..2cbbdb7881 100644 --- a/apps/docs/docs/components/cards/ContentCard/webMetadata.json +++ b/apps/docs/docs/components/cards/ContentCard/webMetadata.json @@ -2,7 +2,7 @@ "import": "import { ContentCard } from '@coinbase/cds-web/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/ContentCard/ContentCard.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?m=auto&node-id=14705-22947&t=ggZ1sWMLosBJXLel-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "A flexible card component for displaying content.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardBody/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCardBody/_mobileExamples.mdx index dcdcd44c97..196fbfb9bc 100644 --- a/apps/docs/docs/components/cards/ContentCardBody/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardBody/_mobileExamples.mdx @@ -1,19 +1,122 @@ ### Basic Example ```jsx - - - - BTC - - - ↗ 5.12% - - - } - /> - +function Example() { + const { spectrum } = useTheme(); + + return ( + + + Hashrate Climbing to 464 EH/s + + + BTC + + + ↗ 5.12% + + + + } + /> + + ); +} +``` + +### With Media (Top Placement) + +```jsx +function Example() { + return ( + + + } + mediaPlacement="top" + /> + + ); +} +``` + +### With Media (End Placement - Horizontal) + +```jsx +function Example() { + return ( + + + } + mediaPlacement="end" + /> + + ); +} +``` + +### With Custom Children + +Use the `children` prop to render custom content below the title and description. This is useful when you need to display additional data, charts, or interactive elements that don't fit the standard media/title/description layout. + +```jsx +function Example() { + const { spectrum } = useTheme(); + + return ( + + + + + + Trades + + 24 + + + + Volume + + $12,450 + + + + P&L + + + +$890 + + + + + + ); +} ``` diff --git a/apps/docs/docs/components/cards/ContentCardBody/_webExamples.mdx b/apps/docs/docs/components/cards/ContentCardBody/_webExamples.mdx index 2a446ab075..27a7ca5d9a 100644 --- a/apps/docs/docs/components/cards/ContentCardBody/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardBody/_webExamples.mdx @@ -3,17 +3,88 @@ ```jsx live - - BTC - - - ↗ 5.12% - - + title="Bitcoin Network Shatters Records" + description={ + + Hashrate Climbing to 464 EH/s + + + BTC + + + ↗ 5.12% + + + + } + /> + +``` + +### With Media (Top Placement) + +```jsx live + + } + mediaPlacement="top" /> ``` + +### With Media (End Placement) + +```jsx live + + + } + mediaPlacement="end" + /> + +``` + +### With Custom Children + +Use the `children` prop to render custom content below the title and description. This is useful when you need to display additional data, charts, or interactive elements that don't fit the standard media/title/description layout. + +```jsx live + + + + + + Trades + + 24 + + + + Volume + + $12,450 + + + + P&L + + + +$890 + + + + + +``` diff --git a/apps/docs/docs/components/cards/ContentCardBody/mobileMetadata.json b/apps/docs/docs/components/cards/ContentCardBody/mobileMetadata.json index 0ea7b88158..946b1ebbe7 100644 --- a/apps/docs/docs/components/cards/ContentCardBody/mobileMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardBody/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ContentCardBody } from '@coinbase/cds-mobile/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/ContentCard/ContentCardBody.tsx", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-CDS-Components?type=design&node-id=61%3A589&mode=design&t=VY6cW5sQK2K2giAk-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "ContentCardBody is a subcomponent of ContentCard that provides the main content area of the card.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardBody/webMetadata.json b/apps/docs/docs/components/cards/ContentCardBody/webMetadata.json index 17368d33fd..f00cf67459 100644 --- a/apps/docs/docs/components/cards/ContentCardBody/webMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardBody/webMetadata.json @@ -1,8 +1,8 @@ { "import": "import { ContentCardBody } from '@coinbase/cds-web/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/ContentCard/ContentCardBody.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--default", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-CDS-Components?type=design&node-id=61%3A589&mode=design&t=VY6cW5sQK2K2giAk-1", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--basic", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "A main content area component for ContentCard.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardFooter/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCardFooter/_mobileExamples.mdx index 8169c776cf..5997ea9d3a 100644 --- a/apps/docs/docs/components/cards/ContentCardFooter/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardFooter/_mobileExamples.mdx @@ -1,4 +1,4 @@ -### Basic Example +### With Icon Counter Buttons ```jsx function Example() { @@ -13,3 +13,65 @@ function Example() { ); } ``` + +### With Image Group and Button + +```jsx +function Example() { + return ( + + + + + + + + + + + ); +} +``` + +### With Text and Actions + +```jsx +function Example() { + return ( + + + + Updated 2 hours ago + + + + + + + + ); +} +``` + +### Centered Content + +```jsx +function Example() { + return ( + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/cards/ContentCardFooter/_webExamples.mdx b/apps/docs/docs/components/cards/ContentCardFooter/_webExamples.mdx index ea9c01cb79..156f49c17c 100644 --- a/apps/docs/docs/components/cards/ContentCardFooter/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardFooter/_webExamples.mdx @@ -1,13 +1,79 @@ -### Basic Example +:::note Semantic HTML +ContentCardFooter renders as a `
` element by default. You can override this using the `as` prop. +::: + +### With Icon Counter Buttons + +```jsx live +function Example() { + return ( + + + + + + + + ); +} +``` + +### With Image Group and Button ```jsx live function Example() { return ( - - - + + + + + + + + + ); +} +``` + +### With Text and Actions + +```jsx live +function Example() { + return ( + + + + Updated 2 hours ago + + + + + + + + ); +} +``` + +### Centered Content + +```jsx live +function Example() { + return ( + + + ); diff --git a/apps/docs/docs/components/cards/ContentCardFooter/mobileMetadata.json b/apps/docs/docs/components/cards/ContentCardFooter/mobileMetadata.json index 7e3d0ede1d..beed937ed0 100644 --- a/apps/docs/docs/components/cards/ContentCardFooter/mobileMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardFooter/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ContentCardFooter } from '@coinbase/cds-mobile/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/ContentCard/ContentCardFooter.tsx", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-CDS-Components?type=design&node-id=61%3A589&mode=design&t=VY6cW5sQK2K2giAk-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "ContentCardFooter is a subcomponent of ContentCard that provides the footer section of the card, typically used for actions or additional information.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardFooter/webMetadata.json b/apps/docs/docs/components/cards/ContentCardFooter/webMetadata.json index d1f5ed674a..bf5d08eec7 100644 --- a/apps/docs/docs/components/cards/ContentCardFooter/webMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardFooter/webMetadata.json @@ -1,8 +1,8 @@ { "import": "import { ContentCardFooter } from '@coinbase/cds-web/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/ContentCard/ContentCardFooter.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--default", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-CDS-Components?type=design&node-id=61%3A589&mode=design&t=VY6cW5sQK2K2giAk-1", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--basic", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "A footer component for ContentCard.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardHeader/_mobileExamples.mdx b/apps/docs/docs/components/cards/ContentCardHeader/_mobileExamples.mdx index 621fb4af7e..c2e5a2b101 100644 --- a/apps/docs/docs/components/cards/ContentCardHeader/_mobileExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardHeader/_mobileExamples.mdx @@ -3,21 +3,15 @@ ```jsx function Example() { return ( - + - - ・News・5 hrs - - - } - title="Description" - end={ + thumbnail={} + title="CoinDesk" + subtitle="News・5 hrs" + actions={ @@ -27,3 +21,60 @@ function Example() { ); } ``` + +### With Multiple Actions + +```jsx +function Example() { + return ( + + } + title="Bitcoin News" + subtitle="Markets・2 hrs" + actions={ + + + + + } + /> + + ); +} +``` + +### Without Thumbnail + +```jsx +function Example() { + return ( + + + } + /> + + ); +} +``` + +### Title Only + +```jsx +function Example() { + return ( + + + + ); +} +``` diff --git a/apps/docs/docs/components/cards/ContentCardHeader/_webExamples.mdx b/apps/docs/docs/components/cards/ContentCardHeader/_webExamples.mdx index 1e218e317f..ee3dbb25d2 100644 --- a/apps/docs/docs/components/cards/ContentCardHeader/_webExamples.mdx +++ b/apps/docs/docs/components/cards/ContentCardHeader/_webExamples.mdx @@ -1,23 +1,21 @@ +:::note Semantic HTML +ContentCardHeader renders as a `
` element by default. You can override this using the `as` prop. +::: + ### Basic Example ```jsx live function Example() { return ( - + - - ・News・5 hrs - - - } - title="Description" - end={ + thumbnail={} + title="CoinDesk" + subtitle="News・5 hrs" + actions={ @@ -27,3 +25,60 @@ function Example() { ); } ``` + +### With Multiple Actions + +```jsx live +function Example() { + return ( + + } + title="Bitcoin News" + subtitle="Markets・2 hrs" + actions={ + + + + + } + /> + + ); +} +``` + +### Without Thumbnail + +```jsx live +function Example() { + return ( + + + } + /> + + ); +} +``` + +### Title Only + +```jsx live +function Example() { + return ( + + + + ); +} +``` diff --git a/apps/docs/docs/components/cards/ContentCardHeader/mobileMetadata.json b/apps/docs/docs/components/cards/ContentCardHeader/mobileMetadata.json index cd8cece546..9fcb60f62a 100644 --- a/apps/docs/docs/components/cards/ContentCardHeader/mobileMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardHeader/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ContentCardHeader } from '@coinbase/cds-mobile/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/ContentCard/ContentCardHeader.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?m=auto&node-id=14705-22947&t=0Jp3mDHvq7E1V7wc-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "ContentCardHeader is a subcomponent of ContentCard that provides the header section of the card, typically used for the title and subtitle.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/ContentCardHeader/webMetadata.json b/apps/docs/docs/components/cards/ContentCardHeader/webMetadata.json index eae47e37a1..502ae80a71 100644 --- a/apps/docs/docs/components/cards/ContentCardHeader/webMetadata.json +++ b/apps/docs/docs/components/cards/ContentCardHeader/webMetadata.json @@ -1,8 +1,8 @@ { "import": "import { ContentCardHeader } from '@coinbase/cds-web/cards/ContentCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/ContentCard/ContentCardHeader.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?m=auto&node-id=14705-22947&t=0Jp3mDHvq7E1V7wc-1", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-contentcard--basic", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17343&t=gV06AckLZnxpAsOk-4", "description": "A header component for ContentCard.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/cards/DataCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/DataCard/_mobileExamples.mdx new file mode 100644 index 0000000000..bbffb5e660 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_mobileExamples.mdx @@ -0,0 +1,643 @@ +DataCard is a flexible card component for displaying data with visualizations. It provides a structured layout for thumbnails, titles, subtitles, and visualization content. Pass any visualization component as children, such as `ProgressBar`, `ProgressCircle`, or custom content. + +:::info Migrating from Legacy DataCard? +See the [Migration Guide](#migration-from-legacy-datacard) at the end of this page. +::: + +## Basic Examples + +DataCard supports two layouts: `vertical` (stacked) and `horizontal` (side-by-side). Pass visualization components as children. + +```jsx +function Example() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 25.25% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ↘ 3.12% + + } + > + + + + + + ); +} +``` + +## With PercentageBarChart + +`PercentageBarChart` can be passed directly as the `children` of a `DataCard` to visualize part-to-whole data alongside a title and subtitle. + +```jsx +function Example() { + const theme = useTheme(); + + function PredictionCard({ question, subtitle, yesValue }) { + const noValue = 100 - yesValue; + + return ( + + + } + series={[ + { id: 'yes', data: yesValue, label: 'Yes', color: theme.color.fgPositive }, + { id: 'no', data: noValue, label: 'No', color: theme.color.fgNegative }, + ]} + stackGap={4} + /> + + + ); + } + + return ( + + + + } + series={[ + { id: 'btc', data: 55, label: 'BTC', color: assets.btc.color }, + { id: 'eth', data: 30, label: 'ETH', color: assets.eth.color }, + { id: 'sushi', data: 15, label: 'SUSHI', color: assets.sushi.color }, + ]} + stackGap={4} + /> + + + + + ); +} +``` + +## Layout Variations + +Use `layout="vertical"` for stacked layouts (thumbnail on left, visualization below) or `layout="horizontal"` for side-by-side layouts (header on left, visualization on right). + +```jsx +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + + ( + + {num}% + + ), + }} + > + + + + + + + + + + + ); +} +``` + +## Title Accessory + +Use `titleAccessory` to display supplementary information inline with the title, such as trends, percentages, or status indicators. + +```jsx +function Example() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 8.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ↘ 4.2% + + } + > + + + + + + ); +} +``` + +## Interactive Cards + +Use `renderAsPressable` to make the card interactive with `onPress` handler. + +```jsx +function Example() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + + ); + + return ( + + Alert.alert('Progress bar card pressed!')} + subtitle="Clickable progress card" + thumbnail={exampleThumbnail} + title="Tap to View Details" + titleAccessory={ + + ↗ 8.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + Alert.alert('Circle card pressed!')} + subtitle="Tap for more info" + thumbnail={exampleThumbnail} + title="Interactive Circle" + titleAccessory={ + + Details + + } + > + + + + + + ); +} +``` + +## Style Customization + +Use `styles` prop to customize specific parts of the card layout. + +```jsx +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + + ( + + {num}% + + ), + }} + > + + + + + + + + + + + ); +} +``` + +## Multiple Cards + +DataCards work well in lists or dashboards to display multiple data points. + +```jsx +function Example() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + + ); + + return ( + + + 6,500 / 10,000 + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + 2 / 7 days + + } + > + + + + + + ); +} +``` + +## Accessibility + +Ensure all visualization components have appropriate `accessibilityLabel` props to convey the progress information to screen readers. + +### Interactive Cards + +When making DataCard interactive with `renderAsPressable`: + +- Add an `accessibilityLabel` to summarize the card's content for VoiceOver users, ensuring all visual text of the card is included in the label (e.g., `accessibilityLabel="ETH Holdings, 45% progress, View details"`) + +```jsx + handlePress()} + title="ETH Holdings" + subtitle="45% progress" +> + + ( + + {num}% + + ), + }} + > + + + + +``` + +:::warning Avoid Nested Interactive Elements +Don't place buttons or pressables inside an interactive card, as this creates accessibility issues for VoiceOver users and can cause unexpected behavior when tapping. +::: + +### Color Contrast for Gain/Loss Text + +When displaying gain or loss percentages in DataCard, be aware of color contrast differences between light and dark modes. + +**Why this matters:** DataCard uses `bgAlternate` as its background color. In **light mode**, the semantic `fgPositive` token does not meet WCAG AA contrast requirements: + +| Mode | Color | Background | Contrast Ratio | WCAG AA (4.5:1) | +| ----- | ------------------------ | ------------------------ | -------------- | --------------- | +| Light | `fgPositive` (`green60`) | `bgAlternate` (`gray10`) | ~3.6:1 | ❌ Fails | +| Light | `green70` | `bgAlternate` (`gray10`) | ~4.8:1 | ✅ Passes | +| Dark | `fgPositive` (`green60`) | `bgAlternate` (`gray5`) | ~6.2:1 | ✅ Passes | + +**Recommendation:** + +- **Light mode**: Use `green70` for positive values instead of `fgPositive` +- **Dark mode**: `fgPositive` meets WCAG AA requirements and can be used as-is +- **Both modes**: `fgNegative` meets WCAG AA requirements + +**On mobile**, access the theme spectrum via `useTheme()` hook for light mode compatibility: + +```jsx +const { spectrum } = useTheme(); + +{ + /* Gain text */ +} + + ↗ 12.5% +; + +{ + /* Loss text */ +} + + ↘ 3.2% +; +``` + +```jsx +function Example() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 12.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ); +} +``` + +## Migration from Legacy DataCard + +The new `DataCard` from `@coinbase/cds-mobile/alpha/data-card` replaces the legacy `DataCard`. The new version provides more flexibility with custom layouts and visualization components. + +**Before:** + +```jsx +import { DataCard } from '@coinbase/cds-mobile/cards/DataCard'; + +; +``` + +**After:** + +```jsx +import { DataCard } from '@coinbase/cds-mobile/alpha/data-card'; + +} +> + ( + + {num}% + + ), + }} + labelPlacement="below" + > + + +; +``` diff --git a/apps/docs/docs/components/cards/DataCard/_mobilePropsTable.mdx b/apps/docs/docs/components/cards/DataCard/_mobilePropsTable.mdx new file mode 100644 index 0000000000..b61fd40d79 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile/alpha/data-card/DataCard/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/DataCard/_mobileStyles.mdx b/apps/docs/docs/components/cards/DataCard/_mobileStyles.mdx new file mode 100644 index 0000000000..3abe6e6cc3 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/alpha/data-card/DataCard/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/cards/DataCard/_webExamples.mdx b/apps/docs/docs/components/cards/DataCard/_webExamples.mdx new file mode 100644 index 0000000000..eb77de43cf --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_webExamples.mdx @@ -0,0 +1,765 @@ +DataCard is a flexible card component for displaying data with visualizations. It provides a structured layout for thumbnails, titles, subtitles, and visualization content. Pass any visualization component as children, such as `ProgressBar`, `ProgressCircle`, `LineChart`, or custom content. + +:::info Migrating from Legacy DataCard? +See the [Migration Guide](#migration-from-legacy-datacard) at the end of this page. +::: + +## Basic Examples + +DataCard supports two layouts: `vertical` (stacked) and `horizontal` (side-by-side). Pass visualization components as children. + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 25.25% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ↘ 3.12% + + } + > + + + + + + ); +} +``` + +## With LineChart + +DataCard can also display chart visualizations like LineChart for showing price trends or time-series data. + +```jsx live +function Example() { + const lineChartData = useMemo( + () => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58, 42, 65, 78, 55, 40, 62], + [], + ); + + const lineChartSeries = useMemo( + () => [ + { + id: 'price', + data: lineChartData, + color: 'var(--color-accentBoldBlue)', + }, + ], + [lineChartData], + ); + + return ( + + + } + title="Line Chart Card" + > + + + + } + title="Chart with Trend" + titleAccessory={ + + ↘ 5.8% + + } + > + + + + } + title="Actionable Chart Card" + titleAccessory={ + + ↗ 8.5% + + } + > + + + + ); +} +``` + +## With PercentageBarChart + +`PercentageBarChart` can be passed directly as the `children` of a `DataCard` to visualize part-to-whole data alongside a title and subtitle. + +```jsx live +function Example() { + const [tick, setTick] = useState(0); + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 4), 1000); + return () => clearInterval(id); + }, []); + + const PredictionLegendEntry = memo(function PredictionLegendEntry({ seriesId, label, color }) { + const { series } = useCartesianChartContext(); + const percentage = series.find((s) => s.id === seriesId)?.data?.[0] ?? 0; + + return ( + + + + {label} + + + · + + + + + ); + }); + + const PredictionCard = useMemo( + () => + memo(function PredictionCard({ question, subtitle, yesValue }) { + const noValue = 100 - yesValue; + + return ( + + + } + series={[ + { id: 'yes', data: yesValue, label: 'Yes', color: 'var(--color-fgPositive)' }, + { id: 'no', data: noValue, label: 'No', color: 'var(--color-fgNegative)' }, + ]} + stackGap={4} + /> + + + ); + }), + [], + ); + + const btcYes = 50 + Math.sin(tick * 0.05) * 49; + + return ( + + + + } + series={[ + { id: 'btc', data: 55, label: 'BTC', color: assets.btc.color }, + { id: 'eth', data: 30, label: 'ETH', color: assets.eth.color }, + { id: 'sushi', data: 15, label: 'SUSHI', color: assets.sushi.color }, + ]} + stackGap={4} + /> + + + + + ); +} +``` + +## Layout Variations + +Use `layout="vertical"` for stacked layouts (thumbnail on left, visualization below) or `layout="horizontal"` for side-by-side layouts (header on left, visualization on right). + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + + ( + + {num}% + + ), + }} + > + + + + + + + + + + + ); +} +``` + +## Title Accessory + +Use `titleAccessory` to display supplementary information inline with the title, such as trends, percentages, or status indicators. + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 8.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ↘ 4.2% + + } + > + + + + + + ); +} +``` + +## Interactive Cards + +Use `renderAsPressable` to make the card interactive. You can render as a button with `onClick` or as a link with `as="a"` and `href`. + +```jsx live +function Example() { + const ref1 = useRef(null); + const ref2 = useRef(null); + + const exampleThumbnail = ( + + ); + + return ( + + alert('Progress bar card clicked!')} + subtitle="Clickable progress card" + thumbnail={exampleThumbnail} + title="Click to View Details" + titleAccessory={ + + ↗ 8.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + External + + } + > + + + + + + ); +} +``` + +## Style Customization + +Use `styles` and `classNames` props to customize specific parts of the card layout. + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + + ( + + {num}% + + ), + }} + > + + + + + + + + + + + ); +} +``` + +## Multiple Cards + +DataCards work well in lists or dashboards to display multiple data points. + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + 6,500 / 10,000 + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + 2 / 7 days + + } + > + + + + + + ); +} +``` + +## Accessibility + +Ensure all visualization components have appropriate `accessibilityLabel` props to convey the progress information to screen readers. + +### Interactive Cards + +When making DataCard interactive with `renderAsPressable`: + +- If `as` is set to `"button"` or `"a"`, `renderAsPressable` defaults to `true` automatically. Add an `accessibilityLabel` to summarize the card's content for screen reader users, ensuring all visual text of the card is included in the label (e.g., `accessibilityLabel="ETH Holdings, 45% progress, View details"`) + +```jsx live + handleClick()} + title="ETH Holdings" + subtitle="45% progress" + width={480} +> + + ( + + {num}% + + ), + }} + > + + + + +``` + +:::warning Avoid Nested Interactive Elements +Don't place buttons or links inside an interactive card, as this creates accessibility issues for screen reader users and can cause unexpected behavior when clicking. +::: + +### Heading Semantics + +By default, the `title` prop renders as a `
`. If you need the title to be a proper heading element for document structure, pass a custom `Text` node with the `as` prop: + +```jsx + + Card Title + + } + // ...other props +/> +``` + +### Color Contrast for Gain/Loss Text + +When displaying gain or loss percentages in DataCard, be aware of color contrast differences between light and dark modes. + +**Why this matters:** DataCard uses `bgAlternate` as its background color. In **light mode**, the semantic `fgPositive` token does not meet WCAG AA contrast requirements: + +| Mode | Color | Background | Contrast Ratio | WCAG AA (4.5:1) | +| ----- | ------------------------ | ------------------------ | -------------- | --------------- | +| Light | `fgPositive` (`green60`) | `bgAlternate` (`gray10`) | ~3.6:1 | ❌ Fails | +| Light | `green70` | `bgAlternate` (`gray10`) | ~4.8:1 | ✅ Passes | +| Dark | `fgPositive` (`green60`) | `bgAlternate` (`gray5`) | ~6.2:1 | ✅ Passes | + +**Recommendation:** + +- **Light mode**: Use `green70` for positive values instead of `fgPositive` +- **Dark mode**: `fgPositive` meets WCAG AA requirements and can be used as-is +- **Both modes**: `fgNegative` meets WCAG AA requirements + +**On web**, use CSS variables for light mode compatibility: + +```jsx +{ + /* Gain text */ +} + + ↗ 12.5% +; + +{ + /* Loss text */ +} + + ↘ 3.2% +; +``` + +```jsx live +function Example() { + const exampleThumbnail = ( + + ); + + return ( + + + ↗ 12.5% + + } + > + + ( + + {num}% + + ), + }} + > + + + + + + ); +} +``` + +## Migration from Legacy DataCard + +The new `DataCard` from `@coinbase/cds-web/alpha/data-card` replaces the legacy `DataCard`. The new version provides more flexibility with custom layouts and visualization components. + +**Before:** + +```jsx +import { DataCard } from '@coinbase/cds-web/cards/DataCard'; + +; +``` + +**After:** + +```jsx +import { DataCard } from '@coinbase/cds-web/alpha/data-card'; + +} +> + + ( + + {num}% + + ), + }} + labelPlacement="below" + > + + + +; +``` diff --git a/apps/docs/docs/components/cards/DataCard/_webPropsTable.mdx b/apps/docs/docs/components/cards/DataCard/_webPropsTable.mdx new file mode 100644 index 0000000000..791cd950c8 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web/alpha/data-card/DataCard/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/DataCard/_webStyles.mdx b/apps/docs/docs/components/cards/DataCard/_webStyles.mdx new file mode 100644 index 0000000000..7e163106e5 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/_webStyles.mdx @@ -0,0 +1,87 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { DataCard } from '@coinbase/cds-web/alpha/data-card'; +import { ProgressBar } from '@coinbase/cds-web/visualizations'; +import { ProgressBarWithFixedLabels } from '@coinbase/cds-web/visualizations'; +import { ProgressCircle } from '@coinbase/cds-web/visualizations'; +import { Box } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; +import { RemoteImage } from '@coinbase/cds-web/media'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import webStylesData from ':docgen/web/alpha/data-card/DataCard/styles-data'; + +## Explorer + +### Vertical Layout + + + {(classNames) => ( + + } + title="Progress Bar Card" + titleAccessory={ + + ↗ 25.25% + + } + width={480} + > + + ( + + {num}% + + ), + }} + > + + + + + )} + + +### Horizontal Layout + + + {(classNames) => ( + + } + title="Progress Circle Card" + titleAccessory={ + + ↘ 3.12% + + } + width={480} + > + + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/cards/DataCard/index.mdx b/apps/docs/docs/components/cards/DataCard/index.mdx new file mode 100644 index 0000000000..c7a328e7b5 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/index.mdx @@ -0,0 +1,39 @@ +--- +id: dataCard +title: DataCard +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web/alpha/data-card/DataCard/toc-props'; +import mobilePropsToc from ':docgen/mobile/alpha/data-card/DataCard/toc-props'; +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +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/cards/DataCard/mobileMetadata.json b/apps/docs/docs/components/cards/DataCard/mobileMetadata.json new file mode 100644 index 0000000000..28a52d3e47 --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/mobileMetadata.json @@ -0,0 +1,33 @@ +{ + "import": "import { DataCard } from '@coinbase/cds-mobile/alpha/data-card'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/alpha/data-card/DataCard.tsx", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17798&t=amrBLVMCPqwVCwLt-4", + "description": "A flexible card component for displaying data with visualizations like progress bars and circles. It supports horizontal and vertical layouts with customizable thumbnails and title accessories.", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MediaCard", + "url": "/components/cards/MediaCard/" + }, + { + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" + }, + { + "label": "ProgressBar", + "url": "/components/feedback/ProgressBar/" + }, + { + "label": "ProgressCircle", + "url": "/components/feedback/ProgressCircle/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/DataCard/webMetadata.json b/apps/docs/docs/components/cards/DataCard/webMetadata.json new file mode 100644 index 0000000000..1eaa7b98ae --- /dev/null +++ b/apps/docs/docs/components/cards/DataCard/webMetadata.json @@ -0,0 +1,34 @@ +{ + "import": "import { DataCard } from '@coinbase/cds-web/alpha/data-card'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/alpha/data-card/DataCard.tsx", + "description": "A flexible card component for displaying data with visualizations like progress bars and circles. It supports horizontal and vertical layouts with customizable thumbnails and title accessories.", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17798&t=amrBLVMCPqwVCwLt-4", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-alpha-datacard--basic-examples", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MediaCard", + "url": "/components/cards/MediaCard/" + }, + { + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" + }, + { + "label": "ProgressBar", + "url": "/components/feedback/ProgressBar/" + }, + { + "label": "ProgressCircle", + "url": "/components/feedback/ProgressCircle/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/FloatingAssetCard/mobileMetadata.json b/apps/docs/docs/components/cards/FloatingAssetCard/mobileMetadata.json index d564c7869e..fff6897a85 100644 --- a/apps/docs/docs/components/cards/FloatingAssetCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/FloatingAssetCard/mobileMetadata.json @@ -1,16 +1,17 @@ { "import": "import { FloatingAssetCard } from '@coinbase/cds-mobile/cards/FloatingAssetCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/FloatingAssetCard.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=10085-2951&t=DIcYU9WAXkBUimkN-0", - "description": "Asset Cards display current and potential future assets, offering a straightforward method to view and manage a customer's holdings. They provide a clear visual and informative overview, simplifying asset management and investment considerations.", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6710&t=EIOPhI0X8y2FmZOa-4", + "description": "Asset Cards display current and potential future assets, offering a straightforward method to view and manage a customer's holdings.", + "warning": "This component is deprecated. Please use MediaCard instead. Note: The floating variation (media outside the card container) is no longer supported.", "relatedComponents": [ { - "label": "ContentCard", - "url": "/components/cards/ContentCard/" + "label": "MediaCard", + "url": "/components/cards/MediaCard/" }, { - "label": "ContainedAssetCard", - "url": "/components/cards/ContainedAssetCard/" + "label": "ContentCard", + "url": "/components/cards/ContentCard/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/cards/FloatingAssetCard/webMetadata.json b/apps/docs/docs/components/cards/FloatingAssetCard/webMetadata.json index cbd9582712..e2063c9acc 100644 --- a/apps/docs/docs/components/cards/FloatingAssetCard/webMetadata.json +++ b/apps/docs/docs/components/cards/FloatingAssetCard/webMetadata.json @@ -2,16 +2,17 @@ "import": "import { FloatingAssetCard } from '@coinbase/cds-web/cards/FloatingAssetCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/FloatingAssetCard.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-floatingassetcard--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=10085-2951&t=DIcYU9WAXkBUimkN-0", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6710&t=EIOPhI0X8y2FmZOa-4", "description": "A card component for displaying and managing asset holdings.", + "warning": "This component is deprecated. Please use MediaCard instead. Note: The floating variation (media outside the card container) is no longer supported.", "relatedComponents": [ { - "label": "ContentCard", - "url": "/components/cards/ContentCard/" + "label": "MediaCard", + "url": "/components/cards/MediaCard/" }, { - "label": "ContainedAssetCard", - "url": "/components/cards/ContainedAssetCard/" + "label": "ContentCard", + "url": "/components/cards/ContentCard/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/cards/MediaCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/MediaCard/_mobileExamples.mdx new file mode 100644 index 0000000000..0aaa1bc80c --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_mobileExamples.mdx @@ -0,0 +1,392 @@ +MediaCard provides a contained card layout with optional media, ideal for showcasing assets, products, or promotional content. + +:::info Migrating from FloatingAssetCard or ContainedAssetCard? +See the [Migration Guide](#migration-from-deprecated-components) at the end of this page. +::: + +## Basic + +At minimum, provide a `thumbnail` to display visual content and a `title` for the card heading. + +```jsx + + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + /> + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + /> + +``` + +## Media Placement + +Use the `media` prop to display larger visual content. Control its position with `mediaPlacement`: + +- `start`: Media on the left +- `end` (default): Media on the right + +```jsx + + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + mediaPlacement="start" + /> + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + mediaPlacement="end" + /> + +``` + +## Interactive + +MediaCard can be made interactive with the `onPress` prop and `renderAsPressable`. + +```jsx + + } + title="Interactive Card" + subtitle="Button" + description="Clickable card with onPress handler" + width={320} + media={ + + } + onPress={() => console.log('Card clicked!')} +/> +``` + +## Text Content + +### Long Text + +The card handles long text content with truncation. + +```jsx + + } + title="This is a very long title text that will get truncated" + subtitle="This is a very long subtitle text that will get truncated" + description="This is a very long description text that demonstrates how the card handles longer content" + width={320} + media={ + + } +/> +``` + +### Custom Content + +Use React nodes for custom styled text content. + +```jsx + + } + title={Custom Title} + subtitle={ + + Custom Subtitle + + } + description={ + + Custom description with bold text and{' '} + italic text + + } + width={320} + media={ + + } +/> +``` + +## Styling + +Use the `styles` prop to customize specific parts of the card. + +```jsx + + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + styles={{ + layoutContainer: { gap: 3 }, + contentContainer: { padding: 3, gap: 2 }, + textContainer: { gap: 1 }, + headerContainer: { gap: 1 }, + mediaContainer: { borderRadius: 300 }, + }} + /> + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + styles={{ + root: { borderWidth: 2, borderColor: 'blue' }, + }} + /> + +``` + +## Multiple Cards + +Display multiple cards in a carousel. + +```jsx + + + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + /> + + + + } + title="Bitcoin" + subtitle="BTC" + description="Another card with different content" + width={320} + media={ + + } + /> + + + + } + title="Ethereum" + subtitle="ETH" + description="Card with onPress handler" + width={320} + onPress={() => {}} + /> + + +``` + +## Accessibility + +### Interactive Cards + +When making MediaCard interactive with `renderAsPressable`: + +- Add an `accessibilityLabel` to summarize the card's action for VoiceOver users (e.g., `accessibilityLabel="View Ethereum details"`) + +:::warning Avoid Nested Interactive Elements +Don't place buttons or pressables inside an interactive card, as this creates accessibility issues for VoiceOver users and can cause unexpected behavior when tapping. +::: + +### Color Contrast + +When customizing card backgrounds, ensure sufficient color contrast between text and background colors. WCAG AA requires a minimum contrast ratio of 4.5:1 for normal text. + +## Migration from Deprecated Components + +### Migrating from ContainedAssetCard + +Replace `ContainedAssetCard` with `MediaCard`: + +```jsx +// Before +} + title="$309.43" + subtitle="Bitcoin" + description={↗3.37%} + size="l" +> + + + +// After +} + title="$309.43" + subtitle="Bitcoin" + description={↗3.37%} + media={} + mediaPlacement="end" +/> +``` + +### Migrating from FloatingAssetCard + +Replace `FloatingAssetCard` with `MediaCard`. Note that the floating variation (media outside the card container) is no longer supported: + +```jsx +// Before +} +/> + +// After +} + title="Balancing the Air" + subtitle="Amber V's Artwork" + description="0.5 ETH" +/> +``` diff --git a/apps/docs/docs/components/cards/MediaCard/_mobilePropsTable.mdx b/apps/docs/docs/components/cards/MediaCard/_mobilePropsTable.mdx new file mode 100644 index 0000000000..a862f0b5d8 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile/cards/MediaCard/index/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/MediaCard/_mobileStyles.mdx b/apps/docs/docs/components/cards/MediaCard/_mobileStyles.mdx new file mode 100644 index 0000000000..b0d4fb52a5 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/cards/MediaCard/index/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/cards/MediaCard/_webExamples.mdx b/apps/docs/docs/components/cards/MediaCard/_webExamples.mdx new file mode 100644 index 0000000000..c0b2207147 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_webExamples.mdx @@ -0,0 +1,426 @@ +MediaCard provides a contained card layout with optional media, ideal for showcasing assets, products, or promotional content. + +:::info Migrating from FloatingAssetCard or ContainedAssetCard? +See the [Migration Guide](#migration-from-deprecated-components) at the end of this page. +::: + +## Basic + +At minimum, provide a `thumbnail` to display visual content and a `title` for the card heading. + +```jsx live + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + /> + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + /> + +``` + +## Media Placement + +Use the `media` prop to display larger visual content. Control its position with `mediaPlacement`: + +- `start`: Media on the left +- `end` (default): Media on the right + +```jsx live + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + mediaPlacement="start" + /> + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + mediaPlacement="end" + /> + +``` + +## Polymorphic and Interactive + +MediaCard supports polymorphic rendering and can be made interactive with `renderAsPressable`. Use `as` to change the underlying element. + +```jsx live + + } + title="Article Card" + subtitle="article element" + description="This card renders as an article element" + width={320} + media={ + + } + /> + } + title="Interactive Card" + subtitle="Link" + description="Clickable card with href" + width={320} + media={ + + } + /> + alert('Card clicked!')} + thumbnail={} + title="Interactive Card" + subtitle="Button" + description="Clickable card with onClick handler" + width={320} + media={ + + } + /> + +``` + +## Text Content + +### Long Text + +The card handles long text content with truncation. + +```jsx live + + } + title="This is a very long title text that will get truncated" + subtitle="This is a very long subtitle text that will get truncated" + description="This is a very long description text that demonstrates how the card handles longer content" + width={320} + media={ + + } +/> +``` + +### Custom Content + +Use React nodes for custom styled text content. + +```jsx live +} + title={ + + Custom Title + + } + subtitle={ + + Custom Subtitle + + } + description={ + + Custom description with bold text and italic text + + } + width={320} + media={ + + } +/> +``` + +## Styling + +Use `styles` and `classNames` props to customize specific parts of the card. + +```jsx live + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + styles={{ + layoutContainer: { gap: 3 }, + contentContainer: { padding: 3, gap: 2 }, + textContainer: { gap: 1 }, + headerContainer: { gap: 1 }, + mediaContainer: { borderRadius: 300 }, + }} + /> + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + styles={{ + root: { borderWidth: 2, borderColor: 'blue' }, + }} + /> + +``` + +## Multiple Cards + +Display multiple cards in a carousel. + +```jsx live + + + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + /> + + + + } + title="Bitcoin" + subtitle="BTC" + description="Another card with different content" + width={320} + media={ + + } + /> + + + console.log('clicked')} + thumbnail={ + + } + title="Ethereum" + subtitle="ETH" + description="Card with onClick handler" + width={320} + /> + + +``` + +## Accessibility + +### Interactive Cards + +When making MediaCard interactive with `renderAsPressable`: + +- If `as` is set to `"button"` or `"a"`, `renderAsPressable` defaults to `true` automatically. Add an `accessibilityLabel` to summarize the card's action for screen reader users (e.g., `accessibilityLabel="View Ethereum details"`) + +:::warning Avoid Nested Interactive Elements +Don't place buttons or links inside an interactive card, as this creates accessibility issues for screen reader users and can cause unexpected behavior when clicking. +::: + +### Heading Semantics + +By default, the `title` prop renders as a `
`. If you need the title to be a proper heading element for document structure, pass a custom `Text` node with the `as` prop: + +```jsx + + Card Title + + } + // ...other props +/> +``` + +### Color Contrast + +When customizing card backgrounds, ensure sufficient color contrast between text and background colors. Use tools like the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify your color combinations meet WCAG guidelines. + +## Migration from Deprecated Components + +### Migrating from ContainedAssetCard + +Replace `ContainedAssetCard` with `MediaCard`: + +```jsx +// Before +} + title="$309.43" + subtitle="Bitcoin" + description={↗3.37%} + size="l" +> + + + +// After +} + title="$309.43" + subtitle="Bitcoin" + description={↗3.37%} + media={} + mediaPlacement="end" +/> +``` + +### Migrating from FloatingAssetCard + +Replace `FloatingAssetCard` with `MediaCard`. Note that the floating variation (media outside the card container) is no longer supported: + +```jsx +// Before +} +/> + +// After +} + title="Balancing the Air" + subtitle="Amber V's Artwork" + description="0.5 ETH" +/> +``` diff --git a/apps/docs/docs/components/cards/MediaCard/_webPropsTable.mdx b/apps/docs/docs/components/cards/MediaCard/_webPropsTable.mdx new file mode 100644 index 0000000000..837a487c40 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web/cards/MediaCard/index/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/MediaCard/_webStyles.mdx b/apps/docs/docs/components/cards/MediaCard/_webStyles.mdx new file mode 100644 index 0000000000..99cc8d1316 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/_webStyles.mdx @@ -0,0 +1,36 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { MediaCard } from '@coinbase/cds-web/cards'; +import { RemoteImage } from '@coinbase/cds-web/media'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; + +import webStylesData from ':docgen/web/cards/MediaCard/index/styles-data'; + +## Explorer + + + {(classNames) => ( + } + title="Title" + subtitle="Subtitle" + description="Description" + width={320} + media={ + + } + /> + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/cards/MediaCard/index.mdx b/apps/docs/docs/components/cards/MediaCard/index.mdx new file mode 100644 index 0000000000..ce45bed957 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/index.mdx @@ -0,0 +1,39 @@ +--- +id: mediaCard +title: MediaCard +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web/cards/MediaCard/index/toc-props'; +import mobilePropsToc from ':docgen/mobile/cards/MediaCard/index/toc-props'; +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + webStyles={} + webStylesToc={webStylesToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + /> + diff --git a/apps/docs/docs/components/cards/MediaCard/mobileMetadata.json b/apps/docs/docs/components/cards/MediaCard/mobileMetadata.json new file mode 100644 index 0000000000..2bc2c2aef2 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/mobileMetadata.json @@ -0,0 +1,17 @@ +{ + "import": "import { MediaCard } from '@coinbase/cds-mobile/cards/MediaCard'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/MediaCard/index.tsx", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-18221&t=amrBLVMCPqwVCwLt-4", + "description": "MediaCard displays content with optional media in a contained card layout. Use it to showcase assets, products, or content with a thumbnail, title, subtitle, description, and optional media placement. MediaCard replaces the deprecated FloatingAssetCard and ContainedAssetCard components.", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/MediaCard/webMetadata.json b/apps/docs/docs/components/cards/MediaCard/webMetadata.json new file mode 100644 index 0000000000..42a0f47811 --- /dev/null +++ b/apps/docs/docs/components/cards/MediaCard/webMetadata.json @@ -0,0 +1,18 @@ +{ + "import": "import { MediaCard } from '@coinbase/cds-web/cards/MediaCard'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/MediaCard/index.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-mediacard--basic", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-18221&t=amrBLVMCPqwVCwLt-4", + "description": "MediaCard displays content with optional media in a contained card layout. Use it to showcase assets, products, or content with a thumbnail, title, subtitle, description, and optional media placement. MediaCard replaces the deprecated FloatingAssetCard and ContainedAssetCard components.", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx b/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx new file mode 100644 index 0000000000..544ffc5bf5 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_mobileExamples.mdx @@ -0,0 +1,892 @@ +MessagingCard provides two card types for promotional and informational content. + +:::info Migrating from NudgeCard or UpsellCard? +See the [Migration Guide](#migration-from-deprecated-components) at the end of this page. +::: + +## Basic Types + +Use `type` to set the card variant: + +- `upsell`: Primary background, used for promoting features or products. Use `variant="secondary"` buttons. +- `nudge`: Alternate background, used for encouraging user actions. Use `variant="tertiary"` (transparent) buttons for a less intrusive appearance. + +```jsx + + console.log('Action pressed!')} + media={ + + } + mediaPlacement="end" + /> + console.log('Action pressed!')} + media={} + mediaPlacement="end" + /> + +``` + +:::tip Nudge Button Style +Use transparent buttons (`variant="tertiary"` or `transparent` prop) for nudge cards. They provide a gentle reminder without being intrusive, blending more seamlessly with the card's alternate background. +::: + +## Media Placement + +Use `mediaPlacement` to control the position of media content. + +```jsx + + } + mediaPlacement="end" + /> + } + mediaPlacement="start" + /> + +``` + +## Upsell Card Styles + +MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens. + +For **custom background colors**, use the recommended approach: + +- **Non-interactive cards** (`renderAsPressable={false}` or omitted): set the background via `styles.root` (e.g. `styles={{ root: { backgroundColor: 'rgb(...)' } }}`). +- **Interactive cards** (`renderAsPressable` with `onPress`): set the background via `blendStyles.background` (e.g. `blendStyles={{ background: 'rgb(...)' }}`) so press states are handled correctly. + +### General Upsell + +Utilize the default background for general information and non-urgent promotions. Its versatile design is perfect for a broad range of content, providing a subtle yet effective approach to engage users. It's also the most suitable style for Pictogram illustrations. + +```jsx + + Recurring Buy + + } + description={ + + Want to add funds to your card every week or month? + + } + width={360} + action={ + + } + media={ + + + + } + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" +/> +``` + +### Feature Upsell + +Ideal for highlighting Coinbase tools, innovative features, and unique functionalities. Choose from our palette of distinct colors to make your Feature Upsell stand out. Each color is carefully selected to grab attention while aligning with the specific nature of the feature being promoted. + +```jsx +function FeatureUpsell() { + const { spectrum } = useTheme(); + const image = ( + + ); + const cards = [ + { bg: `rgb(${spectrum.purple70})` }, + { bg: `rgb(${spectrum.teal50})` }, + { bg: `rgb(${spectrum.blue80})` }, + { bg: `rgb(${spectrum.indigo70})` }, + ]; + return ( + + {cards.map((card, i) => ( + + Up to 3.29% APR on ETH + + } + description={ + + Earn staking rewards on ETH by holding it on Coinbase + + } + width={360} + action="Start earning" + onActionButtonPress={() => console.log('Action pressed!')} + media={image} + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### Community Upsell + +Designed for community-focused messaging. Vibrant colors spark enthusiasm and encourage active participation, fostering a sense of community engagement. + +```jsx +function CommunityUpsell() { + const { spectrum } = useTheme(); + const cards = [ + { bg: `rgb(${spectrum.teal70})`, image: 'https://cds.coinbase.com/img/community.png' }, + { bg: `rgb(${spectrum.purple70})`, image: 'https://cds.coinbase.com/img/radial.png' }, + ]; + return ( + + {cards.map((card, i) => ( + + Join the community + + } + description={ + + Chat with other devs in our Discord community + + } + width={360} + action="Join now" + onActionButtonPress={() => console.log('Action pressed!')} + media={ + + } + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### Product Upsell + +Optimal for business products, security features, and functionalities that emphasize trust and reliability, such as Coinbase One and Coinbase Card. Blue and dark backgrounds symbolize stability, trustworthiness, and professionalism. + +```jsx +function ProductUpsell() { + const { spectrum } = useTheme(); + const cards = [ + { + title: 'Coinbase One offer', + description: 'Use code NOV60 when you sign up for Coinbase One', + action: 'Get 60 days free', + bg: `rgb(${spectrum.blue80})`, + image: 'https://cds.coinbase.com/img/marketing.png', + }, + { + title: 'Coinbase Card', + description: 'Spend USDC to get rewards with our Visa® debit card', + action: 'Get started', + bg: `rgb(${spectrum.gray100})`, + image: 'https://cds.coinbase.com/img/object.png', + }, + ]; + return ( + + {cards.map((card) => ( + + {card.title} + + } + description={ + + {card.description} + + } + width={360} + action={card.action} + onActionButtonPress={() => console.log('Action pressed!')} + media={ + + } + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### News Upsell + +Specifically tailored for company announcements and policy updates. Its design ensures that important information is conveyed clearly and prominently, ensuring users stay well-informed about the latest developments. + +```jsx +function NewsUpsell() { + const { spectrum } = useTheme(); + const cards = [{ bg: `rgb(${spectrum.gray100})` }, { bg: `rgb(${spectrum.indigo70})` }]; + return ( + + {cards.map((card, i) => ( + + Help defend crypto in America + + } + description={ + + Help us keep crypto in America with a single click + + } + width={360} + action="Join the fight" + onActionButtonPress={() => console.log('Action pressed!')} + media={ + + } + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +## Nudge Card Style + +Use `type="nudge"` for gentle reminders or secondary options. Nudge cards use the alternate background and blend more seamlessly with the page. Pair them with Pictogram illustrations and transparent buttons. + +```jsx + + console.log('Action pressed!')} + media={} + mediaPlacement="end" + onDismissButtonPress={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + } + mediaPlacement="end" + /> + +``` + +## Features + +### Dismissible Cards + +Use `onDismissButtonPress` to add a dismiss button. + +```jsx +function DismissibleCards() { + const { spectrum } = useTheme(); + return ( + + + } + mediaPlacement="end" + onDismissButtonPress={() => console.log('Card dismissed!')} + dismissButtonAccessibilityLabel="Close card" + /> + } + mediaPlacement="end" + onDismissButtonPress={() => console.log('Card dismissed!')} + dismissButtonAccessibilityLabel="Close card" + /> + + ); +} +``` + +### Tags + +Use `tag` to add a label badge. + +```jsx + + + } + mediaPlacement="end" + /> + } + mediaPlacement="end" + /> + +``` + +### Actions + +Use the `action` prop to add an action button. Pass a string to render a default button with `onActionButtonPress`, or pass a custom React element. + +```jsx + + console.log('Action pressed!')} + media={ + + } + mediaPlacement="end" + /> + console.log('Action pressed!')} + media={} + mediaPlacement="end" + /> + +``` + +### Complete Example + +Combine all features in a complete card. + +```jsx + + console.log('Action pressed!')} + onDismissButtonPress={() => console.log('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss" + media={ + + } + mediaPlacement="end" + /> + console.log('Action pressed!')} + onDismissButtonPress={() => console.log('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss" + media={} + mediaPlacement="end" + /> + +``` + +## Interactive Dismissible List + +This example shows a list of cards that can be dismissed interactively. Press the dismiss button to remove cards from the list. + +```jsx +function DismissibleCardsList() { + const { spectrum } = useTheme(); + const cards = [ + { + id: '1', + title: 'Welcome to Coinbase', + description: 'Get started with your crypto journey', + type: 'upsell', + }, + { + id: '2', + title: 'Complete your profile', + description: 'Add your details to unlock more features', + type: 'nudge', + }, + { + id: '3', + title: 'Enable notifications', + description: 'Stay updated on market movements', + type: 'upsell', + }, + { + id: '4', + title: 'Invite friends', + description: 'Earn rewards when friends join', + type: 'nudge', + }, + ]; + + const [dismissedIds, setDismissedIds] = useState(new Set()); + + const handleDismiss = (id) => { + setDismissedIds((prev) => new Set(prev).add(id)); + }; + + const handleReset = () => { + setDismissedIds(new Set()); + }; + + const visibleCards = cards.filter((card) => !dismissedIds.has(card.id)); + + return ( + + + {visibleCards.map((card) => ( + + ) : ( + + ) + } + mediaPlacement="end" + onDismissButtonPress={() => handleDismiss(card.id)} + dismissButtonAccessibilityLabel={`Dismiss ${card.title}`} + /> + ))} + {visibleCards.length === 0 && ( + + All cards dismissed! + + )} + + + + ); +} +``` + +## Interactive Cards + +Use `renderAsPressable` with `onPress` for interactive cards. + +```jsx +function InteractiveCards() { + const { spectrum } = useTheme(); + return ( + + console.log('Card pressed!')} + type="upsell" + blendStyles={{ background: `rgb(${spectrum.teal70})` }} + title="Interactive Upsell" + description="Tap to interact" + width={320} + media={ + + } + mediaPlacement="end" + /> + console.log('Card pressed!')} + type="nudge" + title="Interactive Nudge" + description="Tap to interact" + width={320} + media={} + mediaPlacement="end" + /> + + ); +} +``` + +## Custom Content + +Use React nodes for custom styled content. + +```jsx + + + } + mediaPlacement="end" + /> + + Custom Title + + } + tag={ + + Custom Tag + + } + description={ + + Custom description with styled text + + } + media={ + + } + mediaPlacement="end" + /> + +``` + +## Multiple Cards + +Display multiple cards in a carousel. + +```jsx +function MultipleCards() { + const { spectrum } = useTheme(); + return ( + + + + } + mediaPlacement="end" + /> + + + {}} + type="nudge" + title="Card 2" + description="Interactive card" + tag="Tap" + media={} + mediaPlacement="end" + /> + + + console.log('clicked')} + type="upsell" + blendStyles={{ background: `rgb(${spectrum.purple70})` }} + title="Card 3" + description="Card with onPress handler" + tag="Action" + media={ + + } + mediaPlacement="end" + /> + + + ); +} +``` + +## Accessibility + +### Interactive Cards with Dismiss Button + +When you need both `onDismissButtonPress` and want the entire card to be pressable, you should handle accessibility carefully to avoid nested interactive elements. + +**The Problem**: If you use `renderAsPressable` with `onPress` and also have `onDismissButtonPress`, the card becomes a pressable containing another pressable (the dismiss button). This creates accessibility issues for screen reader users. + +**The Solution**: Mark the card as non-accessible and add a separate action button inside the card with the same action. This allows: + +- Regular users to tap anywhere on the card +- Screen reader users to focus on individual interactive elements (action button + dismiss button) + +```jsx + console.log('Card pressed - navigating...')} + type="upsell" + title="Accessible Interactive Card" + description="Card with both dismiss and card-level action" + action={ + + } + onDismissButtonPress={() => console.log('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss promotion" + media={ + + } + mediaPlacement="end" +/> +``` + +**Key points:** + +- Set `accessible={false}` to remove the card from the accessibility tree +- Add a `Button` in `actionButton` with the same `onPress` handler for screen reader users +- Use `actionButtonAccessibilityLabel` and `dismissButtonAccessibilityLabel` to add or override the accessibility label for the action and dismiss buttons + +### Color Contrast + +MessagingCard supports custom backgrounds via the `background` prop and, for custom colors, `styles.root` (non-interactive) or `blendStyles.background` (interactive). When using custom background colors, ensure sufficient color contrast between text and background: + +- Use `fgInverse` text color with dark backgrounds (e.g., `accentBoldPurple`, `bgInverse`) +- Use `fg` text color with light backgrounds (e.g., `bgPrimaryWash`, `bgAlternate`) +- Verify your color combinations meet WCAG AA guidelines (4.5:1 for normal text) + +## Migration from Deprecated Components + +### Migrating from NudgeCard + +Replace `NudgeCard` with `MessagingCard` using `type="nudge"`. + +```jsx +// Before + + +// After +} + action="Learn more" + onActionButtonPress={handleAction} + onDismissButtonPress={handleDismiss} + mediaPlacement="end" +/> +``` + +### Migrating from UpsellCard + +Replace `UpsellCard` with `MessagingCard` using `type="upsell"`. + +```jsx +// Before +} + action="Get Started" + onActionPress={handleAction} + onDismissPress={handleDismiss} +/> + +// After +} + action="Get Started" + onActionButtonPress={handleAction} + onDismissButtonPress={handleDismiss} + mediaPlacement="end" +/> +``` diff --git a/apps/docs/docs/components/cards/MessagingCard/_mobilePropsTable.mdx b/apps/docs/docs/components/cards/MessagingCard/_mobilePropsTable.mdx new file mode 100644 index 0000000000..63e733261b --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_mobilePropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import mobilePropsData from ':docgen/mobile/cards/MessagingCard/index/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/MessagingCard/_mobileStyles.mdx b/apps/docs/docs/components/cards/MessagingCard/_mobileStyles.mdx new file mode 100644 index 0000000000..a137ef46fc --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/cards/MessagingCard/index/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx b/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx new file mode 100644 index 0000000000..a827cc7e72 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_webExamples.mdx @@ -0,0 +1,924 @@ +MessagingCard provides two card types for promotional and informational content. + +:::info Migrating from NudgeCard or UpsellCard? +See the [Migration Guide](#migration-from-deprecated-components) at the end of this page. +::: + +## Basic Types + +Use `type` to set the card variant: + +- `upsell`: Primary background, used for promoting features or products. Use `variant="secondary"` buttons. +- `nudge`: Alternate background, used for encouraging user actions. Use `variant="tertiary"` (transparent) buttons for a less intrusive appearance. + +```jsx live + + alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + /> + alert('Action clicked!')} + media={} + mediaPlacement="end" + /> + +``` + +:::tip Nudge Button Style +Use transparent buttons (`variant="tertiary"` or `transparent` prop) for nudge cards. They provide a gentle reminder without being intrusive, blending more seamlessly with the card's alternate background. +::: + +## Media Placement + +Use `mediaPlacement` to control the position of media content. + +```jsx live + + } + mediaPlacement="end" + /> + } + mediaPlacement="start" + /> + +``` + +## Upsell Card Styles + +MessagingCard with `type="upsell"` supports various background colors to match different promotional purposes. Use the `background` prop for semantic tokens. + +For **custom background colors**, use the recommended approach: + +- **Non-interactive cards** (default `as="article"` or `renderAsPressable={false}`): set the background via `styles.root` or `classNames.root` (e.g. `styles={{ root: { backgroundColor: 'rgb(var(--blue80))' } }}`). +- **Interactive cards** (`renderAsPressable` with `as="a"` or `as="button"`): set the background via `blendStyles.background` (e.g. `blendStyles={{ background: 'rgb(var(--blue80))' }}`) so press states are handled correctly. + +### General Upsell + +Utilize the default background for general information and non-urgent promotions. Its versatile design is perfect for a broad range of content, providing a subtle yet effective approach to engage users. It's also the most suitable style for Pictogram illustrations. + +```jsx live + + Recurring Buy + + } + description={ + + Want to add funds to your card every week or month? + + } + width={360} + action={ + + } + media={ + + + + } + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" +/> +``` + +### Feature Upsell + +Ideal for highlighting Coinbase tools, innovative features, and unique functionalities. Choose from our palette of distinct colors to make your Feature Upsell stand out. Each color is carefully selected to grab attention while aligning with the specific nature of the feature being promoted. + +```jsx live +function FeatureUpsell() { + const cards = [ + { bg: 'rgb(var(--purple70))', label: 'Purple' }, + { bg: 'rgb(var(--teal50))', label: 'Teal' }, + { bg: 'rgb(var(--blue80))', label: 'Blue' }, + { bg: 'rgb(var(--indigo70))', label: 'Indigo' }, + ]; + return ( + + {cards.map((card) => ( + + Up to 3.29% APR on ETH + + } + description={ + + Earn staking rewards on ETH by holding it on Coinbase + + } + width={360} + action="Start earning" + onActionButtonClick={() => alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### Community Upsell + +Designed for community-focused messaging. Vibrant colors spark enthusiasm and encourage active participation, fostering a sense of community engagement. + +```jsx live +function CommunityUpsell() { + const cards = [ + { bg: 'rgb(var(--teal70))', image: '/img/community.png' }, + { bg: 'rgb(var(--purple70))', image: '/img/radial.png' }, + ]; + return ( + + {cards.map((card, i) => ( + + Join the community + + } + description={ + + Chat with other devs in our Discord community + + } + width={360} + action="Join now" + onActionButtonClick={() => alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### Product Upsell + +Optimal for business products, security features, and functionalities that emphasize trust and reliability, such as Coinbase One and Coinbase Card. Blue and dark backgrounds symbolize stability, trustworthiness, and professionalism. + +```jsx live +function ProductUpsell() { + const cards = [ + { + title: 'Coinbase One offer', + description: 'Use code NOV60 when you sign up for Coinbase One', + action: 'Get 60 days free', + bg: 'rgb(var(--blue80))', + image: '/img/marketing.png', + }, + { + title: 'Coinbase Card', + description: 'Spend USDC to get rewards with our Visa® debit card', + action: 'Get started', + bg: 'rgb(var(--gray100))', + image: '/img/object.png', + }, + ]; + return ( + + {cards.map((card) => ( + + {card.title} + + } + description={ + + {card.description} + + } + width={360} + action={card.action} + onActionButtonClick={() => alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +### News Upsell + +Specifically tailored for company announcements and policy updates. Its design ensures that important information is conveyed clearly and prominently, ensuring users stay well-informed about the latest developments. + +```jsx live +function NewsUpsell() { + const cards = [{ bg: 'rgb(var(--gray100))' }, { bg: 'rgb(var(--indigo70))' }]; + return ( + + {cards.map((card, i) => ( + + Help defend crypto in America + + } + description={ + + Help us keep crypto in America with a single click + + } + width={360} + action="Join the fight" + onActionButtonClick={() => alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + ))} + + ); +} +``` + +## Nudge Card Style + +Use `type="nudge"` for gentle reminders or secondary options. Nudge cards use the alternate background and blend more seamlessly with the page. Pair them with Pictogram illustrations and transparent buttons. + +```jsx live + + alert('Action clicked!')} + media={} + mediaPlacement="end" + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + /> + } + mediaPlacement="end" + /> + +``` + +## Features + +### Dismissible Cards + +Use `onDismissButtonClick` to add a dismiss button. + +```jsx live + + + } + mediaPlacement="end" + onDismissButtonClick={() => alert('Card dismissed!')} + dismissButtonAccessibilityLabel="Close card" + styles={{ root: { backgroundColor: 'rgb(var(--teal70))' } }} + /> + } + mediaPlacement="end" + onDismissButtonClick={() => alert('Card dismissed!')} + dismissButtonAccessibilityLabel="Close card" + /> + +``` + +### Tags + +Use `tag` to add a label badge. + +```jsx live + + + } + mediaPlacement="end" + /> + } + mediaPlacement="end" + /> + +``` + +### Actions + +Use the `action` prop to add an action button. Pass a string to render a default button with `onActionButtonClick`, or pass a custom React element. + +```jsx live + + alert('Action clicked!')} + media={ + + } + mediaPlacement="end" + /> + alert('Action clicked!')} + media={} + mediaPlacement="end" + /> + +``` + +### Complete Example + +Combine all features in a complete card. + +```jsx live + + alert('Action clicked!')} + onDismissButtonClick={() => alert('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss" + media={ + + } + mediaPlacement="end" + /> + alert('Action clicked!')} + onDismissButtonClick={() => alert('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss" + media={} + mediaPlacement="end" + /> + +``` + +## Interactive Dismissible List + +This example shows a list of cards that can be dismissed interactively. Click the dismiss button to remove cards from the list. + +```jsx live +function DismissibleCards() { + const cards = [ + { + id: '1', + title: 'Welcome to Coinbase', + description: 'Get started with your crypto journey', + type: 'upsell', + }, + { + id: '2', + title: 'Complete your profile', + description: 'Add your details to unlock more features', + type: 'nudge', + }, + { + id: '3', + title: 'Enable notifications', + description: 'Stay updated on market movements', + type: 'upsell', + }, + { + id: '4', + title: 'Invite friends', + description: 'Earn rewards when friends join', + type: 'nudge', + }, + ]; + + const [dismissedIds, setDismissedIds] = React.useState(new Set()); + + const handleDismiss = (id) => { + setDismissedIds((prev) => new Set(prev).add(id)); + }; + + const handleReset = () => { + setDismissedIds(new Set()); + }; + + const visibleCards = cards.filter((card) => !dismissedIds.has(card.id)); + + return ( + + + {visibleCards.map((card) => ( + + ) : ( + + ) + } + mediaPlacement="end" + onDismissButtonClick={() => handleDismiss(card.id)} + dismissButtonAccessibilityLabel={`Dismiss ${card.title}`} + /> + ))} + {visibleCards.length === 0 && ( + + All cards dismissed! + + )} + + + + ); +} +``` + +## Polymorphic and Interactive + +MessagingCard supports polymorphic rendering with `as` and can be made interactive with `renderAsPressable`. + +```jsx live + + + } + mediaPlacement="end" + /> + + } + mediaPlacement="end" + /> + } + mediaPlacement="end" + /> + alert('Card clicked!')} + type="upsell" + blendStyles={{ background: 'rgb(var(--gray100))' }} + title="Interactive Card" + description="Clickable card with onClick handler" + width={320} + media={ + + } + mediaPlacement="end" + /> + +``` + +## Custom Content + +Use React nodes for custom styled content. + +```jsx live + + + } + mediaPlacement="end" + /> + + Custom Title + + } + tag={ + + Custom Tag + + } + description={ + + Custom description with bold text and italic text + + } + media={ + + } + mediaPlacement="end" + /> + +``` + +## Multiple Cards + +Display multiple cards in a carousel. + +```jsx live + + + + } + mediaPlacement="end" + /> + + + } + mediaPlacement="end" + /> + + + console.log('clicked')} + type="upsell" + blendStyles={{ background: 'rgb(var(--purple70))' }} + title="Card 3" + description="Card with onClick handler" + tag="Action" + media={ + + } + mediaPlacement="end" + /> + + +``` + +## Accessibility + +### Interactive Cards with Dismiss Button + +When you need both `onDismissButtonClick` and want the entire card to be clickable, you should handle accessibility carefully to avoid nested interactive elements. + +**The Problem**: If you use `renderAsPressable` with `onClick` and also have `onDismissButtonClick`, the card becomes a button containing another button (the dismiss button). This creates accessibility issues for screen reader users. + +**The Solution**: Mark the card as non-accessible and add a separate action button inside the card with the same action. This allows: + +- Regular users to click anywhere on the card +- Screen reader users to focus on individual interactive elements (action button + dismiss button) + +```jsx live + alert('Card clicked - navigating...')} + type="upsell" + title="Accessible Interactive Card" + description="Card with both dismiss and card-level action" + width={360} + action={ + + } + background="accentBoldPurple" + onDismissButtonClick={() => alert('Dismissed')} + dismissButtonAccessibilityLabel="Dismiss promotion" + media={ + + } + mediaPlacement="end" +/> +``` + +**Key points:** + +- Use `as="div"` to avoid rendering as a semantic button +- When using `as="div"` with `renderAsPressable`, the card remains keyboard focusable. Set `tabIndex={-1}` to remove it from the tab order if needed +- Call `event.stopPropagation()` at the beginning of the event handler method passed into the `onClick` prop for action buttons. This will prevent two click events from firing if the user directly clicks the action button. +- Use `actionButtonAccessibilityLabel` and `dismissButtonAccessibilityLabel` to add or override the `aria-label` for the action and dismiss buttons + +### Color Contrast + +MessagingCard supports custom backgrounds via the `background` prop and, for custom colors, `styles.root` / `classNames.root` (non-interactive) or `blendStyles.background` (interactive). When using custom background colors, ensure sufficient color contrast between text and background: + +- Use `fgInverse` text color with dark backgrounds (e.g., `accentBoldPurple`, `bgInverse`) +- Use `fg` text color with light backgrounds (e.g., `bgPrimaryWash`, `bgAlternate`) +- Use the [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify your color combinations meet WCAG AA guidelines (4.5:1 for normal text) + +## Migration from Deprecated Components + +### Migrating from NudgeCard + +Replace `NudgeCard` with `MessagingCard` using `type="nudge"`. + +```jsx +// Before + + +// After +} + action="Learn more" + onActionButtonClick={handleAction} + onDismissButtonClick={handleDismiss} + mediaPlacement="end" +/> +``` + +### Migrating from UpsellCard + +Replace `UpsellCard` with `MessagingCard` using `type="upsell"`. + +```jsx +// Before +} + action="Get Started" + onActionPress={handleAction} + onDismissPress={handleDismiss} +/> + +// After +} + action="Get Started" + onActionButtonClick={handleAction} + onDismissButtonClick={handleDismiss} + mediaPlacement="end" +/> +``` diff --git a/apps/docs/docs/components/cards/MessagingCard/_webPropsTable.mdx b/apps/docs/docs/components/cards/MessagingCard/_webPropsTable.mdx new file mode 100644 index 0000000000..8f048c98b8 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_webPropsTable.mdx @@ -0,0 +1,10 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; +import webPropsData from ':docgen/web/cards/MessagingCard/index/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/cards/MessagingCard/_webStyles.mdx b/apps/docs/docs/components/cards/MessagingCard/_webStyles.mdx new file mode 100644 index 0000000000..5aa8dd3859 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/_webStyles.mdx @@ -0,0 +1,31 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { MessagingCard } from '@coinbase/cds-web/cards'; +import { Pictogram } from '@coinbase/cds-web/illustrations'; + +import webStylesData from ':docgen/web/cards/MessagingCard/index/styles-data'; + +## Explorer + + + {(classNames) => ( + {}} + onDismissButtonClick={() => {}} + dismissButtonAccessibilityLabel="Dismiss" + media={} + mediaPlacement="end" + width={360} + /> + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/cards/MessagingCard/index.mdx b/apps/docs/docs/components/cards/MessagingCard/index.mdx new file mode 100644 index 0000000000..ce87df2b0b --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/index.mdx @@ -0,0 +1,43 @@ +--- +id: messagingCard +title: MessagingCard +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web/cards/MessagingCard/index/toc-props'; +import mobilePropsToc from ':docgen/mobile/cards/MessagingCard/index/toc-props'; +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +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/cards/MessagingCard/mobileMetadata.json b/apps/docs/docs/components/cards/MessagingCard/mobileMetadata.json new file mode 100644 index 0000000000..8bc6002c99 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/mobileMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { MessagingCard } from '@coinbase/cds-mobile/cards/MessagingCard'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/MessagingCard/index.tsx", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-21922&t=amrBLVMCPqwVCwLt-4", + "description": "MessagingCard displays promotional or informational content with two variants: 'upsell' for promoting features with a primary background, and 'nudge' for encouraging actions with an alternate background. It replaces the deprecated NudgeCard and UpsellCard components.", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MediaCard", + "url": "/components/cards/MediaCard/" + }, + { + "label": "Banner", + "url": "/components/feedback/Banner/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/MessagingCard/webMetadata.json b/apps/docs/docs/components/cards/MessagingCard/webMetadata.json new file mode 100644 index 0000000000..cf5d2e65a4 --- /dev/null +++ b/apps/docs/docs/components/cards/MessagingCard/webMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { MessagingCard } from '@coinbase/cds-web/cards/MessagingCard'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/MessagingCard/index.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-messagingcard--basic-types", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-21922&t=amrBLVMCPqwVCwLt-4", + "description": "MessagingCard displays promotional or informational content with two variants: 'upsell' for promoting features with a primary background, and 'nudge' for encouraging actions with an alternate background. It replaces the deprecated NudgeCard and UpsellCard components.", + "relatedComponents": [ + { + "label": "ContentCard", + "url": "/components/cards/ContentCard/" + }, + { + "label": "MediaCard", + "url": "/components/cards/MediaCard/" + }, + { + "label": "Banner", + "url": "/components/feedback/Banner/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json b/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json index b084c0b1c9..ee33ef4ad1 100644 --- a/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json @@ -1,21 +1,27 @@ { "import": "import { NudgeCard } from '@coinbase/cds-mobile/cards/NudgeCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/NudgeCard.tsx", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-Components?type=design&node-id=10085%3A3191&mode=design&t=CXWB883JGKycJnlr-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6530&t=EIOPhI0X8y2FmZOa-4", "description": "A card component designed to encourage users to take essential actions.", + "warning": "This component is deprecated. Please use MessagingCard with type=\"nudge\" instead.", "relatedComponents": [ { - "label": "Banner", - "url": "/components/feedback/Banner/" + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" }, { - "label": "UpsellCard", - "url": "/components/cards/UpsellCard/" + "label": "Banner", + "url": "/components/feedback/Banner/" }, { "label": "ContentCard", "url": "/components/cards/ContentCard/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/cards/NudgeCard/webMetadata.json b/apps/docs/docs/components/cards/NudgeCard/webMetadata.json index c8b725450f..db02033166 100644 --- a/apps/docs/docs/components/cards/NudgeCard/webMetadata.json +++ b/apps/docs/docs/components/cards/NudgeCard/webMetadata.json @@ -2,16 +2,17 @@ "import": "import { NudgeCard } from '@coinbase/cds-web/cards/NudgeCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/NudgeCard.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-nudgecard--default&globals=backgrounds.grid:false", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-Components?type=design&node-id=10085%3A3191&mode=design&t=CXWB883JGKycJnlr-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=59121-6530&t=EIOPhI0X8y2FmZOa-4", "description": "A card component designed to encourage users to take essential actions.", + "warning": "This component is deprecated. Please use MessagingCard with type=\"nudge\" instead.", "relatedComponents": [ { - "label": "Banner", - "url": "/components/feedback/Banner/" + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" }, { - "label": "UpsellCard", - "url": "/components/cards/UpsellCard/" + "label": "Banner", + "url": "/components/feedback/Banner/" }, { "label": "ContentCard", diff --git a/apps/docs/docs/components/cards/UpsellCard/mobileMetadata.json b/apps/docs/docs/components/cards/UpsellCard/mobileMetadata.json index 3d967a1b8a..205c3ec8a6 100644 --- a/apps/docs/docs/components/cards/UpsellCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/UpsellCard/mobileMetadata.json @@ -1,16 +1,17 @@ { "import": "import { UpsellCard } from '@coinbase/cds-mobile/cards/UpsellCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/cards/UpsellCard.tsx", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-Components?type=design&node-id=10085%3A6246&mode=design&t=CXWB883JGKycJnlr-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=46131-64332&t=XhGq61uqmdGiv0jp-4", "description": "Upsell Cards are used to promote new features, products, or actions within the app. They are part of the upsell framework and aim to drive user engagement and revenue.", + "warning": "This component is deprecated. Please use MessagingCard with type=\"upsell\" instead.", "relatedComponents": [ { - "label": "Banner", - "url": "/components/feedback/Banner/" + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" }, { - "label": "NudgeCard", - "url": "/components/cards/NudgeCard/" + "label": "Banner", + "url": "/components/feedback/Banner/" }, { "label": "ContentCard", diff --git a/apps/docs/docs/components/cards/UpsellCard/webMetadata.json b/apps/docs/docs/components/cards/UpsellCard/webMetadata.json index 63732e0984..611137aa60 100644 --- a/apps/docs/docs/components/cards/UpsellCard/webMetadata.json +++ b/apps/docs/docs/components/cards/UpsellCard/webMetadata.json @@ -2,16 +2,17 @@ "import": "import { UpsellCard } from '@coinbase/cds-web/cards/UpsellCard'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/cards/UpsellCard.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-cards-upsellcard--default&globals=backgrounds.grid:false", - "figma": "https://www.figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-Normal-Components?type=design&node-id=10085%3A6246&mode=design&t=CXWB883JGKycJnlr-1", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=46131-64332&t=XhGq61uqmdGiv0jp-4", "description": "A card component for promoting new features, products, or actions.", + "warning": "This component is deprecated. Please use MessagingCard with type=\"upsell\" instead.", "relatedComponents": [ { - "label": "Banner", - "url": "/components/feedback/Banner/" + "label": "MessagingCard", + "url": "/components/cards/MessagingCard/" }, { - "label": "NudgeCard", - "url": "/components/cards/NudgeCard/" + "label": "Banner", + "url": "/components/feedback/Banner/" }, { "label": "ContentCard", diff --git a/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx new file mode 100644 index 0000000000..9af1442003 --- /dev/null +++ b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx @@ -0,0 +1,234 @@ +AreaChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) that has some unique features over [LineChart](/components/charts/LineChart), such as the ability to stack areas on top of each other and a default value-axis minimum that follows the baseline (`0` when baseline is not set). Charts are built using `@shopify/react-native-skia`. + +## Basic Example + +```jsx + +``` + +## Simple + +```jsx + +``` + +## Stacking + +You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/charts/CartesianChart/#series-stacks) for more details. + +```jsx +function StackingExample() { + const theme = useTheme(); + return ( + , + }, + ]} + AreaComponent={(props) => } + type="dotted" + /> + ); +} +``` + +## Negative Values + +AreaChart uses the value-axis baseline as the default minimum when `domain.min` is not set (baseline defaults to `0`). If your data crosses below that baseline, the domain expands to include those values so both positive and negative regions render correctly. + +```jsx + } + showYAxis + yAxis={{ + showGrid: true, + }} +/> +``` + +## Area Styles + +You can have different area styles for each series. + +```jsx + +``` + +## Composed Examples + +### Custom Baseline + +You can combine a custom baseline with a scrubber label that shows both price and date. + +```tsx +function CustomBaseline() { + const theme = useTheme(); + const candles = [...btcCandles].reverse().slice(0, 180); + const prices = candles.map((candle) => parseFloat(candle.close)); + const dates = candles.map((candle) => new Date(parseInt(candle.start, 10) * 1000)); + + const startingPrice = prices[0]; + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatPriceInThousands = useCallback((price: number) => { + return `$${(price / 1000).toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}k`; + }, []); + + const formatDate = useCallback((date: Date) => { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, []); + + const formatLabel = useCallback( + (dataIndex: number) => `${formatPrice(prices[dataIndex])} ${formatDate(dates[dataIndex])}`, + [dates, formatDate, formatPrice, prices], + ); + + const PriceLabel = memo((props: ReferenceLineLabelComponentProps) => ( + + )); + + const chartAccessibilityLabel = `Bitcoin area chart with custom baseline. Current price: ${formatPrice( + prices[prices.length - 1], + )}. Swipe to navigate.`; + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${formatPrice(prices[index])} ${formatDate(dates[index])}`, + [dates, formatDate, formatPrice, prices], + ); + + return ( + + + } + dataY={startingPrice} + stroke={theme.color.fg} + label={formatPrice(startingPrice)} + /> + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/AreaChart/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/AreaChart/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/AreaChart/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/AreaChart/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/AreaChart/_webExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_webExamples.mdx new file mode 100644 index 0000000000..2f43c68fd7 --- /dev/null +++ b/apps/docs/docs/components/charts/AreaChart/_webExamples.mdx @@ -0,0 +1,351 @@ +AreaChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) that has some unique features over [LineChart](/components/charts/LineChart), such as the ability to stack areas on top of each other and a default value-axis minimum that follows the baseline (`0` when baseline is not set). Charts are built using SVGs. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` array of numbers. + +```jsx live + +``` + +## Data + +### Positive and Negative + +Area grows from the baseline to each value, allowing for both positive and negative data to be shown. Also, you can set `baseline` to any value you'd like. + +```jsx live + +``` + +### Range + +You can pass in `[min, max]` tuples as data points to render an area that span a range of values. + +```jsx live +} + showXAxis + showYAxis + height={250} + series={[ + { + id: 'marketCap', + label: 'Market Cap', + data: [5.4, 5.8, 6.1, 5.9, 6.0, 6.3], + showLines: true, + fillOpacity: 0, + }, + { + id: 'confidenceInterval', + label: 'Confidence Interval', + data: [ + [5.3, 5.5], + [5.6, 6.0], + [5.8, 6.2], + [5.8, 6.1], + [5.9, 6.3], + [6.2, 6.5], + ], + fillOpacity: 0.3, + }, + ]} + xAxis={{ + showLine: true, + showTickMarks: true, + data: ['January', 'February', 'March', 'April', 'May', 'June'], + }} + yAxis={{ + showGrid: true, + showLine: true, + showTickMarks: true, + domain: { min: 5.0, max: 7.0 }, + tickLabelFormatter: (val) => `$${val}B`, + }} +> + + +``` + +## Stacking + +You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/charts/CartesianChart/#series-stacks) for more info on stacking. + +```jsx live + , + legendShape: 'squircle', + label: 'Potential Rewards', + }, + ]} + AreaComponent={(props) => } + type="dotted" +/> +``` + +## Styling + +### Areas + +You can have different area styles for each series. + +```jsx live + +``` + +### Axes + +Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis). + +```jsx live + `Day ${dataX}`, + }} + yAxis={{ + showGrid: true, + showLine: true, + showTickMarks: true, + }} + fillOpacity={0.5} +/> +``` + +### Gradient + +You can build threshold-based gradients with hard transitions by reusing stop offsets. + +```jsx live + [ + { offset: min, color: 'var(--color-fgNegative)', opacity: 0.45 }, + { offset: -2, color: 'var(--color-fgNegative)', opacity: 0.45 }, + { offset: -2, color: 'var(--color-fgWarning)', opacity: 0.45 }, + { offset: 2, color: 'var(--color-fgWarning)', opacity: 0.45 }, + { offset: 2, color: 'var(--color-fgPositive)', opacity: 0.45 }, + { offset: max, color: 'var(--color-fgPositive)', opacity: 0.45 }, + ], + }, + type: 'gradient', + }, + ]} + yAxis={{ + showGrid: true, + domain: { min: -10, max: 10 }, + tickLabelFormatter: (value) => `${value}%`, + }} +/> +``` + +## Composed Examples + +### Custom Baseline + +You can combine a custom baseline with a scrubber label that shows both price and date. + +```jsx live +function CustomBaseline() { + const candles = [...btcCandles].reverse().slice(0, 180); + const prices = candles.map((candle) => parseFloat(candle.close)); + const dates = candles.map((candle) => new Date(parseInt(candle.start, 10) * 1000)); + + const startingPrice = prices[0]; + + const formatPrice = useCallback((price) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatDate = useCallback((date) => { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, []); + + const formatLabel = useCallback( + (dataIndex) => { + const price = prices[dataIndex]; + const date = dates[dataIndex]; + + return ( + <> + {formatPrice(price)} {formatDate(date)} + + ); + }, + [dates, formatDate, formatPrice, prices], + ); + + const PriceLabel = memo((props) => ( + + )); + + const chartAccessibilityLabel = `Bitcoin area chart with custom baseline. Current price: ${formatPrice( + prices[prices.length - 1], + )}. Use arrow keys to navigate.`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => `${formatPrice(prices[index])} ${formatDate(dates[index])}`, + [dates, formatDate, formatPrice, prices], + ); + + return ( + + + ( + + )} + dataY={startingPrice} + label={formatPrice(startingPrice)} + /> + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/AreaChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/AreaChart/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/AreaChart/_webPropsTable.mdx rename to apps/docs/docs/components/charts/AreaChart/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/AreaChart/index.mdx b/apps/docs/docs/components/charts/AreaChart/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/AreaChart/index.mdx rename to apps/docs/docs/components/charts/AreaChart/index.mdx diff --git a/apps/docs/docs/components/charts/AreaChart/mobileMetadata.json b/apps/docs/docs/components/charts/AreaChart/mobileMetadata.json new file mode 100644 index 0000000000..56b3b67cbb --- /dev/null +++ b/apps/docs/docs/components/charts/AreaChart/mobileMetadata.json @@ -0,0 +1,41 @@ +{ + "import": "import { AreaChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/area/AreaChart.tsx", + "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/AreaChart/webMetadata.json b/apps/docs/docs/components/charts/AreaChart/webMetadata.json new file mode 100644 index 0000000000..b23760aad8 --- /dev/null +++ b/apps/docs/docs/components/charts/AreaChart/webMetadata.json @@ -0,0 +1,34 @@ +{ + "import": "import { AreaChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/area/AreaChart.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-areachart--all", + "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/BarChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/BarChart/_mobileExamples.mdx new file mode 100644 index 0000000000..f7a38fecde --- /dev/null +++ b/apps/docs/docs/components/charts/BarChart/_mobileExamples.mdx @@ -0,0 +1,1229 @@ +BarChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) for comparing discrete categories, with a default value-axis minimum that follows the baseline (`0` when baseline is not set). Charts are built using `@shopify/react-native-skia`. + +## Basics + +Bar charts are a useful component for comparing discrete categories of data. +They are helpful for highlighting trends to users or allowing them to compare proportions at a glance. + +To start, pass in a series of data to the chart. + +```jsx + +``` + +### Layout + +You can set `layout` to `horizontal` to render the chart horizontally. + +```jsx +function HorizontalBars() { + const dataset = [ + { month: 'Jan', seoul: 21 }, + { month: 'Feb', seoul: 28 }, + { month: 'Mar', seoul: 41 }, + { month: 'Apr', seoul: 73 }, + { month: 'May', seoul: 99 }, + { month: 'June', seoul: 144 }, + { month: 'July', seoul: 319 }, + { month: 'Aug', seoul: 249 }, + { month: 'Sept', seoul: 131 }, + { month: 'Oct', seoul: 55 }, + { month: 'Nov', seoul: 48 }, + { month: 'Dec', seoul: 25 }, + ]; + + return ( + d.seoul), + color: 'var(--color-accentBoldBlue)', + }, + ]} + showXAxis + showYAxis + xAxis={{ + label: 'rainfall (mm)', + showGrid: true, + }} + yAxis={{ + position: 'left', + data: dataset.map((d) => d.month), + }} + /> + ); +} +``` + +## Multiple Series + +You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. + +```tsx +function MultipleSeries() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Series Stacking + +You can also configure stacking for your chart using the `stacked` prop. + +```tsx +function StackedBars() { + const theme = useTheme(); + + return ( + + ); +} +``` + +You can also configure multiple stacks by setting the `stackId` prop on each series. + +```tsx +function MonthlyGainsMultipleStacks() { + const theme = useTheme(); + + return ( + + ); +} +``` + +### Stack Gap + +```tsx +function StackGap() { + const theme = useTheme(); + + return ( + + ); +} +``` + +## Border Radius + +Bars have a default borderRadius of `4`. You can change this by setting the `borderRadius` prop on the chart. + +Stacks will only round the top corners of touching bars. + +```jsx +function RoundedStacks() { + const theme = useTheme(); + + return ( + { + if (value === 'D') { + return {value}; + } + return value; + }, + categoryPadding: 0.25, + }} + style={{ margin: '0 auto' }} + /> + ); +} +``` + +### Round Baseline + +You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. + +```jsx +function MonthlyRewards() { + const theme = useTheme(); + const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + + return ( + { + if (value === 11) { + return {months[value]}; + } + return months[value]; + }, + categoryPadding: 0.25, + }} + stackMinSize={24} + style={{ margin: '0 auto' }} + /> + ); +} +``` + +## Data + +### Negative + +```tsx +function PositiveAndNegativeCashFlow() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + const gains = [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, + 0, 0, 0, + ]; + + const losses = [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, + 0, 0, 0, -12, -10, + ]; + const series = [ + { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, + { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, + ]; + + return ( + `$${value}M`, + }} + /> + ); +} +``` + +### Null + +You can pass in `null` or `0` values to not render a bar for that data point. + +```jsx + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} +/> +``` + +You can also use the `BarStackComponent` prop to render an empty circle for zero values. + +```tsx +function MonthlyRewards() { + const theme = useTheme(); + const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + const currentMonth = 7; + const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; + const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; + const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; + const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; + + const series = [ + { id: 'purple', data: purple, color: `rgb(${theme.spectrum.purple30})` }, + { id: 'blue', data: blue, color: `rgb(${theme.spectrum.blue30})` }, + { id: 'cyan', data: cyan, color: `rgb(${theme.spectrum.teal30})` }, + { id: 'green', data: green, color: `rgb(${theme.spectrum.green30})` }, + ]; + + const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { + if (props.height === 0) { + const diameter = props.width; + return ( + + ); + } + + return {children}; + }; + + return ( + { + if (index == currentMonth) { + return {months[index]}; + } + return months[index]; + }, + categoryPadding: 0.25, + }} + /> + ); +} +``` + +### Range + +You can pass in `[min, max]` tuples as data points to render bars that span a range of values. + +```tsx +function PriceRange() { + const candles = btcCandles.slice(0, 180).reverse(); + const data = candles.map((candle) => [parseFloat(candle.low), parseFloat(candle.high)]); + + const min = Math.min(...data.map(([low]) => low)); + const max = Math.max(...data.map(([, high]) => high)); + + const tickFormatter = useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +} +``` + +## Customization + +### Bar Spacing + +There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. + +```jsx + +``` + +### Minimum Size + +To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. + +#### Minimum Stack Size + +You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. + +```jsx + +``` + +#### Minimum Bar Size + +You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. + +```jsx + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} + barMinSize={4} +/> +``` + +### Multiple Axes + +You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stacked. + +```jsx +function MultipleYAxes() { + const theme = useTheme(); + + return ( + + + `$${value}k`} + /> + `${value}%`} + /> + + + ); +} +``` + +When using horizontal layout, you can use multiple x axes. + +```jsx + + + `$${value}k`} + /> + `${value}%`} + /> + + +``` + +## Animations + +You can configure chart transitions using the `transitions` prop. + +```jsx + +``` + +Also, you can toggle animations by setting `animate` to `true` or `false`. + +```jsx + +``` + +### Stagger Delay + +You can set `staggerDelay` (in milliseconds) on bar transitions to create a cascading animation effect where bars animate sequentially from left to right. The delay is distributed across bars based on their horizontal position — the leftmost bar starts immediately, and the rightmost bar starts after the full `staggerDelay` duration. + +```jsx + +``` + +### Delay + +You can set `delay` (in milliseconds) on transitions to add a pause before the animation starts. + +```jsx + +``` + +## Accessibility + +BarChart supports screen reader accessibility through `enableScrubbing` and `getScrubberAccessibilityLabel`. You do not need to add a [Scrubber](/components/charts/Scrubber) component—the chart renders invisible tap targets that screen reader users can navigate with swipe or tap. + +```tsx +function AccessibleBarChart() { + const categories = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); + const values = useMemo(() => [40, 65, 55, 80, 72, 90], []); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${categories[index]}: ${values[index]}`, + [categories, values], + ); + + return ( + + ); +} +``` + +## Composed Examples + +### Candlesticks + +You can render a candlestick chart by setting the `BarComponent` prop to a custom candlestick component. + +```tsx +function Candlesticks() { + const infoTextId = useId(); + const theme = useTheme(); + const [currentIndex, setCurrentIndex] = useState(); + const stockData = btcCandles.slice(0, 90).reverse(); + const min = Math.min(...stockData.map((data) => parseFloat(data.low))); + + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const BandwidthHighlight = memo(({ stroke }: LineComponentProps) => { + const { getXSerializableScale, drawingArea } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + + const rectWidth = useMemo(() => { + if (xScale !== undefined && xScale.type === 'band') { + return xScale.bandwidth; + } + return 0; + }, [xScale]); + + const xPos = useDerivedValue(() => { + const position = unwrapAnimatedValue(scrubberPosition); + const xPos = + position !== undefined && xScale + ? getPointOnSerializableScale(position, xScale) + : undefined; + return xPos !== undefined ? xPos - rectWidth / 2 : 0; + }, [scrubberPosition, xScale]); + + const opacity = useDerivedValue(() => (xPos.value !== undefined ? 1 : 0), [xPos]); + + return ( + + ); + }); + + const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]); + + const CandlestickBarComponent = memo( + ({ x, y, width, height, originY, dataX }) => { + const { getYScale } = useCartesianChartContext(); + const yScale = getYScale(); + + const wickX = x + width / 2; + const timePeriodValue = stockData[dataX as number]; + + const open = parseFloat(timePeriodValue.open); + const close = parseFloat(timePeriodValue.close); + + const bullish = open < close; + const color = bullish ? theme.color.fgPositive : theme.color.fgNegative; + const openY = yScale?.(open) ?? 0; + const closeY = yScale?.(close) ?? 0; + + const bodyHeight = Math.abs(openY - closeY); + const bodyY = openY < closeY ? openY : closeY; + + return ( + <> + + + + ); + }, + ); + + const formatThousandsPrice = useCallback((price: number) => { + const formattedPrice = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price / 1000); + + return `${formattedPrice}k`; + }, []); + + const formatTime = useCallback( + (index: number | null) => { + if (index === null || index === undefined || index >= stockData.length) return ''; + const ts = parseInt(stockData[index].start); + return new Date(ts * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }, + [stockData], + ); + + return ( + + + {currentIndex !== undefined + ? `Open: ${formatThousandsPrice(parseFloat(stockData[currentIndex].open))}, Close: ${formatThousandsPrice(parseFloat(stockData[currentIndex].close))}` + : formatThousandsPrice(parseFloat(stockData[stockData.length - 1].close))} + + + + + + <>{children}} + /> + + + ); +} +``` + +### Monthly Sunlight + +You can combine custom BarPlot components and transitions to create a springy sunlight chart. + +```tsx +function SunlightChartExample() { + const theme = useTheme(); + const dayLength = 1440; + + type SunlightChartData = Array<{ + label: string; + value: number; + }>; + + const sunlightData: SunlightChartData = [ + { label: 'Jan', value: 598 }, + { label: 'Feb', value: 635 }, + { label: 'Mar', value: 688 }, + { label: 'Apr', value: 753 }, + { label: 'May', value: 812 }, + { label: 'Jun', value: 855 }, + { label: 'Jul', value: 861 }, + { label: 'Aug', value: 828 }, + { label: 'Sep', value: 772 }, + { label: 'Oct', value: 710 }, + { label: 'Nov', value: 648 }, + { label: 'Dec', value: 605 }, + ]; + + const ThinSolidLine = memo((props: SolidLineProps) => ); + + return ( + + value), + yAxisId: 'sunlight', + color: `rgb(${theme.spectrum.yellow40})`, + }, + { + id: 'day', + data: sunlightData.map(() => dayLength), + yAxisId: 'day', + color: `rgb(${theme.spectrum.blue100})`, + }, + ]} + xAxis={{ + scaleType: 'band', + data: sunlightData.map(({ label }) => label), + }} + yAxis={[ + { + id: 'day', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + { + id: 'sunlight', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + ]} + > + + + + + + + 2026 sunlight data for the first day of each month in Atlanta, Georgia, provided by NOAA. + + + ); +} +``` + +### Buy vs Sell + +You can combine a horizontal BarChart with a custom legend to create a buy vs sell chart. + +```tsx +function BuyVsSellExample() { + function BuyVsSellLegend({ buy, sell }: { buy: number; sell: number }) { + return ( + + + {buy}% bought + + } + color="var(--color-fgPositive)" + /> + + {sell}% sold + + } + color="var(--color-fgNegative)" + /> + + ); + } + + function BuyVsSellChart({ + buy, + sell, + animate = false, + borderRadius = 3, + height = 6, + inset = 0, + layout = 'horizontal', + stackGap = 4, + xAxis, + yAxis, + ...props + }: Omit & { buy: number; sell: number }) { + return ( + + + + + ); + } + + return ; +} +``` diff --git a/apps/docs/docs/components/graphs/BarChart/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/BarChart/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/BarChart/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/BarChart/_webExamples.mdx b/apps/docs/docs/components/charts/BarChart/_webExamples.mdx new file mode 100644 index 0000000000..98af716258 --- /dev/null +++ b/apps/docs/docs/components/charts/BarChart/_webExamples.mdx @@ -0,0 +1,1289 @@ +BarChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) for comparing discrete categories, with a default value-axis minimum that follows the baseline (`0` when baseline is not set). + +## Basics + +Bar charts are a useful component for comparing discrete categories of data. +They are helpful for highlighting trends to users or allowing them to compare proportions at a glance. + +To start, pass in a series of data to the chart. + +```jsx live + +``` + +### Layout + +You can set `layout` to `horizontal` to render the chart horizontally. + +```jsx live +function HorizontalBars() { + const dataset = [ + { month: 'Jan', seoul: 21 }, + { month: 'Feb', seoul: 28 }, + { month: 'Mar', seoul: 41 }, + { month: 'Apr', seoul: 73 }, + { month: 'May', seoul: 99 }, + { month: 'June', seoul: 144 }, + { month: 'July', seoul: 319 }, + { month: 'Aug', seoul: 249 }, + { month: 'Sept', seoul: 131 }, + { month: 'Oct', seoul: 55 }, + { month: 'Nov', seoul: 48 }, + { month: 'Dec', seoul: 25 }, + ]; + + return ( + d.seoul), + color: 'var(--color-accentBoldBlue)', + }, + ]} + borderRadius={2} + showXAxis + showYAxis + xAxis={{ + label: 'rainfall (mm)', + GridLineComponent: (props) => , + showGrid: true, + showLine: true, + showTickMarks: true, + }} + yAxis={{ + position: 'left', + data: dataset.map((d) => d.month), + showLine: true, + showTickMarks: true, + bandTickMarkPlacement: 'edges', + }} + /> + ); +} +``` + +## Multiple Series + +You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. + +```jsx live + +``` + +## Series Stacking + +You can also configure stacking for your chart using the `stacked` prop. + +```jsx live + +``` + +You can also configure multiple stacks by setting the `stackId` prop on each series. + +```jsx live + +``` + +### Stack Gap + +```jsx live + +``` + +## Border Radius + +Bars have a default `borderRadius` of `4`. You can change this by setting the `borderRadius` prop on the chart. + +Stacks will only round the top corners of touching bars. + +```jsx live + { + if (value === 'D') { + return {value}; + } + return value; + }, + categoryPadding: 0.25, + }} + style={{ margin: '0 auto' }} +/> +``` + +### Round Baseline + +You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. + +```jsx live +function MonthlyRewards() { + const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + + return ( + { + if (value === 11) { + return {months[value]}; + } + return months[value]; + }, + categoryPadding: 0.25, + }} + stackMinSize={24} + style={{ margin: '0 auto' }} + /> + ); +} +``` + +## Data + +### Negative + +```jsx live +function PositiveAndNegativeCashFlow() { + const ThinSolidLine = memo((props) => ); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + const gains = [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, + 0, 0, 0, + ]; + + const losses = [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, + 0, 0, 0, -12, -10, + ]; + const series = [ + { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, + { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, + ]; + + return ( + `$${value}M`, + }} + /> + ); +} +``` + +### Null + +You can pass in `null` or `0` values to not render a bar for that data point. + +```jsx live + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} +/> +``` + +You can also use the `BarStackComponent` prop to render an empty circle for zero values. + +```jsx live +function MonthlyRewards() { + const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + const currentMonth = 7; + const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; + const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; + const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; + const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; + + const series = [ + { id: 'purple', data: purple, color: 'rgb(var(--purple30))' }, + { id: 'blue', data: blue, color: 'rgb(var(--blue30))' }, + { id: 'cyan', data: cyan, color: 'rgb(var(--teal30))' }, + { id: 'green', data: green, color: 'rgb(var(--green30))' }, + ]; + + const CustomBarStackComponent = ({ children, ...props }) => { + if (props.height === 0) { + const diameter = props.width; + return ( + + ); + } + + return {children}; + }; + + return ( + { + if (index == currentMonth) { + return {months[index]}; + } + return months[index]; + }, + categoryPadding: 0.25, + }} + /> + ); +} +``` + +### Range + +You can pass in `[min, max]` tuples as data points to render bars that span a range of values. + +```jsx live +function PriceRange() { + const candles = [...btcCandles].reverse().slice(0, 180); + const data = candles.map((candle) => [parseFloat(candle.low), parseFloat(candle.high)]); + + const min = Math.min(...data.map(([low]) => low)); + const max = Math.max(...data.map(([, high]) => high)); + + const tickFormatter = useCallback( + (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +} +``` + +## Customization + +### Bar Spacing + +There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. + +```jsx live + +``` + +### Minimum Size + +To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. + +#### Minimum Stack Size + +You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. + +```jsx live + +``` + +#### Minimum Bar Size + +You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. + +```jsx live + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} + barMinSize={4} +/> +``` + +### Multiple Axes + +You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stacked. + +```jsx live + + + `$${value}k`} + /> + `${value}%`} + /> + + +``` + +When using horizontal layout, you can use multiple x axes. + +```jsx live + + + `$${value}k`} + /> + `${value}%`} + /> + + +``` + +### Custom Components + +#### Outlined Stacks + +You can set the `BarStackComponent` prop to render a custom component for stacks. + +```jsx live +function MonthlyRewards() { + const CustomBarStackComponent = ({ children, ...props }) => { + return ( + <> + + {children} + + ); + }; + + return ( + { + if (value === 'D') { + return {value}; + } + return value; + }, + categoryPadding: 0.25, + }} + yAxis={{ range: ({ min, max }) => ({ min, max: max - 4 }) }} + style={{ margin: '0 auto' }} + /> + ); +} +``` + +## Animations + +You can configure chart transitions using the `transitions` prop. Also, you can toggle animations by setting `animate` to `true` or `false`. + +```jsx live +function AnimatedStackedBars() { + const dataCount = 12; + const minValue = 20; + const maxValue = 100; + const minStep = 10; + const maxStep = 40; + const updateInterval = 600; + const seriesSpacing = 30; + + const seriesConfig = [ + { id: 'red', label: 'Red', color: 'rgb(var(--red40))' }, + { id: 'orange', label: 'Orange', color: 'rgb(var(--orange40))' }, + { id: 'yellow', label: 'Yellow', color: 'rgb(var(--yellow40))' }, + { id: 'green', label: 'Green', color: 'rgb(var(--green40))' }, + { id: 'blue', label: 'Blue', color: 'rgb(var(--blue40))' }, + { id: 'indigo', label: 'Indigo', color: 'rgb(var(--indigo40))' }, + { id: 'purple', label: 'Purple', color: 'rgb(var(--purple40))' }, + ]; + + const domainLimit = maxValue + seriesConfig.length * seriesSpacing; + + function generateNextValue(previousValue) { + const range = maxStep - minStep; + const offset = Math.random() * range + minStep; + + let direction; + if (previousValue >= maxValue) { + direction = -1; + } else if (previousValue <= minValue) { + direction = 1; + } else { + direction = Math.random() < 0.5 ? -1 : 1; + } + + let newValue = previousValue + offset * direction; + newValue = Math.max(minValue, Math.min(maxValue, newValue)); + return newValue; + } + + function generateInitialData() { + const data = []; + + let previousValue = minValue + Math.random() * (maxValue - minValue); + data.push(previousValue); + + for (let i = 1; i < dataCount; i++) { + const newValue = generateNextValue(previousValue); + data.push(newValue); + previousValue = newValue; + } + + return data; + } + + function AnimatedChart(props) { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((currentData) => { + const lastValue = currentData[currentData.length - 1] ?? minValue; + const newValue = generateNextValue(lastValue); + + return [...currentData.slice(1), newValue]; + }); + }, updateInterval); + + return () => clearInterval(intervalId); + }, []); + + const series = seriesConfig.map((config, index) => ({ + id: config.id, + label: config.label, + color: config.color, + data: index === 0 ? data : Array(dataCount).fill(seriesSpacing), + })); + + return ( + '', + domain: { min: 0, max: domainLimit }, + }} + {...props} + /> + ); + } + + function AnimatedChartExample() { + const animatedStates = [ + { id: 'on', label: 'On' }, + { id: 'off', label: 'Off' }, + ]; + const [animatedState, setAnimatedState] = useState(animatedStates[0]); + + return ( + + + + Animations + + + + + + ); + } + + return ; +} +``` + +## Composed Examples + +### Candlesticks + +You can render a candlestick chart by setting the `BarComponent` prop to a custom candlestick component. + +```jsx live +function Candlesticks() { + const infoTextId = useId(); + const infoTextRef = React.useRef(null); + const selectedIndexRef = React.useRef(null); + const stockData = [...btcCandles].reverse().slice(0, 90); + const min = Math.min(...stockData.map((data) => parseFloat(data.low))); + + const ThinSolidLine = memo((props) => ); + + // Custom line component that renders a rect to highlight the entire bandwidth + const BandwidthHighlight = memo(({ d, stroke }) => { + const { getXScale, drawingArea, getXAxis } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + const xScale = getXScale(); + const xAxis = getXAxis(); + + if (!xScale || scrubberPosition === undefined) return; + + const xPos = xScale(scrubberPosition); + + if (xPos === undefined) return; + + return ( + + ); + }); + + const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]); + + const staggerDelay = 0.25; + + const CandlestickBarComponent = memo(({ x, y, width, height, originY, dataX, ...props }) => { + const { getYScale, drawingArea } = useCartesianChartContext(); + const yScale = getYScale(); + + const normalizedX = React.useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); + + const transition = React.useMemo( + () => ({ + type: 'tween', + duration: 0.325, + delay: normalizedX * staggerDelay, + }), + [normalizedX], + ); + + const wickX = x + width / 2; + + const timePeriodValue = stockData[dataX]; + + const open = parseFloat(timePeriodValue.open); + const close = parseFloat(timePeriodValue.close); + + const bullish = open < close; + const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + const openY = yScale?.(open) ?? 0; + const closeY = yScale?.(close) ?? 0; + + const bodyHeight = Math.abs(openY - closeY); + const bodyY = openY < closeY ? openY : closeY; + + return ( + + + + + ); + }); + + const formatPrice = React.useCallback((price) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(parseFloat(price)); + }, []); + + const formatThousandsPrice = React.useCallback((price) => { + const formattedPrice = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(parseFloat(price) / 1000); + + return `${formattedPrice}k`; + }, []); + + const formatVolume = React.useCallback((volume) => { + const volumeInThousands = parseFloat(volume) / 1000; + return ( + new Intl.NumberFormat('en-US', { + style: 'decimal', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(volumeInThousands) + 'k' + ); + }, []); + + const formatTime = React.useCallback( + (index) => { + if (index === null || index === undefined || index >= stockData.length) return ''; + const ts = parseInt(stockData[index].start); + return new Date(ts * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }, + [stockData], + ); + + const updateInfoText = React.useCallback( + (index) => { + if (!infoTextRef.current) return; + + const text = + index !== null && index !== undefined + ? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice( + stockData[index].close, + )}, Volume: ${(parseFloat(stockData[index].volume) / 1000).toFixed(2)}k` + : formatPrice(stockData[stockData.length - 1].close); + + infoTextRef.current.textContent = text; + selectedIndexRef.current = index; + }, + [stockData, formatPrice, formatVolume], + ); + const initialInfo = formatPrice(stockData[stockData.length - 1].close); + + return ( + + + {initialInfo} + + {children}} + borderRadius={0} + height={{ base: 150, tablet: 200, desktop: 250 }} + inset={{ top: 8, bottom: 8, left: 0, right: 0 }} + onScrubberPositionChange={updateInfoText} + series={[ + { + id: 'stock-prices', + data: candlesData, + }, + ]} + xAxis={{ + tickLabelFormatter: formatTime, + }} + yAxis={{ + domain: { min }, + tickLabelFormatter: formatThousandsPrice, + width: 40, + showGrid: true, + GridLineComponent: ThinSolidLine, + }} + aria-labelledby={infoTextId} + > + + + + ); +} +``` + +### Monthly Sunlight + +You can combine custom and BarPlot components and transitions to create a springy sunlight chart. + +```tsx live +function SunlightChartExample() { + const dayLength = 1440; + type SunlightChartData = Array<{ + label: string; + value: number; + }>; + const sunlightData: SunlightChartData = [ + { label: 'Jan', value: 598 }, + { label: 'Feb', value: 635 }, + { label: 'Mar', value: 688 }, + { label: 'Apr', value: 753 }, + { label: 'May', value: 812 }, + { label: 'Jun', value: 855 }, + { label: 'Jul', value: 861 }, + { label: 'Aug', value: 828 }, + { label: 'Sep', value: 772 }, + { label: 'Oct', value: 710 }, + { label: 'Nov', value: 648 }, + { label: 'Dec', value: 605 }, + ]; + + function SunlightChart({ + data, + height = 300, + ...props + }: Omit & { data: SunlightChartData }) { + return ( + value), + yAxisId: 'sunlight', + color: 'rgb(var(--yellow40))', + }, + { + id: 'day', + data: data.map(() => dayLength), + yAxisId: 'day', + color: 'rgb(var(--blue100))', + }, + ]} + xAxis={{ + ...props.xAxis, + scaleType: 'band', + data: data.map(({ label }) => label), + }} + yAxis={[ + { + id: 'day', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + { + id: 'sunlight', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + ]} + > + + + + + + ); + } + + function Example() { + return ( + + + + 2026 sunlight data for the first day of each month in Atlanta, Georgia, provided by{' '} + + NOAA + + . + + + ); + } + + return ; +} +``` + +### Buy vs Sell + +You can combine a horizontal BarChart with a custom legend to create a buy vs sell chart. + +```tsx live +function BuyVsSellExample() { + function BuyVsSellLegend({ buy, sell }: { buy: number; sell: number }) { + return ( + + + {buy}% bought + + } + color="var(--color-fgPositive)" + /> + + {sell}% sold + + } + color="var(--color-fgNegative)" + /> + + ); + } + + function BuyVsSellChart({ + buy, + sell, + animate = true, + borderRadius = 3, + height = 6, + inset = 0, + layout = 'horizontal', + stackGap = 4, + xAxis, + yAxis, + barMinSize = height, + ...props + }: Omit & { buy: number; sell: number }) { + return ( + + + + + ); + } + + return ; +} +``` diff --git a/apps/docs/docs/components/graphs/BarChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/BarChart/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/_webPropsTable.mdx rename to apps/docs/docs/components/charts/BarChart/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/BarChart/index.mdx b/apps/docs/docs/components/charts/BarChart/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/index.mdx rename to apps/docs/docs/components/charts/BarChart/index.mdx diff --git a/apps/docs/docs/components/charts/BarChart/mobileMetadata.json b/apps/docs/docs/components/charts/BarChart/mobileMetadata.json new file mode 100644 index 0000000000..f46f2c24da --- /dev/null +++ b/apps/docs/docs/components/charts/BarChart/mobileMetadata.json @@ -0,0 +1,33 @@ +{ + "import": "import { BarChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/bar/BarChart.tsx", + "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/BarChart/webMetadata.json b/apps/docs/docs/components/charts/BarChart/webMetadata.json new file mode 100644 index 0000000000..633c6ff959 --- /dev/null +++ b/apps/docs/docs/components/charts/BarChart/webMetadata.json @@ -0,0 +1,26 @@ +{ + "import": "import { BarChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/bar/BarChart.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-barchart--all", + "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx new file mode 100644 index 0000000000..1ed1a7224b --- /dev/null +++ b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx @@ -0,0 +1,1052 @@ +CartesianChart is a customizable, `@shopify/react-native-skia` based component that can be used to display a variety of data in a x/y coordinate space. The underlying logic is handled by D3. + +## Basics + +[AreaChart](/components/charts/AreaChart/), [BarChart](/components/charts/BarChart/), and [LineChart](/components/charts/LineChart/) are built on top of CartesianChart and have default functionality for your chart. + +```jsx + + + + + + + + + + + +``` + +## Setup + +All charts use Skia Canvas for rendering, which requires a context bridge to share React contexts with the Skia renderer. You need to wrap your app with `ChartBridgeProvider` at the root of your app to enable charts to access theme and chart contexts. + +```jsx +import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization/chart'; +import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; + +function App() { + return ( + + + {/* Your app content with charts */} + + + ); +} +``` + +## Series + +Series are the data that will be displayed on the chart. Each series must have a defined `id`. + +### Series Data + +You can pass in an array of numbers or an array of tuples for the `data` prop. Passing in null values is equivalent to no data at that index. + +```jsx +function ForecastedPrice() { + const theme = useTheme(); + + const ForecastRect = memo(({ startIndex, endIndex }) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + + const xScale = getXScale(); + + if (!xScale) return; + + const startX = xScale(startIndex); + const endX = xScale(endIndex); + return ( + + ); + }); + return ( + + + + + + ); +} +``` + +### Series Axis IDs + +Each series can have a different `yAxisId`, allowing you to compare data from different contexts. + +```jsx +function SeriesAxisIds() { + const theme = useTheme(); + + return ( + + + `$${value}k`} + width={60} + /> + `$${value}k`} + /> + + + ); +} +``` + +### Series Stacks + +You can provide a `stackId` to stack series together. + +```jsx +function SeriesStacks() { + const theme = useTheme(); + + return ( + + + + ); +} +``` + +## Axes + +You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` accepts an object or array, while `yAxis` accepts an object or array. + +When `layout="horizontal"`, you can define multiple x-axes (for multiple value scales) but only one y-axis. + +```jsx + + + + + +``` + +For more info, learn about [XAxis](/components/charts/XAxis/#axis-config) and [YAxis](/components/charts/YAxis/#axis-config) configuration. + +## Inset + +You can adjust the inset around the entire chart (outside the axes) with the `inset` prop. This is useful for when you want to have components that are outside of the drawing area of the data but still within the chart svg. + +You can also remove the default inset, such as to have a compact line chart. + +```tsx +function Insets() { + const theme = useTheme(); + + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const formatPrice = useCallback((dataIndex: number) => { + const price = data[dataIndex]; + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + return ( + + + No inset + + + + Custom inset + + + + + + Default inset + + + + + + ); +} +``` + +## Scrubbing + +CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. + +One example of using the scrubber is to provide haptic feedback when the user interacts with the chart. You can trigger a light impact each time the scrubber position changes or even do a dynamic impact depending on the value change, such as a heavy impact when the user crosses a significant boundary of time or reaches a significant market event. + +```jsx +function Scrubbing() { + const [scrubIndex, setScrubIndex] = useState(undefined); + + const onScrubberPositionChange = useCallback((index: number | undefined) => { + // Do a light impact when the scrubber position changes + // An initial and final impact is already configured by the chart + if (scrubIndex !== undefined && index !== undefined) { + void Haptics.lightImpact(); + } + setScrubIndex(index); + }, [scrubIndex]); + + return ( + + Scrubber index: {scrubIndex ?? 'none'} + + + + + ); +} +``` + +### Allow Overflow Gestures + +By default, the scrubber will not allow overflow gestures. You can allow overflow gestures by setting the `allowOverflowGestures` prop to `true`. + +```jsx + + ... + +``` + +## Animations + +CartesianChart delegates transition control to its child components. Each `Line`, `Area`, and `Bar` accepts a `transitions` prop with `enter` (reveal animation) and `update` (data-change animation) keys. Set either to `null` to disable that phase. You can also disable all animations chart-wide by passing `animate={false}` on CartesianChart. + +Because transitions live on the children, a single chart can mix behaviors — for example a Line that morphs smoothly while a Bar snaps instantly. + +### Enter Only + +Disable the update morph animation while keeping a slow enter reveal. Data changes snap instantly but the initial chart appearance animates. Useful when new data arrives frequently and morphing would be distracting. + +```tsx +function EnterAnimationOnly() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + ); + } + + return ; +} +``` + +### Update Only + +Disable the enter reveal animation while keeping a slow update morph. The chart appears instantly but data changes animate smoothly. Useful when the chart is embedded in content that should not animate on load. + +```tsx +function UpdateAnimationOnly() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + ); + } + + return ; +} +``` + +### Mixed Transitions Per Child + +Each child component can define its own transitions independently. Here, the `Line` uses a spring morph while the bars snap with no update animation. This lets you fine-tune each visual layer within a single chart. + +```tsx +function MixedTransitions() { + const theme = useTheme(); + const dataCount = 10; + const updateInterval = 2000; + + function generateNextValue(prev: number) { + const step = Math.random() * 20 - 10; + return Math.max(10, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + d * 0.3), + color: theme.color.accentBoldPurple, + yAxisId: 'bars', + }, + ]} + xAxis={{ scaleType: 'band' }} + yAxis={[ + { id: 'default' }, + { id: 'bars', range: ({ min, max }) => ({ min: max - 48, max }) }, + ]} + > + + + + ); + } + + return ; +} +``` + +### No Animations + +You can disable all animations chart-wide by setting `animate` to `false` on CartesianChart. This is useful for static snapshots or when performance is a concern. Compare this to the animated examples above — data still updates, but changes snap instantly without any transition. + +```tsx +function DisableAnimations() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + ); + } + + return ; +} +``` + +## Customization + +### Price with Volume + +You can showcase the price and volume of an asset over time within one chart. + +```tsx +function PriceWithVolume() { + const theme = useTheme(); + + const [scrubIndex, setScrubIndex] = useState(null); + const btcData = [...btcCandles].reverse().slice(0, 180); + + const btcPrices = btcData.map((candle) => parseFloat(candle.close)); + const btcVolumes = btcData.map((candle) => parseFloat(candle.volume)); + const btcDates = btcData.map((candle) => new Date(parseInt(candle.start) * 1000)); + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatPriceInThousands = useCallback((price: number) => { + return `$${(price / 1000).toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}k`; + }, []); + + const formatVolume = useCallback((volume: number) => { + return `${(volume / 1000).toFixed(2)}K`; + }, []); + + const formatDate = useCallback((date: Date) => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }, []); + + const displayIndex = scrubIndex ?? btcPrices.length - 1; + const currentPrice = btcPrices[displayIndex]; + const currentVolume = btcVolumes[displayIndex]; + const currentDate = btcDates[displayIndex]; + const priceChange = + displayIndex > 0 + ? (currentPrice - btcPrices[displayIndex - 1]) / btcPrices[displayIndex - 1] + : 0; + + const chartAccessibilityLabel = useMemo(() => { + if (scrubIndex === null) + return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + + const getScrubberAccessibilityLabel = useCallback( + (dataIndex: number) => + `Bitcoin on ${formatDate(btcDates[dataIndex])}. Price ${formatPrice(btcPrices[dataIndex])}. Volume ${formatVolume(btcVolumes[dataIndex])}.`, + [btcDates, btcPrices, btcVolumes, formatDate, formatPrice, formatVolume], + ); + + const scrubberLabel = useCallback( + (dataIndex: number) => + `${formatPrice(btcPrices[dataIndex])} ${formatDate(btcDates[dataIndex])}`, + [btcDates, btcPrices, formatDate, formatPrice], + ); + + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const headerId = useId(); + + return ( + + Bitcoin} + balance={{formatPrice(currentPrice)}} + end={ + + + {formatDate(currentDate)} + {formatVolume(currentVolume)} + + + + + + } + /> + ({ min, max: max - 16 }) }} + yAxis={[ + { + id: 'price', + domain: ({ min, max }) => ({ min: min * 0.9, max }), + }, + { + id: 'volume', + range: ({ min, max }) => ({ min: max - 32, max }), + }, + ]} + accessibilityLabel={chartAccessibilityLabel} + aria-labelledby={headerId} + getScrubberAccessibilityLabel={getScrubberAccessibilityLabel} + inset={{ top: 8, left: 8, right: 0, bottom: 0 }} + > + + + + + + + ); +} +``` + +### Earnings History + +You can also create your own type of cartesian chart by using `getSeriesData`, `getXScale`, and `getYScale` directly. + +```tsx +function EarningsHistory() { + const theme = useTheme(); + const CirclePlot = memo(({ seriesId, opacity = 1 }: { seriesId: string; opacity?: number }) => { + const { drawingArea, getSeries, getSeriesData, getXScale, getYScale } = + useCartesianChartContext(); + const series = getSeries(seriesId); + const data = getSeriesData(seriesId); + const xScale = getXScale(); + const yScale = getYScale(series?.yAxisId); + + if (!xScale || !yScale || !data || !isCategoricalScale(xScale)) return null; + + const yScaleSize = Math.abs(yScale.range()[1] - yScale.range()[0]); + + // Have circle diameter be the smaller of the x scale bandwidth or 10% of the y space available + const diameter = Math.min(xScale.bandwidth(), yScaleSize / 10); + + return ( + + {data.map((value, index) => { + if (value === null || value === undefined) return null; + + // Get x position from band scale - center of the band + const xPos = xScale(index); + if (xPos === undefined) return null; + + const centerX = xPos + xScale.bandwidth() / 2; + + // Get y position from value + const yValue = Array.isArray(value) ? value[1] : value; + const centerY = yScale(yValue); + if (centerY === undefined) return null; + + return ( + + ); + })} + + ); + }); + + const quarters = useMemo(() => ['Q1', 'Q2', 'Q3', 'Q4'], []); + const estimatedEPS = useMemo(() => [1.71, 1.82, 1.93, 2.34], []); + const actualEPS = useMemo(() => [1.68, 1.83, 2.01, 2.24], []); + + const formatEarningAmount = useCallback((value: number) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const surprisePercentage = useCallback( + (index: number): ChartTextChildren => { + const percentage = (actualEPS[index] - estimatedEPS[index]) / estimatedEPS[index]; + const percentageString = percentage.toLocaleString('en-US', { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + return ( + 0 ? theme.color.fgPositive : theme.color.fgNegative, + fontWeight: 'bold', + }} + > + {percentage > 0 ? '+' : ''} + {percentageString} + + ); + }, + [actualEPS, estimatedEPS], + ); + + const LegendEntry = memo(({ opacity = 1, label }: { opacity?: number; label: string }) => { + return ( + + + {label} + + ); + }); + + const LegendDot = memo((props: BoxBaseProps) => { + return ; + }); + + return ( + + + + quarters[index]} /> + + + + + + + + + + ); +} +``` + +### Trading Trends + +You can have multiple axes with different domains and ranges to showcase different pieces of data over the time time period. + +```tsx +function TradingTrends() { + const theme = useTheme(); + + function TradingTrends() { + const profitData = [34, 24, 28, -4, 8, -16, -3, 12, 24, 18, 20, 28]; + const gains = profitData.map((value) => (value > 0 ? value : 0)); + const losses = profitData.map((value) => (value < 0 ? value : 0)); + + const renderProfit = useCallback((value: number) => { + return `$${value}M`; + }, []); + + const ThinSolidLine = memo((props: SolidLineProps) => ( + + )); + const ThickSolidLine = memo((props: SolidLineProps) => ( + + )); + + return ( + ({ min: min, max: max - 64 }), + domain: { min: -40, max: 40 }, + }, + { + id: 'revenue', + range: ({ min, max }) => ({ min: max - 64, max }), + domain: { min: 100 }, + }, + ]} + > + + + + + + + ); + } +} +``` diff --git a/apps/docs/docs/components/graphs/CartesianChart/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/CartesianChart/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/CartesianChart/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/CartesianChart/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx b/apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx new file mode 100644 index 0000000000..9e9403e658 --- /dev/null +++ b/apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx @@ -0,0 +1,975 @@ +CartesianChart is a customizable, SVG based component that can be used to display a variety of data in a x/y coordinate space. The underlying logic is handled by D3. + +## Basics + +[AreaChart](/components/charts/AreaChart/), [BarChart](/components/charts/BarChart/), and [LineChart](/components/charts/LineChart/) are built on top of CartesianChart and have default functionality for your chart. + +```jsx live + + + + + + + + + + + +``` + +## Series + +Series are the data that will be displayed on the chart. Each series must have a defined `id`. + +### Series Data + +You can pass in an array of numbers or an array of tuples for the `data` prop. Passing in null values is equivalent to no data at that index. + +```jsx live +function ForecastedPrice() { + const ForecastRect = memo(({ startIndex, endIndex }) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + + const xScale = getXScale(); + + if (!xScale) return; + + const startX = xScale(startIndex); + const endX = xScale(endIndex); + return ( + + ); + }); + return ( + + + + + + ); +} +``` + +### Series Axis IDs + +Each series can have a different `yAxisId`, allowing you to compare data from different contexts. + +```jsx live + + + `$${value}k`} + width={60} + /> + `$${value}k`} + /> + + +``` + +### Series Stacks + +You can provide a `stackId` to stack series together. + +```jsx live + + + +``` + +## Axes + +You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` accepts an object or array, while `yAxis` accepts an object or array. + +When `layout="horizontal"`, you can define multiple x-axes (for multiple value scales) but only one y-axis. + +```jsx live + + + + + +``` + +For more info, learn about [XAxis](/components/charts/XAxis/#axis-config) and [YAxis](/components/charts/YAxis/#axis-config) configuration. + +## Inset + +You can adjust the inset around the entire chart (outside the axes) with the `inset` prop. This is useful for when you want to have components that are outside of the drawing area of the data but still within the chart svg. + +You can also remove the default inset, such as to have a compact line chart. + +```jsx live +function Insets() { + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const formatPrice = useCallback((dataIndex) => { + const price = data[dataIndex]; + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + return ( + + + No inset + + + + Custom inset + + + + + + Default inset + + + + + + ); +} +``` + +## Scrubbing + +CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. + +```jsx live +function Scrubbing() { + const [scrubIndex, setScrubIndex] = useState(undefined); + + const onScrubberPositionChange = useCallback((index) => { + setScrubIndex(index); + }, []); + + return ( + + Scrubber index: {scrubIndex ?? 'none'} + + + + + ); +} +``` + +## Animations + +CartesianChart delegates transition control to its child components. You can also disable all animations chart-wide by passing `animate={false}` on CartesianChart. + +### Enter Only + +Disable the update morph animation while keeping a slow enter reveal. Data changes snap instantly but the initial chart appearance animates. Useful when new data arrives frequently and morphing would be distracting. + +```tsx live +function EnterAnimationOnly() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + ); + } + + return ; +} +``` + +### Update Only + +Disable the enter reveal animation while keeping a slow update morph. The chart appears instantly but data changes animate smoothly. Useful when the chart is embedded in content that should not animate on load. + +```tsx live +function UpdateAnimationOnly() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + ); + } + + return ; +} +``` + +### Mixed Transitions Per Child + +Each child component can define its own transitions independently. Here, the `Line` uses a spring morph while the bars snap with no update animation. This lets you fine-tune each visual layer within a single chart. + +```tsx live +function MixedTransitions() { + const dataCount = 10; + const updateInterval = 2000; + + function generateNextValue(prev: number) { + const step = Math.random() * 20 - 10; + return Math.max(10, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + d * 0.3), + color: 'var(--color-accentBoldPurple)', + yAxisId: 'bars', + }, + ]} + xAxis={{ scaleType: 'band' }} + yAxis={[ + { id: 'default' }, + { id: 'bars', range: ({ min, max }) => ({ min: max - 48, max }) }, + ]} + aria-hidden="true" + > + + + + ); + } + + return ; +} +``` + +### No Animations + +You can disable all animations chart-wide by setting `animate` to `false` on CartesianChart. This is useful for static snapshots or when performance is a concern. Compare this to the animated examples above — data still updates, but changes snap instantly without any transition. + +```tsx live +function DisableAnimations() { + const dataCount = 15; + const updateInterval = 2500; + + function generateNextValue(prev: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, prev + step)); + } + + function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; + } + + function Chart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + ); + } + + return ; +} +``` + +## Customization + +### Price with Volume + +You can showcase the price and volume of an asset over time within one chart. + +```jsx live +function PriceWithVolume() { + const [scrubIndex, setScrubIndex] = useState(null); + const btcData = btcCandles.slice(0, 180).reverse(); + + const btcPrices = btcData.map((candle) => parseFloat(candle.close)); + const btcVolumes = btcData.map((candle) => parseFloat(candle.volume)); + const btcDates = btcData.map((candle) => new Date(parseInt(candle.start) * 1000)); + + const formatPrice = useCallback((price) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatPriceInThousands = useCallback((price) => { + return `$${(price / 1000).toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + })}k`; + }, []); + + const formatVolume = useCallback((volume) => { + return `${(volume / 1000).toFixed(2)}K`; + }, []); + + const formatDate = useCallback((date) => { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }, []); + + const displayIndex = scrubIndex ?? btcPrices.length - 1; + const currentPrice = btcPrices[displayIndex]; + const currentVolume = btcVolumes[displayIndex]; + const currentDate = btcDates[displayIndex]; + const priceChange = + displayIndex > 0 + ? (currentPrice - btcPrices[displayIndex - 1]) / btcPrices[displayIndex - 1] + : 0; + + const chartAccessibilityLabel = useMemo(() => { + if (scrubIndex === null) + return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; + }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + + const getScrubberAccessibilityLabel = useCallback( + (dataIndex) => + `Bitcoin on ${formatDate(btcDates[dataIndex])}. Price ${formatPrice(btcPrices[dataIndex])}. Volume ${formatVolume(btcVolumes[dataIndex])}.`, + [btcDates, btcPrices, btcVolumes, formatDate, formatPrice, formatVolume], + ); + + const scrubberLabel = useCallback( + (dataIndex) => `${formatPrice(btcPrices[dataIndex])} ${formatDate(btcDates[dataIndex])}`, + [btcDates, btcPrices, formatDate, formatPrice], + ); + + const ThinSolidLine = memo((props) => ); + + const headerId = useId(); + + return ( + + Bitcoin} + balance={{formatPrice(currentPrice)}} + end={ + + + {formatDate(currentDate)} + {formatVolume(currentVolume)} + + + + + + } + /> + ({ min, max: max - 16 }) }} + yAxis={[ + { + id: 'price', + domain: ({ min, max }) => ({ min: min * 0.9, max }), + }, + { + id: 'volume', + range: ({ min, max }) => ({ min: max - 32, max }), + }, + ]} + accessibilityLabel={chartAccessibilityLabel} + aria-labelledby={headerId} + inset={{ top: 8, left: 8, right: 0, bottom: 0 }} + > + + + + + + + ); +} +``` + +### Earnings History + +You can also create your own type of cartesian chart by using `getSeriesData`, `getXScale`, and `getYScale` directly. + +```jsx live +function EarningsHistory() { + const CirclePlot = memo(({ seriesId, opacity = 1 }) => { + const { drawingArea, getSeries, getSeriesData, getXScale, getYScale } = + useCartesianChartContext(); + const series = getSeries(seriesId); + const data = getSeriesData(seriesId); + const xScale = getXScale(); + const yScale = getYScale(series?.yAxisId); + + if (!xScale || !yScale || !data || !isCategoricalScale(xScale)) return null; + + const yScaleSize = Math.abs(yScale.range()[1] - yScale.range()[0]); + + // Have circle diameter be the smaller of the x scale bandwidth or 10% of the y space available + const diameter = Math.min(xScale.bandwidth(), yScaleSize / 10); + + return ( + + {data.map((value, index) => { + if (value === null || value === undefined) return null; + + // Get x position from band scale - center of the band + const xPos = xScale(index); + if (xPos === undefined) return null; + + const centerX = xPos + xScale.bandwidth() / 2; + + // Get y position from value + const yValue = Array.isArray(value) ? value[1] : value; + const centerY = yScale(yValue); + if (centerY === undefined) return null; + + return ( + + ); + })} + + ); + }); + + const quarters = useMemo(() => ['Q1', 'Q2', 'Q3', 'Q4'], []); + const estimatedEPS = useMemo(() => [1.71, 1.82, 1.93, 2.34], []); + const actualEPS = useMemo(() => [1.68, 1.83, 2.01, 2.24], []); + + const formatEarningAmount = useCallback((value) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const surprisePercentage = useCallback( + (index) => { + const percentage = (actualEPS[index] - estimatedEPS[index]) / estimatedEPS[index]; + const percentageString = percentage.toLocaleString('en-US', { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + return ( + 0 ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)', + fontWeight: 'bold', + }} + > + {percentage > 0 ? '+' : ''} + {percentageString} + + ); + }, + [actualEPS, estimatedEPS], + ); + + const LegendEntry = memo(({ opacity = 1, label }) => { + return ( + + + {label} + + ); + }); + + const LegendDot = memo((props) => { + return ; + }); + + return ( + + + + quarters[index]} /> + + + + + + + + + + ); +} +``` + +### Trading Trends + +You can have multiple axes with different domains and ranges to showcase different pieces of data over the time time period. + +```jsx live +function TradingTrends() { + const profitData = [34, 24, 28, -4, 8, -16, -3, 12, 24, 18, 20, 28]; + const gains = profitData.map((value) => (value > 0 ? value : 0)); + const losses = profitData.map((value) => (value < 0 ? value : 0)); + + const renderProfit = useCallback((value) => { + return `$${value}M`; + }, []); + + const ThinSolidLine = memo((props) => ( + + )); + const ThickSolidLine = memo((props) => ( + + )); + + return ( + ({ min: min, max: max - 64 }), + domain: { min: -40, max: 40 }, + }, + { id: 'revenue', range: ({ min, max }) => ({ min: max - 64, max }), domain: { min: 100 } }, + ]} + > + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/CartesianChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/CartesianChart/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/CartesianChart/_webPropsTable.mdx rename to apps/docs/docs/components/charts/CartesianChart/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/CartesianChart/index.mdx b/apps/docs/docs/components/charts/CartesianChart/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/CartesianChart/index.mdx rename to apps/docs/docs/components/charts/CartesianChart/index.mdx diff --git a/apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json b/apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json new file mode 100644 index 0000000000..abaf43dac8 --- /dev/null +++ b/apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json @@ -0,0 +1,41 @@ +{ + "import": "import { CartesianChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/CartesianChart.tsx", + "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", + "relatedComponents": [ + { + "label": "Point", + "url": "/components/charts/Point/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/CartesianChart/webMetadata.json b/apps/docs/docs/components/charts/CartesianChart/webMetadata.json new file mode 100644 index 0000000000..3b029ade16 --- /dev/null +++ b/apps/docs/docs/components/charts/CartesianChart/webMetadata.json @@ -0,0 +1,29 @@ +{ + "import": "import { CartesianChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/CartesianChart.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-cartesianchart--miscellaneous", + "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", + "relatedComponents": [ + { + "label": "Point", + "url": "/components/charts/Point/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/Legend/_mobileExamples.mdx b/apps/docs/docs/components/charts/Legend/_mobileExamples.mdx new file mode 100644 index 0000000000..c2b1627927 --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/_mobileExamples.mdx @@ -0,0 +1,611 @@ +Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components. + +## Basics + +Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization. Legend's `flexDirection` is automatically set to `row` for top/bottom `legendPosition` and `column` otherwise. + +```jsx +function BasicLegend() { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + return ( + + + + ); +} +``` + +### Position + +Use `legendPosition` to place the legend at different positions around the chart. You can also customize alignment using the `justifyContent` prop on Legend. + +```jsx +function LegendPosition() { + const theme = useTheme(); + + return ( + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: `rgb(${theme.spectrum.yellow40})`, + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: theme.color.fgPositive, + legendShape: 'squircle', + }, + ]} + width="100%" + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + ); +} +``` + +### Shape Variants + +Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`. + +```jsx +function ShapeVariants() { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'pill', + label: 'Pill', + data: [120, 150, 130, 170, 160, 190], + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'pill', + }, + { + id: 'circle', + label: 'Circle', + data: [80, 110, 95, 125, 115, 140], + color: `rgb(${theme.spectrum.green40})`, + legendShape: 'circle', + }, + { + id: 'square', + label: 'Square', + data: [60, 85, 70, 100, 90, 115], + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'square', + }, + { + id: 'squircle', + label: 'Squircle', + data: [40, 60, 50, 75, 65, 85], + color: `rgb(${theme.spectrum.purple40})`, + legendShape: 'squircle', + }, + ]} + width="100%" + xAxis={{ data: months }} + /> + + ); +} +``` + +## Styling + +### Custom Shape + +You can pass a custom ReactNode as `legendShape` for fully custom indicators. On mobile, this uses React Native Skia for rendering dotted patterns. + +```jsx +function CustomLegendShapes() { + const theme = useTheme(); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value) => `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = useMemo(() => { + const indicatorSize = 10; + const legendPatternSize = patternSize / 2; + const legendDotSize = dotSize / 2; + const dottedPath = getDottedAreaPath( + { x: 1, y: 1, width: indicatorSize - 2, height: indicatorSize - 2 }, + legendPatternSize, + legendDotSize, + ); + const skiaPath = Skia.Path.MakeFromSVGString(dottedPath); + const squirclePath = Skia.Path.Make(); + squirclePath.addRRect(Skia.RRectXY(Skia.XYWHRect(1, 1, 8, 8), 2, 2)); + + return ( + + + {skiaPath && } + + + + ); + }, [theme.color.fgPositive]); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = useMemo(() => { + return memo(function DottedBar(props) { + const { x, y, width, height, fill, d } = props; + + const dottedPath = useMemo(() => { + return getDottedAreaPath({ x, y, width, height }, patternSize, dotSize); + }, [x, y, width, height]); + + const barClipPath = useMemo(() => { + return d ? (Skia.Path.MakeFromSVGString(d) ?? undefined) : undefined; + }, [d]); + + const dotsSkiaPath = useMemo(() => { + return Skia.Path.MakeFromSVGString(dottedPath) ?? undefined; + }, [dottedPath]); + + return ( + <> + + {dotsSkiaPath && } + + + + ); + }); + }, []); + + return ( + + ); +} +``` + +## Accessibility + +Use `legendAccessibilityLabel` on chart components to provide a descriptive label for the legend group. This helps screen reader users understand what the legend represents. + +```jsx +function AccessibleLegend() { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +} +``` + +You can also set `accessibilityLabel` directly on the `Legend` component for more control: + +```jsx +function CustomAccessibleLegend() { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="top" + series={[ + { + id: 'online', + label: 'Online Sales', + data: [45, 52, 48, 61, 55, 67], + color: `rgb(${theme.spectrum.blue40})`, + }, + { + id: 'instore', + label: 'In-Store Sales', + data: [38, 41, 44, 39, 47, 51], + color: `rgb(${theme.spectrum.purple40})`, + }, + ]} + width="100%" + xAxis={{ data: months }} + yAxis={{ domain: { min: 0 }, showGrid: true }} + /> + ); +} +``` + +## Composed Examples + +### Dynamic Label + +You can use `EntryComponent` to display a label that updates as a user interacts with the chart. + +```jsx +function DynamicLabel() { + const theme = useTheme(); + const [scrubberPosition, setScrubberPosition] = useState(); + + const timeLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'circle', + }, + ], + [theme.spectrum.blue40, theme.spectrum.gray40, theme.spectrum.orange40], + ); + + const dataLength = seriesConfig[0].data?.length ?? 0; + const dataIndex = scrubberPosition ?? dataLength - 1; + + const ValueLegendEntry = useCallback( + ({ seriesId, label, color, shape }) => { + const seriesData = seriesConfig.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue)}%`; + + return ( + + + {label} + {formattedValue} + + ); + }, + [seriesConfig, dataIndex], + ); + + return ( + } + legendPosition="top" + onScrubberPositionChange={setScrubberPosition} + series={seriesConfig} + width="100%" + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + ); +} +``` + +### Interactive Legend + +You can create an interactive legend that the user can use to toggle to emphasize a specific series. + +```jsx +function InteractiveLegend() { + const theme = useTheme(); + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: 'blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: 'orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: 'green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendEntry = memo(function ChipLegendEntry({ seriesId, label }) { + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? 'gray'; + + const color10 = theme.spectrum[`${baseColor}10`]; + const color50 = theme.spectrum[`${baseColor}50`]; + const color90 = theme.spectrum[`${baseColor}90`]; + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(${isEmphasized ? color90 : color10})`, + borderWidth: 0, + borderRadius: theme.borderRadius[1000], + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(${theme.spectrum[`${config.baseColor}40`]})`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig, theme.spectrum]); + + return ( + } + legendPosition="top" + series={series} + width="100%" + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + ); +} +``` diff --git a/apps/docs/docs/components/charts/Legend/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/Legend/_mobilePropsTable.mdx new file mode 100644 index 0000000000..60808a42b4 --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/_mobilePropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import mobilePropsData from ':docgen/mobile-visualization/chart/legend/Legend/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/charts/Legend/_webExamples.mdx b/apps/docs/docs/components/charts/Legend/_webExamples.mdx new file mode 100644 index 0000000000..def80130fa --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/_webExamples.mdx @@ -0,0 +1,765 @@ +Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components. + +## Basics + +Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization. Legend's `flexDirection` is automatically set to `row` for top/bottom `legendPosition` and `column` otherwise. + +```jsx live +function BasicLegend() { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const numberFormatter = useCallback( + (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + const chartAccessibilityLabel = `Website traffic across ${pages.length} pages showing page views and unique visitors.`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `${pages[index]}: ${numberFormatter(pageViews[index])} page views, ${numberFormatter(uniqueVisitors[index])} unique visitors.`; + }, + [pages, pageViews, uniqueVisitors, numberFormatter], + ); + + return ( + + + + ); +} +``` + +Legend will automatically wrap when there are too many items to fit on one line. + +```jsx live +function WrappedLegend() { + const precipitationData = [ + { + id: 'northeast', + label: 'Northeast', + data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89], + color: 'rgb(var(--blue40))', + }, + { + id: 'upperMidwest', + label: 'Upper Midwest', + data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48], + color: 'rgb(var(--green40))', + }, + { + id: 'ohioValley', + label: 'Ohio Valley', + data: [4.74, 1.83, 3.1, 5.42, 5.69, 3.29, 5.02, 2.57, 4.13, 0.79, 4.31, 3.67], + color: 'rgb(var(--orange40))', + }, + { + id: 'southeast', + label: 'Southeast', + data: [5.48, 3.11, 5.73, 2.97, 5.45, 3.28, 7.18, 5.67, 7.93, 1.33, 2.69, 3.21], + color: 'rgb(var(--yellow40))', + }, + { + id: 'northernRockiesAndPlains', + label: 'Northern Rockies and Plains', + data: [0.64, 1.01, 1.06, 2.12, 3.34, 2.65, 1.54, 1.89, 0.95, 0.57, 1.23, 0.67], + color: 'rgb(var(--indigo40))', + }, + { + id: 'south', + label: 'South', + data: [4.19, 1.79, 2.93, 3.84, 5.25, 3.4, 4.27, 1.84, 3.08, 0.52, 4.5, 2.62], + color: 'rgb(var(--pink40))', + }, + { + id: 'southwest', + label: 'Southwest', + data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25], + color: 'rgb(var(--purple40))', + }, + { + id: 'northwest', + label: 'Northwest', + data: [5.69, 3.67, 3.32, 1.95, 2.08, 1.31, 0.28, 0.81, 0.95, 2.03, 5.45, 5.8], + color: 'rgb(var(--red40))', + }, + { + id: 'west', + label: 'West', + data: [3.39, 4.7, 3.09, 1.07, 0.55, 0.12, 0.23, 0.26, 0.22, 0.4, 2.7, 2.54], + color: 'rgb(var(--teal40))', + }, + ]; + + const xAxisData = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + const chartAccessibilityLabel = `Regional precipitation data across ${precipitationData.length} US regions over 12 months.`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + const month = xAxisData[index]; + const regionValues = precipitationData + .map((region) => `${region.label}: ${region.data[index]} inches`) + .join(', '); + return `${month} precipitation: ${regionValues}`; + }, + [xAxisData, precipitationData], + ); + + return ( + + + + ); +} +``` + +### Position + +Use `legendPosition` to place the legend at different positions around the chart. You can also customize alignment using the `justifyContent` prop on Legend. + +```jsx live +function LegendPosition() { + return ( + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: 'rgb(var(--yellow40))', + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: 'var(--color-fgPositive)', + legendShape: 'squircle', + }, + ]} + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + ); +} +``` + +### Shape Variants + +Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`. + +```jsx live +function ShapeVariants() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +} +``` + +## Styling + +### Custom Shape + +You can pass a custom ReactNode as `legendShape` for fully custom indicators. + +```jsx live +function CustomLegendShapes() { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value) => `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const legendPatternId = useId(); + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = ( + + + + + + + + + + + + + + + ); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = memo((props) => { + const { dataX, x, y } = props; + // Create unique IDs per bar so patterns are scoped to each bar + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + {/* Pattern positioned relative to this bar's origin */} + + + + + + + + + + + + + ); + }); + + return ( + + ); +} +``` + +## Accessibility + +Use `legendAccessibilityLabel` on chart components to provide a descriptive label for the legend group. This helps screen reader users understand what the legend represents. + +```jsx live +function AccessibleLegend() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + const chartAccessibilityLabel = + 'Monthly financial performance chart showing revenue and expenses over 6 months.'; + + return ( + + ); +} +``` + +You can also set `accessibilityLabel` directly on the `Legend` component for more control: + +```jsx live +function CustomAccessibleLegend() { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + } + legendPosition="top" + series={[ + { + id: 'online', + label: 'Online Sales', + data: [45, 52, 48, 61, 55, 67], + color: 'rgb(var(--blue40))', + }, + { + id: 'instore', + label: 'In-Store Sales', + data: [38, 41, 44, 39, 47, 51], + color: 'rgb(var(--purple40))', + }, + ]} + xAxis={{ data: months }} + yAxis={{ domain: { min: 0 }, showGrid: true }} + /> + ); +} +``` + +## Composed Examples + +### Dynamic Label + +You can use `EntryComponent` to display a label that updates as a user interacts with the chart. + +```jsx live +function CustomLegendEntry() { + const timeLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const series = [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: 'rgb(var(--blue40))', + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: 'rgb(var(--orange40))', + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: 'rgb(var(--gray40))', + legendShape: 'circle', + }, + ]; + + const chartAccessibilityLabel = `Candidate polling data over ${timeLabels.length} months showing support percentages for 3 candidates.`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + const month = timeLabels[index]; + const candidateValues = series + .map((s) => { + const value = s.data[index]; + return `${s.label}: ${value === null ? 'no data' : `${value}%`}`; + }) + .join(', '); + return `${month}: ${candidateValues}`; + }, + [timeLabels, series], + ); + + const ValueLegendEntry = memo(function ValueLegendEntry({ seriesId, label, color, shape }) { + const { scrubberPosition } = useScrubberContext(); + const { series: chartSeries, dataLength } = useCartesianChartContext(); + + const dataIndex = scrubberPosition ?? dataLength - 1; + + const seriesData = chartSeries.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue)}%`; + + return ( + + + {label} + + {formattedValue} + + + ); + }); + + return ( + } + legendPosition="top" + series={series} + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + ); +} +``` + +### Interactive Legend + +You can create an interactive legend that the user can use to toggle to emphasize a specific series. + +```jsx live +function InteractiveLegend() { + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: '--blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: '--orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: '--green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendEntry = memo(function ChipLegendEntry({ seriesId, label }) { + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? '--gray'; + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(var(${baseColor}10))`, + borderWidth: 0, + color: 'var(--color-fg)', + outlineColor: `rgb(var(${baseColor}50))`, + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(var(${config.baseColor}40))`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig]); + + return ( + } + legendPosition="top" + series={series} + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + ); +} +``` diff --git a/apps/docs/docs/components/charts/Legend/_webPropsTable.mdx b/apps/docs/docs/components/charts/Legend/_webPropsTable.mdx new file mode 100644 index 0000000000..7b21df026f --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/_webPropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import webPropsData from ':docgen/web-visualization/chart/legend/Legend/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/charts/Legend/_webStyles.mdx b/apps/docs/docs/components/charts/Legend/_webStyles.mdx new file mode 100644 index 0000000000..4374db998e --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/_webStyles.mdx @@ -0,0 +1,67 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Legend, LineChart } from '@coinbase/cds-web-visualization'; + +import webStylesData from ':docgen/web-visualization/chart/legend/Legend/styles-data'; + +## Explorer + +### Bottom + + + {(classNames) => ( + } + legendPosition="bottom" + series={[ + { + id: 'pageViews', + data: [2400, 1398, 9800, 3908, 4800, 3800, 4300], + color: 'rgb(var(--green40))', + label: 'Page Views', + }, + { + id: 'uniqueVisitors', + data: [4000, 3000, 2000, 2780, 1890, 2390, 3490], + color: 'rgb(var(--purple40))', + label: 'Unique Visitors', + areaType: 'dotted', + }, + ]} + /> + )} + + +### Right + + + {(classNames) => ( + } + legendPosition="right" + series={[ + { + id: 'pageViews', + data: [2400, 1398, 9800, 3908, 4800, 3800, 4300], + color: 'rgb(var(--green40))', + label: 'Page Views', + }, + { + id: 'uniqueVisitors', + data: [4000, 3000, 2000, 2780, 1890, 2390, 3490], + color: 'rgb(var(--purple40))', + label: 'Unique Visitors', + areaType: 'dotted', + }, + ]} + /> + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/charts/Legend/index.mdx b/apps/docs/docs/components/charts/Legend/index.mdx new file mode 100644 index 0000000000..68b893552e --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/index.mdx @@ -0,0 +1,38 @@ +--- +id: legend +title: Legend +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/legend/Legend/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/legend/Legend/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webStyles={} + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + webStylesToc={webStylesToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/charts/Legend/mobileMetadata.json b/apps/docs/docs/components/charts/Legend/mobileMetadata.json new file mode 100644 index 0000000000..7ce6bbd017 --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/mobileMetadata.json @@ -0,0 +1,25 @@ +{ + "import": "import { Legend } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/legend/Legend.tsx", + "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + }, + { + "label": "BarChart", + "url": "/components/charts/BarChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Legend/webMetadata.json b/apps/docs/docs/components/charts/Legend/webMetadata.json new file mode 100644 index 0000000000..10d2b3ad05 --- /dev/null +++ b/apps/docs/docs/components/charts/Legend/webMetadata.json @@ -0,0 +1,20 @@ +{ + "import": "import { Legend } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/legend/Legend.tsx", + "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + }, + { + "label": "BarChart", + "url": "/components/charts/BarChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx new file mode 100644 index 0000000000..2e75e87033 --- /dev/null +++ b/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx @@ -0,0 +1,1932 @@ +LineChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) that makes it easy to create standard line charts, supporting a single x/y axis pair. Charts are built using `@shopify/react-native-skia`. + +## Setup + +Before using LineChart, you need to wrap your app with `ChartBridgeProvider`. This enables charts to access CDS theming and other React contexts within the Skia renderer. See [CartesianChart](/components/charts/CartesianChart/#setup) for details. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` array of numbers. + +```jsx + +``` + +## Layout + +Lines can be rendered horizontally or vertically by setting the `layout` prop. + +```jsx +function HorizontalLine() { + const dataset = [ + { month: 'Jan', seoul: 21 }, + { month: 'Feb', seoul: 28 }, + { month: 'Mar', seoul: 41 }, + { month: 'Apr', seoul: 73 }, + { month: 'May', seoul: 99 }, + { month: 'June', seoul: 144 }, + { month: 'July', seoul: 319 }, + { month: 'Aug', seoul: 249 }, + { month: 'Sept', seoul: 131 }, + { month: 'Oct', seoul: 55 }, + { month: 'Nov', seoul: 48 }, + { month: 'Dec', seoul: 25 }, + ]; + + return ( + d.seoul), color: 'var(--color-accentBoldBlue)' }, + ]} + showXAxis + showYAxis + xAxis={{ label: 'rainfall (mm)' }} + yAxis={{ + data: dataset.map((d) => d.month), + }} + /> + ); +} +``` + +LineChart also supports multiple lines, interaction, and axes. +Other props, such as `areaType` can be applied to the chart as a whole or per series. + +```tsx +function MultipleLine() { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`, + [pages, pageViews, uniqueVisitors], + ); + + return ( + + + + ); +} +``` + +## Data + +The data array for each series defines the y values for that series. You can adjust the y values for a series of data by setting the `data` prop on the xAxis. + +```tsx +function DataFormat() { + const yData = useMemo(() => [2, 5.5, 2, 8.5, 1.5, 5], []); + const xData = useMemo(() => [1, 2, 3, 5, 8, 10], []); + + const chartAccessibilityLabel = `Chart with custom X and Y data. ${yData.length} data points.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`, + [xData, yData], + ); + + return ( + + + + ); +} +``` + +### Live Updates + +You can change the data passed in via `series` prop to update the chart. + +You can also use the `useRef` hook to reference the scrubber and pulse it on each update. + +```tsx +function LiveUpdates() { + const scrubberRef = useRef < ScrubberRef > null; + + const initialData = useMemo(() => { + return sparklineInteractiveData.hour.map((d) => d.value); + }, []); + + const [priceData, setPriceData] = useState(initialData); + + const lastDataPointTimeRef = useRef(Date.now()); + const updateCountRef = useRef(0); + + const intervalSeconds = 3600 / initialData.length; + + const maxPercentChange = Math.abs(initialData[initialData.length - 1] - initialData[0]) * 0.05; + + useEffect(() => { + const priceUpdateInterval = setInterval( + () => { + setPriceData((currentData) => { + const newData = [...currentData]; + const lastPrice = newData[newData.length - 1]; + + const priceChange = (Math.random() - 0.5) * maxPercentChange; + const newPrice = Math.round((lastPrice + priceChange) * 100) / 100; + + // Check if we should roll over to a new data point + const currentTime = Date.now(); + const timeSinceLastPoint = (currentTime - lastDataPointTimeRef.current) / 1000; + + if (timeSinceLastPoint >= intervalSeconds) { + // Time for a new data point - remove first, add new at end + lastDataPointTimeRef.current = currentTime; + newData.shift(); // Remove oldest data point + newData.push(newPrice); // Add new data point + updateCountRef.current = 0; + } else { + // Just update the last data point + newData[newData.length - 1] = newPrice; + updateCountRef.current++; + } + + return newData; + }); + + // Pulse the scrubber on each update + scrubberRef.current?.pulse(); + }, + 2000 + Math.random() * 1000, + ); + + return () => clearInterval(priceUpdateInterval); + }, [intervalSeconds, maxPercentChange]); + + const chartAccessibilityLabel = `Live price chart with ${priceData.length} data points.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${priceData[index]}`, + [priceData], + ); + + return ( + + + + ); +} +``` + +### Missing Data + +By default, null values in data create gaps in a line. Use `connectNulls` to skip null values and draw a continuous line. +Note that scrubber beacons and points are still only shown at non-null data values. + +```tsx +function MissingData() { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, null, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, null, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pages.length} pages. Some data points are missing.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const pv = pageViews[index]; + const uv = uniqueVisitors[index]; + const pvStr = pv != null ? pv : 'no data'; + const uvStr = uv != null ? uv : 'no data'; + return `${pages[index]}: ${pvStr} views, ${uvStr} unique visitors.`; + }, + [pages, pageViews, uniqueVisitors], + ); + + const numberFormatter = useCallback( + (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + {/* We can offset the overlay to account for the points being drawn on the lines */} + + + ); +} +``` + +#### Empty State + +```jsx +function EmptyState() { + const theme = useTheme(); + return ( + + ); +} +``` + +### Scales + +LineChart uses `linear` scaling on axes by default, but you can also use other types, such as `log`. See [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis) for more information. + +```jsx + +``` + +## Interaction + +Charts have built in functionality enabled through scrubbing, which can be used by setting `enableScrubbing` to true. You can listen to value changes through `onScrubberPositionChange`. Adding `Scrubber` to LineChart showcases the current scrubber position. + +```tsx +function Interaction() { + const [scrubberPosition, setScrubberPosition] = useState(); + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + const chartAccessibilityLabel = `Price chart with ${data.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${data[index]}`, + [data], + ); + + return ( + + + {scrubberPosition !== undefined + ? `Scrubber position: ${scrubberPosition}` + : 'Not scrubbing'} + + + + + + ); +} +``` + +### Points + +You can use `points` from LineChart to render instances of [Point](/components/charts/Point) at specific data locations with custom styling. + +```jsx +function Points() { + const theme = useTheme(); + const keyMarketShiftIndices = [4, 6, 7, 9, 10]; + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + keyMarketShiftIndices.includes(dataX) + ? { + ...props, + strokeWidth: 2, + stroke: theme.color.bg, + radius: 5, + } + : false + } + seriesId="prices" + /> + + ); +} +``` + +### Performance + +Renders are done on JS thread, other code is in UI + +```tsx +function Performance() { + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + const [scrubberPosition, setScrubberPosition] = useState(); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const sparklineTimePeriodDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + }, + [tabs], + ); + + return ( + + + + + + ); +} + +const PerformanceHeader = memo( + ({ + scrubberPosition, + sparklineTimePeriodDataValues, + }: { + scrubberPosition: number | undefined; + sparklineTimePeriodDataValues: number[]; + }) => { + const theme = useTheme(); + + const formatPriceThousands = useCallback((price: number) => { + return `${new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price / 1000)}k`; + }, []); + + const shownPosition = + scrubberPosition !== undefined ? scrubberPosition : sparklineTimePeriodDataValues.length - 1; + + return ( + + + + + + ); + }, +); + +const PerformanceChart = memo( + ({ + timePeriod, + onScrubberPositionChange, + }: { + timePeriod: TabValue; + onScrubberPositionChange: (position: number | undefined) => void; + }) => { + const theme = useTheme(); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const sparklineTimePeriodDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const sparklineTimePeriodDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const formatPriceThousands = useCallback((price: number) => { + return `${new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price / 1000)}k`; + }, []); + + const formatDate = useCallback((date: Date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const getScrubberLabel = useCallback( + (d: number) => formatDate(sparklineTimePeriodDataTimestamps[d]), + [formatDate, sparklineTimePeriodDataTimestamps], + ); + + const chartAccessibilityLabel = `Bitcoin price chart with high, actual, and low series. ${sparklineTimePeriodDataValues.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = formatPriceThousands(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `Point ${index + 1}: ${price}, ${date}`; + }, + [ + formatDate, + formatPriceThousands, + sparklineTimePeriodDataTimestamps, + sparklineTimePeriodDataValues, + ], + ); + + return ( + d * 1.2), + color: theme.color.fgPositive, + label: 'High Price', + }, + { + id: 'btc', + data: sparklineTimePeriodDataValues, + color: assets.btc.color, + label: 'Actual Price', + }, + { + id: 'low', + data: sparklineTimePeriodDataValues.map((d) => d * 0.8), + color: theme.color.fgNegative, + label: 'Low Price', + }, + ]} + xAxis={{ range: ({ min, max }) => ({ min, max: max - 16 }) }} + yAxis={{ showGrid: true, tickLabelFormatter: formatPriceThousands }} + > + + + ); + }, +); +``` + +### Gestures + +By default, charts will not track gestures that go outside of the chart bounds. You can allow overflow gestures by setting `allowOverflowGestures` to `true`. + +```jsx + `Point ${index + 1}`} + ... +> + ... + +``` + +## Animations + +You can configure chart transitions using `transitions` on Line (or LineChart) and `transitions` on [Scrubber](/components/charts/Scrubber). The `transitions` prop accepts an object with `enter` (the reveal animation) and `update` (data change animation) keys. Set either to `null` to disable that animation phase. You can also disable animations by setting `animate` on LineChart to `false`. + +```tsx + + +``` + +Also, you can toggle animations by setting `animate` to `true` or `false`. + +```tsx + +``` + +## Accessibility + +Use `accessibilityLabel` on `LineChart` (or `CartesianChart`) to provide both: + +- a summary label when focus first lands on the chart +- point-by-point labels while swiping through scrubber targets + +`getScrubberAccessibilityLabel` defines the per-segment text announced by screen readers. You do not need to add a `Scrubber` component for accessibility—`enableScrubbing` with `getScrubberAccessibilityLabel` is sufficient. BarChart and other charts work the same way. + +```tsx +function BasicAccessible() { + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + // Chart-level accessibility label provides overview when focus lands on the chart + const chartAccessibilityLabel = useMemo( + () => + `Price chart showing trend over ${data.length} data points. Current value: ${data[data.length - 1]}. Swipe to navigate.`, + [data], + ); + + // Per-segment label announced when screen reader user taps or swipes to a segment + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Price at position ${index + 1} of ${data.length}: ${data[index]}`, + [data], + ); + + return ( + + + + ); +} +``` + +## Styling + +### Axes + +Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis). + +```tsx + `Day ${dataX}`, + }} + yAxis={{ + showGrid: true, + showLine: true, + showTickMarks: true, + }} +/> +``` + +### Fonts + +By default, charts will use the default font of the system. You can use `fontFamily` at the chart level to customize this. For more, see [Skia's documentation on fonts](https://shopify.github.io/react-native-skia/docs/text/paragraph/#fonts). + +```jsx + + ... + +``` + +You can also use `fontProvider` along with `useFonts` from Skia if you need to load a custom font. + +```jsx +const fontProvider = useFonts({ + MyCustomFontFamily: [ + require("./MyCustomFont-Regular.ttf"), + require("./MyCustomFont-Bold.ttf"), + ], +}); + +return ( + + ... + +); +``` + +### Gradients + +Gradients can be applied to the y-axis (default) or x-axis. Each stop requires an `offset`, which is based on the data within the x/y scale and `color`, with an optional `opacity` (defaults to 1). + +Values in between stops will be interpolated smoothly using [srgb color space](https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty). + +```tsx +function Gradients() { + const theme = useTheme(); + const spectrumColors: ThemeVars.SpectrumHue[] = [ + 'blue', + 'green', + 'orange', + 'yellow', + 'gray', + 'indigo', + 'pink', + 'purple', + 'red', + 'teal', + 'chartreuse', + ]; + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); + + return ( + + + {spectrumColors.map((color) => ( + setCurrentSpectrumColor(color)} + style={{ + backgroundColor: `rgb(${theme.spectrum[`${color}20`]})`, + borderColor: `rgb(${theme.spectrum[`${color}50`]})`, + borderWidth: 2, + }} + width={16} + /> + ))} + + d + 50), + // You can create a "discrete" gradient by having multiple stops at the same offset + gradient: { + stops: ({ min, max }) => [ + // Allows a function which accepts min/max or direct array + { offset: min, color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})` }, + { + offset: min + (max - min) / 3, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})`, + }, + { + offset: min + (max - min) / 3, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}50`]})`, + }, + { + offset: min + ((max - min) / 3) * 2, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}50`]})`, + }, + { + offset: min + ((max - min) / 3) * 2, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})`, + }, + { offset: max, color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})` }, + ], + }, + }, + { + id: 'xAxisGradient', + data: data.map((d) => d + 100), + gradient: { + // You can also configure by the x-axis. + axis: 'x', + stops: ({ min, max }) => [ + { + offset: min, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})`, + opacity: 0, + }, + { + offset: max, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})`, + opacity: 1, + }, + ], + }, + }, + ]} + strokeWidth={4} + yAxis={{ + showGrid: true, + }} + /> + + ); +} +``` + +You can even pass in a separate gradient for your `Line` and `Area` components. + +```tsx +function GainLossChart() { + const theme = useTheme(); + const data = useMemo(() => [-40, -28, -21, -5, 48, -5, -28, 2, -29, -46, 16, -30, -29, 8], []); + const negativeColor = `rgb(${theme.spectrum.gray15})`; + const positiveColor = theme.color.fgPositive; + + const tickLabelFormatter = useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + // Line gradient: hard color change at 0 (full opacity for line) + const lineGradient = { + stops: [ + { offset: 0, color: negativeColor }, + { offset: 0, color: positiveColor }, + ], + }; + + const GradientDottedArea = memo((props: DottedAreaProps) => ( + [ + { offset: min, color: negativeColor, opacity: 0.4 }, + { offset: 0, color: negativeColor, opacity: 0 }, + { offset: 0, color: positiveColor, opacity: 0 }, + { offset: max, color: positiveColor, opacity: 0.4 }, + ], + }} + /> + )); + + const chartAccessibilityLabel = `Price chart with ${data.length} data points.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${data[index]}`, + [data], + ); + + return ( + ({ min, max: max - 16 }), + }} + > + + + + + ); +} +``` + +### Legend + +Using `legend` will add a default [Legend](/components/charts/Legend) to your chart. + +You can use `legendPosition` to place the legend at different positions around the chart. + +```tsx + + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + }} +/> +``` + +### Lines + +You can customize lines by placing props in `LineChart` or at each individual series. Lines can have a `type` of `solid` or `dotted`. They can optionally show an area underneath them (using `showArea`). + +```jsx + +``` + +You can also add instances of [ReferenceLine](/components/charts/ReferenceLine) to your LineChart to highlight a specific x or y value. + +```tsx +function ReferenceLineExample() { + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + const chartAccessibilityLabel = `Price chart with reference line at 10. ${data.length} data points.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${data[index]}`, + [data], + ); + + return ( + ({ min, max: max - 24 }) }} + > + } + dataY={10} + stroke={theme.color.fg} + /> + + + ); +} +``` + +### Points + +You can also add instances of [Point](/components/charts/Point) directly inside of a LineChart. + +```tsx +function HighLowPrice() { + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + const minPrice = Math.min(...data); + const maxPrice = Math.max(...data); + + const minPriceIndex = data.indexOf(minPrice); + const maxPriceIndex = data.indexOf(maxPrice); + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + return ( + + + + + ); +} +``` + +### Scrubber + +When using [Scrubber](/components/charts/Scrubber) with series that have labels, labels will automatically render to the side of the scrubber beacon. + +You can customize the line used for and which series will render a scrubber beacon. + +You can have scrubber beacon's pulse by either adding `idlePulse` to Scrubber or use Scrubber's ref to dynamically pulse. + +```tsx +function StylingScrubber() { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${pages[index]}: ${pageViews[index]} views, ${uniqueVisitors[index]} unique visitors.`, + [pages, pageViews, uniqueVisitors], + ); + + const numberFormatter = useCallback( + (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + ); +} +``` + +### Sizing + +Charts by default take up `100%` of the `width` and `height` available, but can be customized as any other component. + +#### Compact + +You can also have charts in a compact form. + +```tsx +function Compact() { + const theme = useTheme(); + const dimensions = { width: 62, height: 18 }; + + const sparklineData = prices + .map((price) => parseFloat(price)) + .filter((price, index) => index % 10 === 0); + const positiveFloor = Math.min(...sparklineData) - 10; + + const negativeData = sparklineData.map((price) => -1 * price).reverse(); + const negativeCeiling = Math.max(...negativeData) + 10; + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + type CompactChartProps = { + data: number[]; + showArea?: boolean; + color?: string; + referenceY: number; + }; + + const CompactChart = memo(({ data, showArea, color, referenceY }: CompactChartProps) => ( + + + + + + )); + + const ChartCell = memo( + ({ + data, + showArea, + color, + referenceY, + subdetail, + }: CompactChartProps & { subdetail: string }) => { + return ( + + } + media={} + onPress={() => console.log('clicked')} + spacingVariant="condensed" + style={{ padding: 0 }} + subdetail={subdetail} + /> + ); + }, + ); + + return ( + + + + + + + ); +} +``` + +## Composed Examples + +### Asset Price with Dotted Area + +You can use [PeriodSelector](/components/charts/PeriodSelector) to have a chart where the user can select a time period and the chart automatically animates. + +```tsx +function AssetPriceWithDottedArea() { + const fontMgr = useMemo(() => { + const fontProvider = Skia.TypefaceFontProvider.Make(); + // Register system fonts if available, otherwise Skia will use defaults + return fontProvider; + }, []); + + const BTCTab: TabComponent = memo( + forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + const AssetPriceDotted = memo(() => { + const theme = useTheme(); + const currentPrice = + sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value; + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; + }, [timePeriod]); + + const sparklineTimePeriodDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const sparklineTimePeriodDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const onPeriodChange = useCallback( + (period: TabValue | null) => { + setTimePeriod(period || tabs[0]); + }, + [tabs, setTimePeriod], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const chartAccessibilityLabel = useMemo( + () => + `Bitcoin price chart for ${timePeriod.label} period. Current price: ${formatPrice(currentPrice)}. Swipe to navigate.`, + [currentPrice, formatPrice, timePeriod.label], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = formatPrice(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `${price} ${date}`; + }, + [formatDate, formatPrice, sparklineTimePeriodDataTimestamps, sparklineTimePeriodDataValues], + ); + + return ( + + {formatPrice(currentPrice)}} + end={ + + + + } + title={Bitcoin} + /> + + { + const date = formatDate(sparklineTimePeriodDataTimestamps[d]); + const price = formatPrice(sparklineTimePeriodDataValues[d]); + + const regularStyle: SkTextStyle = { + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(theme.color.fgMuted), + }; + + const boldStyle: SkTextStyle = { + fontFamilies: ['Inter'], + ...regularStyle, + fontStyle: { + weight: FontWeight.Bold, + }, + }; + + // 3. Use the ParagraphBuilder + const builder = Skia.ParagraphBuilder.Make( + { + textAlign: TextAlign.Left, + }, + fontMgr, + ); + + builder.pushStyle(boldStyle); + builder.addText(price); + + builder.pushStyle(regularStyle); + builder.addText(` ${date}`); + + const para = builder.build(); + para.layout(512); + return para; + }} + labelElevated + /> + + + + ); + }); + + return ; +} +``` + +### Monotone Asset Price + +You can adjust [YAxis](/components/charts/YAxis) and [Scrubber](/components/charts/Scrubber) to have a chart where the y-axis is overlaid and the beacon is inverted in style. + +```tsx +function MonotoneAssetPrice() { + const theme = useTheme(); + const prices = sparklineInteractiveData.hour; + + const fontMgr = useMemo(() => { + const fontProvider = Skia.TypefaceFontProvider.Make(); + // Register system fonts if available, otherwise Skia will use defaults + return fontProvider; + }, []); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const scrubberPriceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [], + ); + + const formatPrice = useCallback( + (price: number) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date: Date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const scrubberLabel = useCallback( + (index: number) => { + const price = scrubberPriceFormatter.format(prices[index].value); + const date = formatDate(prices[index].date); + + const regularStyle: SkTextStyle = { + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(theme.color.fgMuted), + }; + + const boldStyle: SkTextStyle = { + fontFamilies: ['Inter'], + ...regularStyle, + fontStyle: { + weight: FontWeight.Bold, + }, + }; + + const builder = Skia.ParagraphBuilder.Make( + { + textAlign: TextAlign.Left, + }, + fontMgr, + ); + + builder.pushStyle(boldStyle); + builder.addText(`${price} USD`); + + builder.pushStyle(regularStyle); + builder.addText(` ${date}`); + + const para = builder.build(); + para.layout(512); + return para; + }, + [scrubberPriceFormatter, prices, formatDate, theme.color.fgMuted, fontMgr], + ); + + const formatAxisLabelPrice = useCallback( + (price: number) => { + return formatPrice(price); + }, + [formatPrice], + ); + + // Custom tick label component with offset positioning + const CustomYAxisTickLabel = useCallback( + (props: any) => , + [], + ); + + const InvertedBeacon = useMemo( + () => (props) => ( + + ), + [theme.color.fg, theme.color.bg], + ); + + const chartAccessibilityLabel = `Price chart with ${prices.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = scrubberPriceFormatter.format(prices[index].value); + const date = formatDate(prices[index].date); + return `${price} USD ${date}`; + }, + [formatDate, prices, scrubberPriceFormatter], + ); + + return ( + price.value), + color: theme.color.fg, + gradient: { + axis: 'x', + stops: ({ min }) => [ + { offset: min, color: theme.color.fg, opacity: 0 }, + { offset: 32, color: theme.color.fg, opacity: 1 }, + ], + }, + }, + ]} + xAxis={{ + range: ({ max }) => ({ min: 96, max }), + }} + yAxis={{ + position: 'left', + width: 0, + showGrid: true, + tickLabelFormatter: formatAxisLabelPrice, + TickLabelComponent: CustomYAxisTickLabel, + }} + > + + + ); +} +``` + +### Service Availability + +You can have irregular data points by passing in `data` to `xAxis`. + +```jsx +function ServiceAvailability() { + const theme = useTheme(); + const availabilityEvents = useMemo( + () => [ + { date: new Date('2022-01-01'), availability: 79 }, + { date: new Date('2022-01-03'), availability: 81 }, + { date: new Date('2022-01-04'), availability: 82 }, + { date: new Date('2022-01-06'), availability: 91 }, + { date: new Date('2022-01-07'), availability: 92 }, + { date: new Date('2022-01-10'), availability: 86 }, + ], + [], + ); + + return ( + event.availability), + gradient: { + stops: ({ min, max }) => [ + { offset: min, color: theme.color.fgNegative }, + { offset: 85, color: theme.color.fgNegative }, + { offset: 85, color: theme.color.fgWarning }, + { offset: 90, color: theme.color.fgWarning }, + { offset: 90, color: theme.color.fgPositive }, + { offset: max, color: theme.color.fgPositive }, + ], + }, + }, + ]} + xAxis={{ + data: availabilityEvents.map((event) => event.date.getTime()), + }} + yAxis={{ + domain: ({ min, max }) => ({ min: Math.max(min - 2, 0), max: Math.min(max + 2, 100) }), + }} + > + new Date(value).toLocaleDateString()} + /> + `${value}%`} + /> + ({ + ...props, + fill: theme.color.bg, + stroke: props.fill, + })} + seriesId="availability" + /> + + + ); +} +``` + +### Forecast Asset Price + +You can combine multiple lines within a series to change styles dynamically. + +```tsx +function ForecastAssetPrice() { + const startYear = 2020; + const data = [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70]; + const currentIndex = 6; + + const strokeWidth = 3; + // To prevent cutting off the edge of our lines + const clipOffset = strokeWidth; + + const axisFormatter = useCallback( + (dataIndex: number) => { + return `${startYear + dataIndex}`; + }, + [startYear], + ); + + const HistoricalLineComponent = memo((props: SolidLineProps) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); + + const historicalClipPath = useMemo(() => { + if (!xScale || !drawingArea) return null; + + const currentX = xScale(currentIndex); + if (currentX === undefined) return null; + + // Create clip path for historical data (left side) + const clip = Skia.Path.Make(); + clip.addRect({ + x: drawingArea.x - clipOffset, + y: drawingArea.y - clipOffset, + width: currentX + clipOffset - drawingArea.x, + height: drawingArea.height + clipOffset * 2, + }); + return clip; + }, [xScale, drawingArea]); + + if (!historicalClipPath) return null; + + return ( + + + + ); + }); + + // Since the solid and dotted line have different curves, + // we need two separate line components. Otherwise we could + // have one line component with SolidLine and DottedLine inside + // of it and two clipPaths. + const ForecastLineComponent = memo((props: DottedLineProps) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); + + const forecastClipPath = useMemo(() => { + if (!xScale || !drawingArea) return null; + + const currentX = xScale(currentIndex); + if (currentX === undefined) return null; + + // Create clip path for forecast data (right side) + const clip = Skia.Path.Make(); + clip.addRect({ + x: currentX, + y: drawingArea.y - clipOffset, + width: drawingArea.x + drawingArea.width - currentX + clipOffset * 2, + height: drawingArea.height + clipOffset * 2, + }); + return clip; + }, [xScale, drawingArea]); + + if (!forecastClipPath) return null; + + return ( + + + + ); + }); + const CustomScrubber = memo(() => { + const { scrubberPosition } = useScrubberContext(); + + const idleScrubberOpacity = useDerivedValue( + () => (scrubberPosition.value === undefined ? 1 : 0), + [scrubberPosition], + ); + const scrubberOpacity = useDerivedValue( + () => (scrubberPosition.value !== undefined ? 1 : 0), + [scrubberPosition], + ); + + // Fade in animation for the Scrubber + const fadeInOpacity = useSharedValue(0); + + useEffect(() => { + fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); + }, [fadeInOpacity]); + + return ( + + + + + + + + + ); + }); + + return ( + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/LineChart/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/LineChart/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/LineChart/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/LineChart/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/LineChart/_webExamples.mdx b/apps/docs/docs/components/charts/LineChart/_webExamples.mdx new file mode 100644 index 0000000000..4dd1889daa --- /dev/null +++ b/apps/docs/docs/components/charts/LineChart/_webExamples.mdx @@ -0,0 +1,1891 @@ +LineChart is a wrapper for [CartesianChart](/components/charts/CartesianChart) that makes it easy to create standard line charts, supporting a single x/y axis pair. Charts are built using SVGs. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` array of numbers. + +```jsx live + +``` + +LineChart also supports multiple lines, interaction, and axes. +Other props, such as `areaType` can be applied to the chart as a whole or per series. + +```jsx live +function MultipleLine() { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`; + }, + [pages, pageViews, uniqueVisitors], + ); + + const numberFormatter = useCallback( + (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + ); +} +``` + +## Data + +The data array for each series defines the y values for that series. You can adjust the y values for a series of data by setting the `data` prop on the xAxis. + +```jsx live +function DataFormat() { + const yData = useMemo(() => [2, 5.5, 2, 8.5, 1.5, 5], []); + const xData = useMemo(() => [1, 2, 3, 5, 8, 10], []); + + const chartAccessibilityLabel = `Chart with custom X and Y data. ${yData.length} data points`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`; + }, + [xData, yData], + ); + + return ( + + + + ); +} +``` + +### Live Updates + +You can change the data passed in via `series` prop to update the chart. + +You can also use the `useRef` hook to reference the scrubber and pulse it on each update. + +```jsx live +function LiveUpdates() { + const scrubberRef = useRef(); + + const initialData = useMemo(() => { + return sparklineInteractiveData.hour.map((d) => d.value); + }, []); + + const [priceData, setPriceData] = useState(initialData); + + const lastDataPointTimeRef = useRef(Date.now()); + const updateCountRef = useRef(0); + + const intervalSeconds = 3600 / initialData.length; + + const maxPercentChange = Math.abs(initialData[initialData.length - 1] - initialData[0]) * 0.05; + + useEffect(() => { + const priceUpdateInterval = setInterval( + () => { + setPriceData((currentData) => { + const newData = [...currentData]; + const lastPrice = newData[newData.length - 1]; + + const priceChange = (Math.random() - 0.5) * maxPercentChange; + const newPrice = Math.round((lastPrice + priceChange) * 100) / 100; + + // Check if we should roll over to a new data point + const currentTime = Date.now(); + const timeSinceLastPoint = (currentTime - lastDataPointTimeRef.current) / 1000; + + if (timeSinceLastPoint >= intervalSeconds) { + // Time for a new data point - remove first, add new at end + lastDataPointTimeRef.current = currentTime; + newData.shift(); // Remove oldest data point + newData.push(newPrice); // Add new data point + updateCountRef.current = 0; + } else { + // Just update the last data point + newData[newData.length - 1] = newPrice; + updateCountRef.current++; + } + + return newData; + }); + + // Pulse the scrubber on each update + scrubberRef.current?.pulse(); + }, + 2000 + Math.random() * 1000, + ); + + return () => clearInterval(priceUpdateInterval); + }, [intervalSeconds, maxPercentChange]); + + return ( + + + + ); +} +``` + +### Missing Data + +By default, null values in data create gaps in a line. Use `connectNulls` to skip null values and draw a continuous line. +Note that scrubber beacons and points are still only shown at non-null data values. + +```jsx live +function MissingData() { + const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; + const pageViews = [2400, 1398, null, 3908, 4800, 3800, 4300]; + const uniqueVisitors = [4000, 3000, null, 2780, 1890, 2390, 3490]; + + const numberFormatter = useCallback( + (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + {/* We can offset the overlay to account for the points being drawn on the lines */} + + + ); +} +``` + +#### Empty State + +```jsx live + +``` + +### Scales + +LineChart uses `linear` scaling on axes by default, but you can also use other types, such as `log`. See [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis) for more information. + +```jsx live + +``` + +## Interaction + +Charts have built in functionality enabled through scrubbing, which can be used by setting `enableScrubbing` to true. You can listen to value changes through `onScrubberPositionChange`. Adding `Scrubber` to LineChart showcases the current scrubber position. + +```jsx live +function Interaction() { + const [scrubberPosition, setScrubberPosition] = useState(); + + return ( + + + {scrubberPosition !== undefined + ? `Scrubber position: ${scrubberPosition}` + : 'Not scrubbing'} + + + + + + ); +} +``` + +### Points + +You can use `points` from LineChart with `onClick` listeners to render instances of [Point](/components/charts/Point) that are interactable. + +```jsx live +function Points() { + const keyMarketShiftIndices = [4, 6, 7, 9, 10]; + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + + + keyMarketShiftIndices.includes(dataX) + ? { + ...props, + strokeWidth: 2, + stroke: 'var(--color-bg)', + radius: 5, + onClick: () => + alert( + `You have clicked a key market shift at position ${dataX + 1} with value ${dataY}!`, + ), + accessibilityLabel: `Key market shift point at position ${dataX + 1}, value ${dataY}. Click to view details.`, + } + : false + } + seriesId="prices" + /> + + ); +} +``` + +## Animations + +You can configure chart transitions using `transitions`. The `transitions` prop accepts an object with `enter` (the clip-path reveal animation) and `update` (data change morph animation) keys. Set either to `null` to disable that animation phase. You can also disable all animations by setting `animate` on LineChart to `false`. + +```jsx live +function Transitions() { + const dataCount = 20; + const maxDataOffset = 15000; + const minStepOffset = 2500; + const maxStepOffset = 10000; + const domainLimit = 20000; + const updateInterval = 500; + + const myTransitionConfig = { type: 'spring', stiffness: 700, damping: 20 }; + const negativeColor = 'rgb(var(--gray15))'; + const positiveColor = 'var(--color-fgPositive)'; + + function generateNextValue(previousValue) { + const range = maxStepOffset - minStepOffset; + const offset = Math.random() * range + minStepOffset; + + let direction; + if (previousValue >= maxDataOffset) { + direction = -1; + } else if (previousValue <= -maxDataOffset) { + direction = 1; + } else { + direction = Math.random() < 0.5 ? -1 : 1; + } + + let newValue = previousValue + offset * direction; + newValue = Math.max(-maxDataOffset, Math.min(maxDataOffset, newValue)); + return newValue; + } + + function generateInitialData() { + const data = []; + + let previousValue = Math.random() * 2 * maxDataOffset - maxDataOffset; + data.push(previousValue); + + for (let i = 1; i < dataCount; i++) { + const newValue = generateNextValue(previousValue); + data.push(newValue); + previousValue = newValue; + } + + return data; + } + + const MyGradient = memo((props) => { + const areaGradient = { + stops: ({ min, max }) => [ + { offset: min, color: negativeColor, opacity: 1 }, + { offset: 0, color: negativeColor, opacity: 0 }, + { offset: 0, color: positiveColor, opacity: 0 }, + { offset: max, color: positiveColor, opacity: 1 }, + ], + }; + + return ; + }); + + function CustomTransitionsChart() { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((currentData) => { + const lastValue = currentData[currentData.length - 1] ?? 0; + const newValue = generateNextValue(lastValue); + + return [...currentData.slice(1), newValue]; + }); + }, updateInterval); + + return () => clearInterval(intervalId); + }, []); + + const tickLabelFormatter = useCallback( + (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + const valueAtIndexFormatter = useCallback( + (dataIndex) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(data[dataIndex]), + [data], + ); + + const lineGradient = { + stops: [ + { offset: 0, color: negativeColor }, + { offset: 0, color: positiveColor }, + ], + }; + + return ( + + ); + } + + return ; +} +``` + +## Accessibility + +You can use `accessibilityLabel` on both the chart and the scrubber to provide descriptive labels. The chart's label gives an overview, while the scrubber's label provides specific information about the current data point being viewed. + +```jsx live +function BasicAccessible() { + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + // Chart-level accessibility label provides overview + const chartAccessibilityLabel = useMemo(() => { + const currentPrice = data[data.length - 1]; + return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; + }, [data]); + + // Scrubber-level accessibility label provides specific position info + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; + }, + [data], + ); + + return ( + + + + ); +} +``` + +When a chart has a visible header or title, you can use `aria-labelledby` to reference it, and still provide a dynamic scrubber accessibility label. + +```jsx live +function AccessibleWithHeader() { + const headerId = useId(); + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + // Display label provides overview + const displayLabel = useMemo( + () => `Revenue chart showing trend. Current value: ${data[data.length - 1]}`, + [data], + ); + + // Scrubber-specific accessibility label + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `Viewing position ${index + 1} of ${data.length}, value: ${data[index]}`; + }, + [data], + ); + + return ( + + + {displayLabel} + + + + + + ); +} +``` + +## Styling + +### Axes + +Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis). + +```jsx live + `Day ${dataX}`, + }} + yAxis={{ + showGrid: true, + showLine: true, + showTickMarks: true, + }} +/> +``` + +### Gradients + +Gradients can be applied to the y-axis (default) or x-axis. Each stop requires an `offset`, which is based on the data within the x/y scale and `color`, with an optional `opacity` (defaults to 1). + +Values in between stops will be interpolated smoothly using [srgb color space](https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty). + +```jsx live +function Gradients() { + const spectrumColors = [ + 'blue', + 'green', + 'orange', + 'yellow', + 'gray', + 'indigo', + 'pink', + 'purple', + 'red', + 'teal', + 'chartreuse', + ]; + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); + + return ( + + + {spectrumColors.map((color) => ( + setCurrentSpectrumColor(color)} + style={{ + backgroundColor: `rgb(var(--${color}20))`, + border: `2px solid rgb(var(--${color}50))`, + outlineColor: `rgb(var(--${color}80))`, + outline: + currentSpectrumColor === color ? `2px solid rgb(var(--${color}80))` : undefined, + }} + width={{ base: 16, tablet: 24, desktop: 24 }} + /> + ))} + + d + 50), + // You can create a "discrete" gradient by having multiple stops at the same offset + gradient: { + stops: ({ min, max }) => [ + // Allows a function which accepts min/max or direct array + { offset: min, color: `rgb(var(--${currentSpectrumColor}80))` }, + { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}80))` }, + { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}50))` }, + { + offset: min + ((max - min) / 3) * 2, + color: `rgb(var(--${currentSpectrumColor}50))`, + }, + { + offset: min + ((max - min) / 3) * 2, + color: `rgb(var(--${currentSpectrumColor}20))`, + }, + { offset: max, color: `rgb(var(--${currentSpectrumColor}20))` }, + ], + }, + }, + { + id: 'xAxisGradient', + data: data.map((d) => d + 100), + gradient: { + // You can also configure by the x-axis. + axis: 'x', + stops: ({ min, max }) => [ + { offset: min, color: `rgb(var(--${currentSpectrumColor}80))`, opacity: 0 }, + { offset: max, color: `rgb(var(--${currentSpectrumColor}20))`, opacity: 1 }, + ], + }, + }, + ]} + strokeWidth={4} + yAxis={{ + showGrid: true, + }} + /> + + ); +} +``` + +You can even pass in a separate gradient for your `Line` and `Area` components. + +```jsx live +function GainLossChart() { + const data = useMemo(() => [-40, -28, -21, -5, 48, -5, -28, 2, -29, -46, 16, -30, -29, 8], []); + const negativeColor = 'rgb(var(--gray15))'; + const positiveColor = 'var(--color-fgPositive)'; + + const tickLabelFormatter = useCallback( + (value) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + // Line gradient: hard color change at 0 (full opacity for line) + const lineGradient = { + stops: [ + { offset: 0, color: negativeColor }, + { offset: 0, color: positiveColor }, + ], + }; + + const chartAccessibilityLabel = `Gain/Loss chart showing price changes. Current value: ${tickLabelFormatter(data[data.length - 1])}`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + const value = data[index]; + const status = value >= 0 ? 'gain' : 'loss'; + return `Position ${index + 1} of ${data.length}: ${tickLabelFormatter(value)} ${status}`; + }, + [data, tickLabelFormatter], + ); + + const GradientDottedArea = memo((props) => ( + [ + { offset: min, color: negativeColor, opacity: 0.4 }, + { offset: 0, color: negativeColor, opacity: 0 }, + { offset: 0, color: positiveColor, opacity: 0 }, + { offset: max, color: positiveColor, opacity: 0.4 }, + ], + }} + /> + )); + + return ( + ({ min, max: max - 16 }), + }} + > + + + + + ); +} +``` + +### Legend + +Using `legend` will add a default [Legend](/components/charts/Legend) to your chart. + +You can use `legendPosition` to place the legend at different positions around the chart. + +```jsx live + + new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + }} +/> +``` + +### Lines + +You can customize lines by placing props in `LineChart` or at each individual series. Lines can have a `type` of `solid` or `dotted`. They can optionally show an area underneath them (using `showArea`). + +```jsx live + +``` + +You can also add instances of [ReferenceLine](/components/charts/ReferenceLine) to your LineChart to highlight a specific x or y value. + +```jsx live + ({ min, max: max - 24 }), + }} +> + } + dataY={10} + stroke="var(--color-fg)" + /> + + +``` + +### Points + +You can also add instances of [Point](/components/charts/Point) directly inside of a LineChart. + +```jsx live +function HighLowPrice() { + const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + const minPrice = Math.min(...data); + const maxPrice = Math.max(...data); + + const minPriceIndex = data.indexOf(minPrice); + const maxPriceIndex = data.indexOf(maxPrice); + + const formatPrice = useCallback((price) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + return ( + + + + + ); +} +``` + +### Scrubber + +When using [Scrubber](/components/charts/Scrubber) with series that have labels, labels will automatically render to the side of the scrubber beacon. + +You can customize the line used for and which series will render a scrubber beacon. + +You can have scrubber beacon's pulse by either adding `idlePulse` to Scrubber or use Scrubber's ref to dynamically pulse. + +```jsx live +function StylingScrubber() { + const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; + const pageViews = [2400, 1398, 9800, 3908, 4800, 3800, 4300]; + const uniqueVisitors = [4000, 3000, 2000, 2780, 1890, 2390, 3490]; + + const numberFormatter = useCallback( + (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + ); +} +``` + +### Sizing + +Charts by default take up `100%` of the `width` and `height` available, but can be customized as any other component. + +```jsx live +function DynamicChartSizing() { + const candles = [...btcCandles].reverse(); + const prices = candles.map((candle) => parseFloat(candle.close)); + const highs = candles.map((candle) => parseFloat(candle.high)); + const lows = candles.map((candle) => parseFloat(candle.low)); + + const latestPrice = prices[prices.length - 1]; + const previousPrice = prices[prices.length - 2]; + const change24h = ((latestPrice - previousPrice) / previousPrice) * 100; + + function DetailCell({ title, description }) { + return ( + + + {title} + + {description} + + ); + } + + // Calculate 7-day moving average + const calculateMA = (data, period) => { + const ma = []; + for (let i = 0; i < data.length; i++) { + if (i >= period - 1) { + const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); + ma.push(sum / period); + } + } + return ma; + }; + + const ma7 = calculateMA(prices, 7); + const latestMA7 = ma7[ma7.length - 1]; + + const periodHigh = Math.max(...highs); + const periodLow = Math.min(...lows); + + const formatPrice = useCallback((price) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatPercentage = useCallback((value) => { + const sign = value >= 0 ? '+' : ''; + return `${sign}${value.toFixed(2)}%`; + }, []); + + return ( + + + {/* LineChart fills to take up available width and height */} + + + + + BTC + {formatPrice(latestPrice)} + + + + + + + + + + ); +} +``` + +#### Compact + +You can also have charts in a compact form. + +```jsx live +function Compact() { + const dimensions = { width: 62, height: 18 }; + + const sparklineData = prices + .map((price) => parseFloat(price)) + .filter((price, index) => index % 10 === 0); + const positiveFloor = Math.min(...sparklineData) - 10; + + const negativeData = sparklineData.map((price) => -1 * price).reverse(); + const negativeCeiling = Math.max(...negativeData) + 10; + + const formatPrice = useCallback((price) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const CompactChart = memo(({ data, showArea, color, referenceY }) => ( + + + + + + )); + + const ChartCell = memo(({ data, showArea, color, referenceY, subdetail }) => { + const { isPhone } = useBreakpoints(); + + return ( + + } + media={} + onClick={() => console.log('clicked')} + spacingVariant="condensed" + style={{ padding: 0 }} + subdetail={subdetail} + title={isPhone ? undefined : assets.btc.name} + /> + ); + }); + + return ( + + + + + + + ); +} +``` + +## Composed Examples + +### Asset Price with Dotted Area + +You can use [PeriodSelector](/components/charts/PeriodSelector) to have a chart where the user can select a time period and the chart automatically animates. + +```jsx live +function AssetPriceWithDottedArea() { + const BTCTab = memo( + forwardRef(({ label, ...props }, ref) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + + const BTCActiveIndicator = memo(({ style, ...props }) => ( + + )); + + const AssetPriceDotted = memo(() => { + const currentPrice = + sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value; + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + + const sparklineTimePeriodData = useMemo(() => { + return sparklineInteractiveData[timePeriod.id]; + }, [timePeriod]); + + const sparklineTimePeriodDataValues = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.value); + }, [sparklineTimePeriodData]); + + const sparklineTimePeriodDataTimestamps = useMemo(() => { + return sparklineTimePeriodData.map((d) => d.date); + }, [sparklineTimePeriodData]); + + const onPeriodChange = useCallback( + (period) => { + setTimePeriod(period || tabs[0]); + }, + [tabs, setTimePeriod], + ); + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const scrubberPriceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const formatDate = useCallback((date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const scrubberLabel = useCallback( + (index) => { + const price = scrubberPriceFormatter.format(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return ( + <> + {price} USD {date} + + ); + }, + [ + scrubberPriceFormatter, + sparklineTimePeriodDataValues, + sparklineTimePeriodDataTimestamps, + formatDate, + ], + ); + + const chartAccessibilityLabel = `Bitcoin price chart for ${timePeriod.label} period. Current price: ${formatPrice(currentPrice)}`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + const price = scrubberPriceFormatter.format(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `${price} USD ${date}`; + }, + [ + scrubberPriceFormatter, + sparklineTimePeriodDataValues, + sparklineTimePeriodDataTimestamps, + formatDate, + ], + ); + + return ( + + {formatPrice(currentPrice)}} + end={ + + + + } + style={{ padding: 0 }} + title={Bitcoin} + /> + + + + + + ); + }); + + return ; +} +``` + +### Monotone Asset Price + +You can adjust [YAxis](/components/charts/YAxis) and [Scrubber](/components/charts/Scrubber) to have a chart where the y-axis is overlaid and the beacon is inverted in style. + +```jsx live +function MonotoneAssetPrice() { + const prices = sparklineInteractiveData.hour; + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + + const scrubberPriceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [], + ); + + const formatPrice = useCallback( + (price) => { + return priceFormatter.format(price); + }, + [priceFormatter], + ); + + const CustomYAxisTickLabel = useCallback( + (props) => , + [], + ); + + const formatDate = useCallback((date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const scrubberLabel = useCallback( + (index) => { + const price = scrubberPriceFormatter.format(prices[index].value); + const date = formatDate(prices[index].date); + return ( + <> + {price} USD {date} + + ); + }, + [scrubberPriceFormatter, prices, formatDate], + ); + + const InvertedBeacon = useMemo( + () => (props) => ( + + ), + [], + ); + + return ( + price.value), + color: 'var(--color-fg)', + gradient: { + axis: 'x', + stops: ({ min, max }) => [ + { offset: min, color: 'var(--color-fg)', opacity: 0 }, + { offset: 32, color: 'var(--color-fg)', opacity: 1 }, + ], + }, + }, + ]} + style={{ outlineColor: 'var(--color-fg)' }} + xAxis={{ + range: ({ min, max }) => ({ min: 96, max: max }), + }} + yAxis={{ + position: 'left', + width: 0, + showGrid: true, + tickLabelFormatter: formatPrice, + TickLabelComponent: CustomYAxisTickLabel, + }} + > + + + ); +} +``` + +### Asset Price Widget + +```jsx live +function AssetPriceWidget() { + const { isPhone } = useBreakpoints(); + const prices = [...btcCandles].reverse().map((candle) => parseFloat(candle.close)); + const latestPrice = prices[prices.length - 1]; + + const formatPrice = (price) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price); + }; + + const formatPercentChange = (price) => { + return new Intl.NumberFormat('en-US', { + style: 'percent', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price); + }; + + const percentChange = (latestPrice - prices[0]) / prices[0]; + + const chartAccessibilityLabel = `Bitcoin price chart. Current price: ${formatPrice(latestPrice)}. Change: ${formatPercentChange(percentChange)}`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + return `Bitcoin price at position ${index + 1}: ${formatPrice(prices[index])}`; + }, + [prices], + ); + + return ( + + + + {!isPhone && ( + + + BTC + + + Bitcoin + + + )} + + + {formatPrice(latestPrice)} + + + +{formatPercentChange(percentChange)} + + + + + + + + + + ); +} +``` + +### Service Availability + +You can have irregular data points by passing in `data` to `xAxis`. + +```jsx live +function ServiceAvailability() { + const availabilityEvents = useMemo( + () => [ + { date: new Date('2022-01-01'), availability: 79 }, + { date: new Date('2022-01-03'), availability: 81 }, + { date: new Date('2022-01-04'), availability: 82 }, + { date: new Date('2022-01-06'), availability: 91 }, + { date: new Date('2022-01-07'), availability: 92 }, + { date: new Date('2022-01-10'), availability: 86 }, + ], + [], + ); + + const chartAccessibilityLabel = `Availability chart showing ${availabilityEvents.length} data points over time`; + + const getScrubberAccessibilityLabel = useCallback( + (index) => { + const event = availabilityEvents[index]; + const formattedDate = event.date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const status = + event.availability >= 90 ? 'Good' : event.availability >= 85 ? 'Warning' : 'Critical'; + return `${formattedDate}: Availability ${event.availability}% - Status: ${status}`; + }, + [availabilityEvents], + ); + + return ( + event.availability), + gradient: { + stops: ({ min, max }) => [ + { offset: min, color: 'var(--color-fgNegative)' }, + { offset: 85, color: 'var(--color-fgNegative)' }, + { offset: 85, color: 'var(--color-fgWarning)' }, + { offset: 90, color: 'var(--color-fgWarning)' }, + { offset: 90, color: 'var(--color-fgPositive)' }, + { offset: max, color: 'var(--color-fgPositive)' }, + ], + }, + }, + ]} + xAxis={{ + data: availabilityEvents.map((event) => event.date.getTime()), + }} + yAxis={{ + domain: ({ min, max }) => ({ min: Math.max(min - 2, 0), max: Math.min(max + 2, 100) }), + }} + > + new Date(value).toLocaleDateString()} + /> + `${value}%`} + /> + ({ + ...props, + fill: 'var(--color-bg)', + stroke: props.fill, + })} + seriesId="availability" + /> + + + ); +} +``` + +### Forecast Asset Price + +You can combine multiple lines within a series to change styles dynamically. + +```jsx live +function ForecastAssetPrice() { + const startYear = 2020; + const data = [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70]; + const currentIndex = 6; + + const strokeWidth = 3; + // To prevent cutting off the edge of our lines + const clipOffset = strokeWidth; + + const axisFormatter = useCallback( + (dataIndex) => { + return startYear + dataIndex; + }, + [startYear], + ); + + const HistoricalLineComponent = memo((props) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); + + if (!xScale || !drawingArea) return; + + const currentX = xScale(currentIndex); + + if (currentX === undefined) return; + + return ( + <> + + + + + + + + + + ); + }); + + // Since the solid and dotted line have different curves, + // we need two separate line components. Otherwise we could + // have one line component with SolidLine and DottedLine inside + // of it and two clipPaths. + const ForecastLineComponent = memo((props) => { + const { drawingArea, getXScale } = useCartesianChartContext(); + const xScale = getXScale(); + + if (!xScale || !drawingArea) return; + + const currentX = xScale(currentIndex); + + if (currentX === undefined) return; + + return ( + <> + + + + + + + + + + ); + }); + + const CustomScrubber = memo(() => { + const { scrubberPosition } = useScrubberContext(); + const isScrubbing = scrubberPosition !== undefined; + // We need a fade in animation for the Scrubber + return ( + + + + + + + + + ); + }); + + return ( + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/LineChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/LineChart/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/LineChart/_webPropsTable.mdx rename to apps/docs/docs/components/charts/LineChart/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/LineChart/index.mdx b/apps/docs/docs/components/charts/LineChart/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/LineChart/index.mdx rename to apps/docs/docs/components/charts/LineChart/index.mdx diff --git a/apps/docs/docs/components/charts/LineChart/mobileMetadata.json b/apps/docs/docs/components/charts/LineChart/mobileMetadata.json new file mode 100644 index 0000000000..519805196f --- /dev/null +++ b/apps/docs/docs/components/charts/LineChart/mobileMetadata.json @@ -0,0 +1,45 @@ +{ + "import": "import { LineChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/LineChart.tsx", + "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Point", + "url": "/components/charts/Point/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/LineChart/webMetadata.json b/apps/docs/docs/components/charts/LineChart/webMetadata.json new file mode 100644 index 0000000000..1854a03e78 --- /dev/null +++ b/apps/docs/docs/components/charts/LineChart/webMetadata.json @@ -0,0 +1,38 @@ +{ + "import": "import { LineChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/LineChart.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-linechart--all", + "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Point", + "url": "/components/charts/Point/" + }, + { + "label": "ReferenceLine", + "url": "/components/charts/ReferenceLine/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/PercentageBarChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/PercentageBarChart/_mobileExamples.mdx new file mode 100644 index 0000000000..f093306c04 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/_mobileExamples.mdx @@ -0,0 +1,681 @@ +PercentageBarChart is a wrapper for [BarChart](/components/charts/BarChart) that simplifies the creation of segmented, part-to-whole horizontal visualizations. Charts are built using `@shopify/react-native-skia`. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a value for `data`. + +```jsx + +``` + +## Stack Gap + +Use `stackGap` to add space between segments while keeping the full bar length. + +```jsx + +``` + +## Border Radius + +Bars use `borderRadius` like in [BarChart](/components/charts/BarChart/#border-radius). + +```jsx + +``` + +## Data + +**Negative** values, **`null`**, and **missing indices** from a shorter `data` array are treated as **zero** for that segment at that category. A **single-number** `data` value applies to the **first** category only—later categories count as zero for that series. + +```jsx + +``` + +If **every** group sums to zero after clamping, nothing is drawn—handle that in surrounding UI. + +## Customization + +### Bar Stack Spacing + +Use `categoryPadding` on the band axis to adjust spacing between stacks. + +```jsx + +``` + +### Minimum Bar Size + +`barMinSize` keeps a thin share wide enough to see or tap when one segment dominates: + +```jsx + +``` + +### Custom Components + +#### Slanted Stack Gap + +A custom `BarComponent` that replaces the default rectangular inner edges with **slanted cuts**, creating a parallelogram-shaped gap purely from the path geometry—no `stackGap` needed. Outer ends stay pill-shaped. + +```jsx +function SlantedStackExample() { + function getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + pillLeft, + pillRight, + slantDx, + ) { + if (width <= 0 || height <= 0 || pillLeft === pillRight) return undefined; + + const r = Math.min(borderRadius, height / 2, width / 2); + const s = Math.min(Math.max(0, slantDx), width - r * 2); + const x0 = x, + x1 = x + width, + y0 = y, + y1 = y + height; + + if (pillLeft && !pillRight) { + return [ + `M ${x0 + r} ${y0}`, + `L ${x1} ${y0}`, + `L ${x1 - s} ${y1}`, + `L ${x0 + r} ${y1}`, + `A ${r} ${r} 0 0 1 ${x0} ${y1 - r}`, + `L ${x0} ${y0 + r}`, + `A ${r} ${r} 0 0 1 ${x0 + r} ${y0}`, + 'Z', + ].join(' '); + } + + return [ + `M ${x0 + s} ${y0}`, + `L ${x1 - r} ${y0}`, + `A ${r} ${r} 0 0 1 ${x1} ${y0 + r}`, + `L ${x1} ${y1 - r}`, + `A ${r} ${r} 0 0 1 ${x1 - r} ${y1}`, + `L ${x0} ${y1}`, + 'Z', + ].join(' '); + } + + const SLANT_DX = 8; + + const SlantedStackBar = memo(function SlantedStackBar(props) { + const { layout } = useCartesianChartContext(); + const { + x, + y, + width, + height, + borderRadius = 4, + roundTop, + roundBottom, + dataX, + d: defaultD, + fill, + fillOpacity, + origin: _origin, + dataY: _dataY, + seriesId: _seriesId, + minSize: _minSize, + ...rest + } = props; + + const d = useMemo(() => { + if (layout !== 'horizontal') { + return ( + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + } + const isLeftmost = Array.isArray(dataX) && Math.abs(dataX[0]) < 1; + return ( + getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + isLeftmost, + !isLeftmost, + SLANT_DX, + ) ?? + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + }, [layout, defaultD, dataX, x, y, width, height, borderRadius, roundTop, roundBottom]); + + if (!d) return null; + + return ( + + ); + }); + + return ( + + ); +} +``` + +#### Dotted bar + +A custom `BarComponent` can render a **dotted fill** (Skia path from `getDottedAreaPath` plus an outlined `DefaultBar`). Set `BarComponent` on **one series** to emphasize a single segment, or on the **chart** to apply the same look to every segment. + +```jsx +function DottedBarExamples() { + const DOTTED_BAR_PATTERN_SIZE = 4; + const DOTTED_BAR_DOT_SIZE = 1; + const DOTTED_BAR_OUTLINE_STROKE_WIDTH = 2; + + const DottedBarComponent = memo(function DottedBarComponent(props) { + const { x, y, width, height, fill, d } = props; + + const dottedPath = useMemo( + () => + getDottedAreaPath({ x, y, width, height }, DOTTED_BAR_PATTERN_SIZE, DOTTED_BAR_DOT_SIZE), + [x, y, width, height], + ); + + const barClipPath = useMemo( + () => (d ? (Skia.Path.MakeFromSVGString(d) ?? undefined) : undefined), + [d], + ); + + const dotsSkiaPath = useMemo( + () => (dottedPath ? (Skia.Path.MakeFromSVGString(dottedPath) ?? undefined) : undefined), + [dottedPath], + ); + + return ( + <> + + {dotsSkiaPath && fill ? : null} + + + + ); + }); + + const dottedBarSeries = [ + { + id: 'segment-a', + data: 60, + label: 'Segment A', + color: `rgb(${theme.spectrum.teal60})`, + BarComponent: DottedBarComponent, + }, + { id: 'segment-b', data: 30, label: 'Segment B', color: `rgb(${theme.spectrum.chartreuse50})` }, + { id: 'segment-c', data: 10, label: 'Segment C', color: `rgb(${theme.spectrum.indigo40})` }, + ]; + + const dottedBarSeriesPlain = [ + { id: 'segment-a', data: 60, label: 'Segment A', color: `rgb(${theme.spectrum.teal60})` }, + { id: 'segment-b', data: 30, label: 'Segment B', color: `rgb(${theme.spectrum.chartreuse50})` }, + { id: 'segment-c', data: 10, label: 'Segment C', color: `rgb(${theme.spectrum.indigo40})` }, + ]; + + return ( + + + + First series only + + + + + + Chart-level BarComponent + + + + + ); +} +``` + +## Animations + +Configure motion with the `transitions` prop. Toggle motion with `animate`. + +```jsx +function AnimationsExample() { + const [animate, setAnimate] = useState(true); + + function randomShares() { + const raw = [Math.random() + 0.1, Math.random() + 0.1, Math.random() + 0.1]; + const sum = raw[0] + raw[1] + raw[2]; + return raw.map((v) => Math.max(1, Math.round((v / sum) * 100))); + } + + function generateData() { + return [randomShares(), randomShares(), randomShares()]; + } + + const [data, setData] = useState(generateData); + + useEffect(() => { + const id = setInterval(() => setData(generateData()), 800); + return () => clearInterval(id); + }, []); + + const series = [ + { id: 'btc', data: data.map((q) => q[0]), label: 'BTC', color: assets.btc.color }, + { + id: 'eth', + data: data.map((q) => q[1]), + label: 'ETH', + color: assets.eth.color, + }, + { + id: 'other', + data: data.map((q) => q[2]), + label: 'Other', + color: theme.color.fgMuted, + }, + ]; + + return ( + + + setAnimate((v) => !v)}> + Animate + + + `${value}%`, + }} + yAxis={{ + categoryPadding: 0.75, + data: ['Q1 2025', 'Q2 2025', 'Q3 2025'], + position: 'left', + requestedTickCount: 5, + showTickMarks: true, + }} + /> + + ); +} +``` + +### Stagger Delay + +```jsx + +``` + +### Delay + +```jsx + +``` + +## Accessibility + +Unlike [BarChart](/components/charts/BarChart/), `PercentageBarChart` does **not** expose scrubbing props. Provide an `accessibilityLabel` on the chart so assistive technologies can describe the visualization. Optionally set `legendAccessibilityLabel` when using the built-in legend. + +```jsx + +``` + +## Composed Examples + +### Live-updating Data + +Using a custom legend, you can create a prediction markets-style chart that stays in sync when data changes. + +```jsx +function LiveFeedExample() { + const liveFeedSubtitleBase = 100; + const liveFeedYesDollarsPerPercentPoint = (182 - liveFeedSubtitleBase) / 50; + const liveFeedNoDollarsPerPercentPoint = (222 - liveFeedSubtitleBase) / 50; + + function getLiveFeedProjectedValue(seriesId, percentage) { + const inverseShare = 100 - percentage; + if (seriesId === 'yes') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedYesDollarsPerPercentPoint); + } + if (seriesId === 'no') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedNoDollarsPerPercentPoint); + } + return undefined; + } + + const liveFeedCurrencyFormat = { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }; + + const LiveFeedCTALegendEntry = memo(function LiveFeedCTALegendEntry({ seriesId, label, color }) { + const { series } = useCartesianChartContext(); + const seriesData = series.find((s) => s.id === seriesId); + const percentage = seriesData?.data?.[0] ?? 0; + const projectedValue = getLiveFeedProjectedValue(seriesId, percentage); + + return ( + + ); + }); + + function LiveFeedChart() { + const [tick, setTick] = useState(0); + + const yesValue = 50 + Math.sin(tick * 0.05) * 49; + const noValue = 50 - Math.sin(tick * 0.05) * 49; + + const series = [ + { id: 'yes', data: yesValue, label: 'Yes', color: theme.color.fgPositive }, + { id: 'no', data: noValue, label: 'No', color: theme.color.fgNegative }, + ]; + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 4), 1000); + return () => clearInterval(id); + }, []); + + return ( + + } + legendPosition="bottom" + series={series} + stackGap={2} + /> + ); + } + + return ; +} +``` + +### Vertical Mix + +Monthly **BTC / ETH / Other** portfolio allocation across a full year, with `layout="vertical"` and the legend on the right. + +```jsx + +``` + +### Buy vs Sell + +You can combine a PercentageBarChart with a custom legend to create a buy vs sell chart. + +```jsx +function BuyVsSellExample() { + const series = [ + { id: 'buy', data: 76, color: theme.color.fgPositive, legendShape: 'circle' }, + { id: 'sell', data: 24, color: theme.color.fgNegative, legendShape: 'square' }, + ]; + + function BuyVsSellLegend() { + const [buy, sell] = series; + return ( + + + {`${buy.data}% bought`} + + } + seriesId={buy.id} + shape={buy.legendShape} + /> + + {`${sell.data}% sold`} + + } + seriesId={sell.id} + shape={sell.legendShape} + /> + + ); + } + + return ( + + + + + ); +} +``` diff --git a/apps/docs/docs/components/charts/PercentageBarChart/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/PercentageBarChart/_mobilePropsTable.mdx new file mode 100644 index 0000000000..d0fe2d5718 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/_mobilePropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import mobilePropsData from ':docgen/mobile-visualization/chart/bar/PercentageBarChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/charts/PercentageBarChart/_webExamples.mdx b/apps/docs/docs/components/charts/PercentageBarChart/_webExamples.mdx new file mode 100644 index 0000000000..fd47ba94b8 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/_webExamples.mdx @@ -0,0 +1,675 @@ +PercentageBarChart is a wrapper for [BarChart](/components/charts/BarChart) that simplifies the creation of segmented, part-to-whole horizontal visualizations. Charts are built using SVGs. + +## Basics + +The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a value for `data`. + +```jsx live + +``` + +## Stack Gap + +Use `stackGap` to add space between segments while keeping the full bar length. + +```jsx live + +``` + +## Border Radius + +Bars use `borderRadius` like in [BarChart](/components/charts/BarChart/#border-radius). + +```jsx live + +``` + +## Data + +**Negative** values, **`null`**, and **missing indices** from a shorter `data` array are treated as **zero** for that segment at that category. A **single-number** `data` value applies to the **first** category only—later categories count as zero for that series. + +```jsx live + +``` + +If **every** group sums to zero after clamping, nothing is drawn—handle that in surrounding UI (empty state or copy). + +## Customization + +### Bar Stack Spacing + +Use `categoryPadding` on the band axis to adjust spacing between stacks. + +```jsx live + +``` + +### Minimum Bar Size + +`barMinSize` enforces a minimum pixel size for **individual** segments (non-zero values), similar to `BarChart`. Use it when a small share would otherwise be too narrow to see or interact with: + +```jsx live + +``` + +### Custom Components + +#### Slanted Stack Gap + +A custom `BarComponent` that replaces the default rectangular inner edges with **slanted cuts**, creating a parallelogram-shaped gap purely from the path geometry—no `stackGap` needed. Outer ends stay pill-shaped. + +```jsx live +function SlantedStackExample() { + function getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + pillLeft, + pillRight, + slantDx, + ) { + if (width <= 0 || height <= 0 || pillLeft === pillRight) return undefined; + + const r = Math.min(borderRadius, height / 2, width / 2); + const s = Math.min(Math.max(0, slantDx), width - r * 2); + const x0 = x, + x1 = x + width, + y0 = y, + y1 = y + height; + + if (pillLeft && !pillRight) { + return [ + `M ${x0 + r} ${y0}`, + `L ${x1} ${y0}`, + `L ${x1 - s} ${y1}`, + `L ${x0 + r} ${y1}`, + `A ${r} ${r} 0 0 1 ${x0} ${y1 - r}`, + `L ${x0} ${y0 + r}`, + `A ${r} ${r} 0 0 1 ${x0 + r} ${y0}`, + 'Z', + ].join(' '); + } + + return [ + `M ${x0 + s} ${y0}`, + `L ${x1 - r} ${y0}`, + `A ${r} ${r} 0 0 1 ${x1} ${y0 + r}`, + `L ${x1} ${y1 - r}`, + `A ${r} ${r} 0 0 1 ${x1 - r} ${y1}`, + `L ${x0} ${y1}`, + 'Z', + ].join(' '); + } + + const SLANT_DX = 8; + + const SlantedStackBar = memo(function SlantedStackBar(props) { + const { layout } = useCartesianChartContext(); + const { + x, + y, + width, + height, + borderRadius = 4, + roundTop, + roundBottom, + dataX, + d: defaultD, + fill, + fillOpacity, + ...rest + } = props; + + const d = useMemo(() => { + if (layout !== 'horizontal') { + return ( + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + } + const isLeftmost = Array.isArray(dataX) && Math.abs(dataX[0]) < 1; + return ( + getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + isLeftmost, + !isLeftmost, + SLANT_DX, + ) ?? + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + }, [layout, defaultD, dataX, x, y, width, height, borderRadius, roundTop, roundBottom]); + + if (!d) return null; + + return ( + + ); + }); + + return ( + + ); +} +``` + +#### Dotted bar + +A custom `BarComponent` can render a **dotted fill** (SVG pattern mask plus outline). Set `BarComponent` on **one series** to emphasize a single segment, or on the **chart** to apply the same look to every segment. + +```jsx live +function DottedBarExamples() { + const DOTTED_BAR_OUTLINE_STROKE_WIDTH = 2; + + const DottedBarComponent = memo((props) => { + const { + dataX, + x, + y, + width, + height, + borderRadius = 4, + roundTop = true, + roundBottom = true, + } = props; + const { layout } = useCartesianChartContext(); + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const outlineInset = DOTTED_BAR_OUTLINE_STROKE_WIDTH / 2; + + const outlineGeometry = useMemo(() => { + const insetWidth = width - 2 * outlineInset; + const insetHeight = height - 2 * outlineInset; + if (insetWidth <= 0 || insetHeight <= 0) { + return null; + } + const insetX = x + outlineInset; + const insetY = y + outlineInset; + const insetRadius = Math.max(0, borderRadius - outlineInset); + return { + d: getBarPath( + insetX, + insetY, + insetWidth, + insetHeight, + insetRadius, + roundTop, + roundBottom, + layout, + ), + height: insetHeight, + width: insetWidth, + x: insetX, + y: insetY, + }; + }, [borderRadius, height, layout, outlineInset, roundBottom, roundTop, width, x, y]); + + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + + + + + + + + + + + {outlineGeometry ? ( + + ) : ( + + )} + + ); + }); + + const dottedBarSeries = [ + { + id: 'segment-a', + data: 60, + label: 'Segment A', + color: 'rgb(var(--teal60))', + BarComponent: DottedBarComponent, + }, + { id: 'segment-b', data: 30, label: 'Segment B', color: 'rgb(var(--chartreuse50))' }, + { id: 'segment-c', data: 10, label: 'Segment C', color: 'rgb(var(--indigo40))' }, + ]; + + const dottedBarSeriesPlain = [ + { id: 'segment-a', data: 60, label: 'Segment A', color: 'rgb(var(--teal60))' }, + { id: 'segment-b', data: 30, label: 'Segment B', color: 'rgb(var(--chartreuse50))' }, + { id: 'segment-c', data: 10, label: 'Segment C', color: 'rgb(var(--indigo40))' }, + ]; + + return ( + + + + First series only + + + + + + Chart-level BarComponent + + + + + ); +} +``` + +## Animations + +Configure motion with the `transitions` prop (forwarded to `BarChart`). Toggle motion with `animate`. + +```jsx live +function AnimationsExample() { + const [animate, setAnimate] = useState(true); + + function randomShares() { + const raw = [Math.random() + 0.1, Math.random() + 0.1, Math.random() + 0.1]; + const sum = raw[0] + raw[1] + raw[2]; + return raw.map((v) => Math.max(1, Math.round((v / sum) * 100))); + } + + function generateData() { + return [randomShares(), randomShares(), randomShares()]; + } + + const [data, setData] = useState(generateData); + + useEffect(() => { + const id = setInterval(() => setData(generateData()), 800); + return () => clearInterval(id); + }, []); + + const series = [ + { id: 'btc', data: data.map((q) => q[0]), label: 'BTC', color: assets.btc.color }, + { + id: 'eth', + data: data.map((q) => q[1]), + label: 'ETH', + color: assets.eth.color, + }, + { + id: 'other', + data: data.map((q) => q[2]), + label: 'Other', + color: 'var(--color-fgMuted)', + }, + ]; + + return ( + + + setAnimate((v) => !v)}> + Animate + + + `${value}%`, + }} + yAxis={{ + categoryPadding: 0.75, + data: ['Q1 2025', 'Q2 2025', 'Q3 2025'], + position: 'left', + requestedTickCount: 5, + showTickMarks: true, + }} + /> + + ); +} +``` + +## Composed Examples + +### Live-updating Data + +Using a custom legend, you can create a prediction markets-style chart that stays in sync when data changes. + +```jsx live +function LiveFeedExample() { + const liveFeedSubtitleBase = 100; + const liveFeedYesDollarsPerPercentPoint = (182 - liveFeedSubtitleBase) / 50; + const liveFeedNoDollarsPerPercentPoint = (222 - liveFeedSubtitleBase) / 50; + + function getLiveFeedProjectedValue(seriesId, percentage) { + const inverseShare = 100 - percentage; + if (seriesId === 'yes') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedYesDollarsPerPercentPoint); + } + if (seriesId === 'no') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedNoDollarsPerPercentPoint); + } + return undefined; + } + + const liveFeedCurrencyFormat = { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 0, + }; + + const LiveFeedCTALegendEntry = memo(function LiveFeedCTALegendEntry({ seriesId, label, color }) { + const { series } = useCartesianChartContext(); + const seriesData = series.find((s) => s.id === seriesId); + const percentage = seriesData?.data?.[0] ?? 0; + const projectedValue = getLiveFeedProjectedValue(seriesId, percentage); + + return ( + + ); + }); + + function LiveFeedChart() { + const [tick, setTick] = useState(0); + + const yesValue = 50 + Math.sin(tick * 0.05) * 49; + const noValue = 50 - Math.sin(tick * 0.05) * 49; + + const series = [ + { id: 'yes', data: yesValue, label: 'Yes', color: 'var(--color-fgPositive)' }, + { id: 'no', data: noValue, label: 'No', color: 'var(--color-fgNegative)' }, + ]; + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 4), 1000); + return () => clearInterval(id); + }, []); + + return ( + + } + legendPosition="bottom" + series={series} + stackGap={2} + /> + ); + } + + return ; +} +``` + +### Vertical Mix + +Monthly **BTC / ETH / Other** portfolio allocation across a full year, with `layout="vertical"` and the legend on the right. + +```jsx live + +``` + +### Buy vs Sell + +You can combine a PercentageBarChart with a custom legend to create a buy vs sell chart. + +```jsx live +function BuyVsSellExample() { + const series = [ + { id: 'buy', data: 76, color: 'var(--color-fgPositive)', legendShape: 'circle' }, + { id: 'sell', data: 24, color: 'var(--color-fgNegative)', legendShape: 'square' }, + ]; + + function BuyVsSellLegend() { + const [buy, sell] = series; + return ( + + + {`${buy.data}% bought`} + + } + seriesId={buy.id} + shape={buy.legendShape} + /> + + {`${sell.data}% sold`} + + } + seriesId={sell.id} + shape={sell.legendShape} + /> + + ); + } + + return ( + + + + + ); +} +``` diff --git a/apps/docs/docs/components/charts/PercentageBarChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/PercentageBarChart/_webPropsTable.mdx new file mode 100644 index 0000000000..3e51b3bfd7 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/_webPropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import webPropsData from ':docgen/web-visualization/chart/bar/PercentageBarChart/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/charts/PercentageBarChart/index.mdx b/apps/docs/docs/components/charts/PercentageBarChart/index.mdx new file mode 100644 index 0000000000..1443fa76ee --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/index.mdx @@ -0,0 +1,39 @@ +--- +id: percentageBarChart +title: PercentageBarChart +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/bar/PercentageBarChart/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/bar/PercentageBarChart/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/charts/PercentageBarChart/mobileMetadata.json b/apps/docs/docs/components/charts/PercentageBarChart/mobileMetadata.json new file mode 100644 index 0000000000..e63d6b58f4 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/mobileMetadata.json @@ -0,0 +1,37 @@ +{ + "import": "import { PercentageBarChart } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx", + "description": "A bar chart component for comparing share or mix across categories as percentages. Supports horizontal and vertical orientations, 100% stacked bars, and a fixed 0–100% value axis.", + "relatedComponents": [ + { + "label": "BarChart", + "url": "/components/charts/BarChart/" + }, + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/PercentageBarChart/webMetadata.json b/apps/docs/docs/components/charts/PercentageBarChart/webMetadata.json new file mode 100644 index 0000000000..8136f16db8 --- /dev/null +++ b/apps/docs/docs/components/charts/PercentageBarChart/webMetadata.json @@ -0,0 +1,30 @@ +{ + "import": "import { PercentageBarChart } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-percentagebarchart--all", + "description": "A bar chart component for comparing share or mix across categories as percentages. Supports horizontal and vertical orientations, 100% stacked bars, and a fixed 0–100% value axis.", + "relatedComponents": [ + { + "label": "BarChart", + "url": "/components/charts/BarChart/" + }, + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/PeriodSelector/_mobileExamples.mdx b/apps/docs/docs/components/charts/PeriodSelector/_mobileExamples.mdx new file mode 100644 index 0000000000..b97f3d8639 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/_mobileExamples.mdx @@ -0,0 +1,198 @@ +PeriodSelector is a specialized [SegmentedTabs](/components/navigation/SegmentedTabs) optimized for chart time-period selection. It provides a transparent background, primary wash active state, and full-width layout by default. + +## Basics + +```jsx +function Example() { + const tabs = [ + { id: '1H', label: '1H' }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ]; + + const [activeTab, setActiveTab] = useState(tabs[0]); + + return ; +} +``` + +## Sizing + +Set `width` to `fit-content` to make the selector only as wide as its content, and use `gap` to control spacing between tabs. + +```jsx +function Example() { + const tabs = [ + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + ]; + + const [activeTab, setActiveTab] = useState(tabs[0]); + + return ( + + ); +} +``` + +## Live Indicator + +Use the `LiveTabLabel` component (exported from PeriodSelector) to indicate a live data period. Pair it with a conditional `activeBackground` to visually differentiate the live state. + +```jsx +function Example() { + const tabs = useMemo( + () => [ + { id: '1H', label: }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[0]); + const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); + + const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); + + return ( + + ); +} +``` + +## Overflow + +When there are too many tabs to fit in a single row, wrap the selector in a horizontal `ScrollView` with an optional action button. + +```jsx +function Example() { + const tabs = useMemo( + () => [ + { id: '1H', label: }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + { id: '1Y', label: '1Y' }, + { id: '5Y', label: '5Y' }, + { id: 'All', label: 'All' }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[0]); + const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); + + const activeBackground = useMemo(() => (!isLive ? 'bgPrimaryWash' : 'bgNegativeWash'), [isLive]); + + return ( + + + + + + + ); +} +``` + +## Customization + +### Custom Colors + +Use `TabComponent` and `TabsActiveIndicatorComponent` to fully brand the selector. This example applies a custom asset color to both the active indicator background and the tab text, while keeping the default red styling for live periods. + +```jsx +function Example() { + const btcColor = assets.btc.color; + + const BTCActiveIndicator = memo((props) => { + const theme = useTheme(); + const { activeTab } = useTabsContext(); + const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); + + const backgroundColor = useMemo( + () => (isLive ? theme.color.bgNegativeWash : `${btcColor}1A`), + [isLive, theme.color.bgNegativeWash], + ); + + return ; + }); + + const BTCTab = memo( + forwardRef(({ label, ...props }, ref) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + const theme = useTheme(); + + const wrappedLabel = + typeof label === 'string' ? ( + + {label} + + ) : ( + label + ); + + return ; + }), + ); + + const tabs = [ + { id: '1H', label: }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[1]); + + return ( + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/PeriodSelector/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/PeriodSelector/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/PeriodSelector/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/PeriodSelector/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/PeriodSelector/_mobileStyles.mdx b/apps/docs/docs/components/charts/PeriodSelector/_mobileStyles.mdx new file mode 100644 index 0000000000..fd336d8f78 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile-visualization/chart/PeriodSelector/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx b/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx new file mode 100644 index 0000000000..3e0f403959 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/_webExamples.mdx @@ -0,0 +1,539 @@ +PeriodSelector is a specialized [SegmentedTabs](/components/navigation/SegmentedTabs) optimized for chart time-period selection. It provides a transparent background, primary wash active state, and full-width layout by default. + +## Basics + +```jsx live +function Example() { + const tabs = [ + { id: '1H', label: '1H' }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'YTD', label: 'YTD' }, + { id: 'All', label: 'All' }, + ]; + + const [activeTab, setActiveTab] = useState(tabs[0]); + + return ( + + + + ); +} +``` + +## Sizing + +Set `width` to `fit-content` to make the selector only as wide as its content, and use `gap` to control spacing between tabs. + +```jsx live +function Example() { + const tabs = [ + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + ]; + + const [activeTab, setActiveTab] = useState(tabs[0]); + + return ( + + ); +} +``` + +## Live Indicator + +Use the `LiveTabLabel` component (exported from PeriodSelector) to indicate a live data period. Pair it with a conditional `activeBackground` to visually differentiate the live state. + +```jsx live +function Example() { + const tabs = useMemo( + () => [ + { id: '1H', label: }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[0]); + const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); + + const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); + + return ( + + + + ); +} +``` + +## Overflow + +When there are too many tabs to fit in a single row, wrap the selector in a scrollable container with a fade edge and an optional action button. + +```jsx live +function Example() { + const tabs = useMemo( + () => [ + { id: '1H', label: '1H' }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + { id: '1Y', label: '1Y' }, + { id: '5Y', label: '5Y' }, + { id: 'All', label: 'All' }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[0]); + + return ( + + + + + + + + + + + ); +} +``` + +## Customization + +### Custom Colors + +Use the `activeBackground` prop to change the active indicator color. This example conditionally applies a negative wash when the live period is selected. + +```jsx live +function Example() { + const tabs = useMemo( + () => [ + { id: '1H', label: }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[1]); + const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); + + const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); + + return ( + + + + ); +} +``` + +### Color Shifting + +Animate the active tab's foreground color using a CSS variable and framer-motion. This pattern is useful for charts where the color changes based on price movement (positive/negative). + +```jsx live +function Example() { + const TabLabel = memo(({ label }) => ( + + {label} + + )); + + const tabs = useMemo( + () => [ + { id: '1H', label: }, + { id: '1D', label: }, + { id: '1W', label: }, + { id: '1M', label: }, + { id: '1Y', label: }, + { id: 'All', label: }, + ], + [], + ); + + const [activeTab, setActiveTab] = useState(tabs[0]); + const [chartActiveColor, setChartActiveColor] = useState('positive'); + + const toggleColor = useCallback(() => { + setChartActiveColor((activeColor) => (activeColor === 'positive' ? 'negative' : 'positive')); + }, []); + + const activeForegroundColor = useMemo(() => { + return chartActiveColor === 'positive' ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + }, [chartActiveColor]); + + const activeBackground = useMemo(() => { + return chartActiveColor === 'positive' ? 'bgPositiveWash' : 'bgNegativeWash'; + }, [chartActiveColor]); + + return ( + + + + + + + ); +} +``` + +### Asset Price Chart + +A composed example using PeriodSelector to control the time period of a [LineChart](/components/charts/LineChart), with a settings tray for axis toggles. + +```jsx live +function Example() { + const tabs = [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ]; + + const PeriodSelectorWrapper = memo(({ activeTab, setActiveTab, tabs, onClickSettings }) => ( + + + + + + + + + + + )); + + const AssetPriceChart = memo(() => { + const [activeTab, setActiveTab] = useState(tabs[0]); + const [showSettings, setShowSettings] = useState(false); + const [showYAxis, setShowYAxis] = useState(true); + const [showXAxis, setShowXAxis] = useState(true); + const [scrubIndex, setScrubIndex] = useState(); + const breakpoints = useBreakpoints(); + + const formatPrice = useCallback((price) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price); + }, []); + + const formatYAxisPrice = useCallback( + (price) => { + if (breakpoints.isPhone) { + if (price >= 1000000) { + return `$${(price / 1000000).toFixed(1)}M`; + } else if (price >= 1000) { + return `$${(price / 1000).toFixed(0)}k`; + } + return `$${price.toFixed(0)}`; + } + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price); + }, + [breakpoints.isPhone], + ); + const toggleShowYAxis = useCallback(() => setShowYAxis((show) => !show), []); + const toggleShowXAxis = useCallback(() => setShowXAxis((show) => !show), []); + + const data = useMemo(() => sparklineInteractiveData[activeTab.id], [activeTab.id]); + const currentPrice = useMemo( + () => sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value, + [], + ); + const currentTimePrice = useMemo(() => { + if (scrubIndex !== undefined) { + return data[scrubIndex].value; + } + return currentPrice; + }, [data, scrubIndex, currentPrice]); + + const formatDate = useCallback((date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const scrubberLabel = useMemo(() => { + if (scrubIndex === undefined) return; + return formatDate(data[scrubIndex].date); + }, [scrubIndex, data, formatDate]); + + const accessibilityLabel = useMemo(() => { + if (scrubIndex === undefined) return; + const price = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(data[scrubIndex].value); + const date = formatDate(data[scrubIndex].date); + return `Asset price: ${price} USD on ${date}`; + }, [scrubIndex, data, formatDate]); + + const onClickSettings = useCallback(() => setShowSettings(!showSettings), [showSettings]); + + const seriesData = useMemo(() => [{ id: 'price', data: data.map((d) => d.value) }], [data]); + + const getFormattingConfigForPeriod = useCallback((period) => { + switch (period) { + case 'hour': + case 'day': + return { + hour: 'numeric', + minute: 'numeric', + }; + + case 'week': + case 'month': + return { + month: 'numeric', + day: 'numeric', + }; + + case 'year': + case 'all': + return { + month: 'numeric', + year: 'numeric', + }; + } + }, []); + + const formatXAxisDate = useCallback( + (index) => { + if (!data[index]) return ''; + const date = data[index].date; + const formatConfig = getFormattingConfigForPeriod(activeTab.id); + + if (activeTab.id === 'hour' || activeTab.id === 'day') { + return date.toLocaleTimeString('en-US', formatConfig); + } else { + return date.toLocaleDateString('en-US', formatConfig); + } + }, + [data, activeTab.id, getFormattingConfigForPeriod], + ); + + const isMobile = breakpoints.isPhone || breakpoints.isTabletPortrait; + + return ( + + Asset Price} + balance={ + + } + end={ + isMobile ? undefined : ( + + + + ) + } + /> + + + + {isMobile && ( + + + + )} + {showSettings && ( + setShowSettings(false)}> + {({ handleClose }) => ( + + + Show Y-Axis + + + + + Show X-Axis + + + + )} + + )} + + ); + }, []); + + return ; +} +``` diff --git a/apps/docs/docs/components/graphs/PeriodSelector/_webPropsTable.mdx b/apps/docs/docs/components/charts/PeriodSelector/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/PeriodSelector/_webPropsTable.mdx rename to apps/docs/docs/components/charts/PeriodSelector/_webPropsTable.mdx diff --git a/apps/docs/docs/components/charts/PeriodSelector/_webStyles.mdx b/apps/docs/docs/components/charts/PeriodSelector/_webStyles.mdx new file mode 100644 index 0000000000..bee623a5a6 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/_webStyles.mdx @@ -0,0 +1,37 @@ +import { useState, useCallback } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { PeriodSelector } from '@coinbase/cds-web-visualization/chart/PeriodSelector'; + +import webStylesData from ':docgen/web-visualization/chart/PeriodSelector/styles-data'; + +export const PeriodSelectorExample = ({ classNames }) => { + const tabs = [ + { id: '1H', label: '1H' }, + { id: '1D', label: '1D' }, + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: '1Y', label: '1Y' }, + { id: 'All', label: 'All' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + const handleChange = useCallback((tab) => setActiveTab(tab), []); + return ( + + ); +}; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/charts/PeriodSelector/index.mdx b/apps/docs/docs/components/charts/PeriodSelector/index.mdx new file mode 100644 index 0000000000..b2ea468716 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/index.mdx @@ -0,0 +1,45 @@ +--- +id: periodSelector +title: PeriodSelector +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/PeriodSelector/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/PeriodSelector/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + mobileExamplesToc={mobileExamplesToc} + mobilePropsTable={} + mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} + /> + diff --git a/apps/docs/docs/components/charts/PeriodSelector/mobileMetadata.json b/apps/docs/docs/components/charts/PeriodSelector/mobileMetadata.json new file mode 100644 index 0000000000..b1423df754 --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/mobileMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { PeriodSelector } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/PeriodSelector.tsx", + "description": "A selector component for choosing time periods in charts.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "SegmentedTabs", + "url": "/components/navigation/SegmentedTabs/" + } + ], + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/PeriodSelector/webMetadata.json b/apps/docs/docs/components/charts/PeriodSelector/webMetadata.json new file mode 100644 index 0000000000..1e28ce12ea --- /dev/null +++ b/apps/docs/docs/components/charts/PeriodSelector/webMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { PeriodSelector } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/PeriodSelector.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-periodselector--all", + "description": "A selector component for choosing time periods in charts.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "SegmentedTabs", + "url": "/components/navigation/SegmentedTabs/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Point/_mobileExamples.mdx b/apps/docs/docs/components/charts/Point/_mobileExamples.mdx new file mode 100644 index 0000000000..475edb7e04 --- /dev/null +++ b/apps/docs/docs/components/charts/Point/_mobileExamples.mdx @@ -0,0 +1,275 @@ +## Basic Example + +Points are visual markers that highlight specific data values on a chart. They can be used to emphasize important data points, show discrete values, or provide interactive elements. + +You can add points using `points` on Line or [LineChart](/components/charts/LineChart). + +```jsx + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} +> + + +``` + +You can also add Points directly to a chart. + +```jsx +function MyChart() { + const prices = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + `$${value}`} /> + {prices.map((price, index) => ( + + ))} + + ); +} +``` + +### Conditional + +You can conditionally render points to highlight specific values in your data, such as maximum/minimum values or outliers. + +```tsx +function AssetPriceWithMinMax() { + const data = sparklineInteractiveData.hour.map((d) => d.value); + + const minPrice = Math.min(...data); + const maxPrice = Math.max(...data); + + const formatPrice = useCallback((price: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price); + }, []); + + return ( + { + const isMin = dataY === minPrice; + const isMax = dataY === maxPrice; + + if (isMin) { + return { label: formatPrice(dataY), labelPosition: 'bottom' }; + } + + if (isMax) { + return { label: formatPrice(dataY), labelPosition: 'top' }; + } + }} + series={[ + { + id: 'btc', + data: data, + color: assets.btc.color, + }, + ]} + /> + ); +} +``` + +## Styling + +Points support customization through various properties including colors, sizes, and labels. + +```jsx +function CustomizedPoints() { + const theme = useTheme(); + return ( + { + const isHighPerformance = dataY >= 90; + const isLowPerformance = dataY < 75; + + return { + fill: isHighPerformance + ? theme.color.bgPositive + : isLowPerformance + ? theme.color.bgNegative + : theme.color.fgPrimary, + radius: isHighPerformance ? 6 : 4, + strokeWidth: 2, + stroke: theme.color.bg, + label: isHighPerformance || isLowPerformance ? `${dataY}%` : undefined, + labelPosition: isHighPerformance ? 'top' : 'bottom', + }; + }} + series={[ + { + id: 'performance', + data: [65, 70, 72, 85, 88, 92, 78, 82, 90, 95, 91, 94], + }, + ]} + yAxis={{ + showGrid: true, + label: 'Performance Score', + }} + /> + ); +} +``` + +### Labels + +You can use `labelPosition`, `labelOffset`, and `labelFont` to adjust Point's label. + +```jsx +function Scatterplot() { + const dataPoints = [ + { x: 20, y: 30, label: 'A' }, + { x: 40, y: 65, label: 'B' }, + { x: 60, y: 45, label: 'C' }, + { x: 75, y: 80, label: 'D' }, + ]; + + return ( + + + + {dataPoints.map((point, index) => ( + + ))} + + ); +} +``` + +### Custom Label Position + +You can also use `LabelComponent` to create custom label components. + +```jsx +function ScatterplotWithCustomLabels() { + const theme = useTheme(); + const dataPoints = [ + { x: 12, y: 34, label: 'A', color: theme.color.fgAccent }, + { x: 28, y: 67, label: 'B', color: theme.color.fgAccent }, + { x: 45, y: 23, label: 'C', color: theme.color.fgAccent }, + { x: 67, y: 89, label: 'D', color: theme.color.bgPositive }, + { x: 82, y: 76, label: 'E', color: theme.color.bgPositive }, + { x: 34, y: 91, label: 'F', color: theme.color.bgPositive }, + { x: 56, y: 45, label: 'G', color: theme.color.bgPositive }, + { x: 19, y: 12, label: 'H', color: theme.color.fgWarning }, + { x: 73, y: 28, label: 'I', color: theme.color.fgWarning }, + { x: 91, y: 54, label: 'J', color: theme.color.fgWarning }, + { x: 15, y: 58, label: 'K', color: theme.color.fgPrimary }, + { x: 39, y: 72, label: 'L', color: theme.color.fgPrimary }, + { x: 88, y: 15, label: 'M', color: theme.color.fgPrimary }, + { x: 52, y: 82, label: 'N', color: theme.color.fgPrimary }, + ]; + + // Calculate domain based on data + const xValues = dataPoints.map((p) => p.x); + const yValues = dataPoints.map((p) => p.y); + const xMin = Math.min(...xValues); + const xMax = Math.max(...xValues); + const yMin = Math.min(...yValues); + const yMax = Math.max(...yValues); + + // Custom label component that places labels to the top-right + const TopRightPointLabel = ({ x, y, offset = 0, children }) => { + return ( + + {children} + + ); + }; + + return ( + + + + {dataPoints.map((point, index) => ( + + ))} + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/Point/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/Point/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Point/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/Point/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/Point/_webExamples.mdx b/apps/docs/docs/components/charts/Point/_webExamples.mdx new file mode 100644 index 0000000000..7630505ff2 --- /dev/null +++ b/apps/docs/docs/components/charts/Point/_webExamples.mdx @@ -0,0 +1,310 @@ +## Basics + +Points are visual markers that highlight specific data values on a chart. They can be used to emphasize important data points, show discrete values, or provide interactive elements. + +You can add points using `points` on Line or [LineChart](/components/charts/LineChart). + +```jsx live + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} +> + + +``` + +You can also add Points directly to a chart. + +```jsx live +function MyChart() { + const prices = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + + return ( + + `$${value}`} /> + {prices.map((price, index) => ( + + ))} + + ); +} +``` + +### Conditional + +You can conditionally render points to highlight specific values in your data, such as maximum/minimum values or outliers. + +```jsx live +function AssetPriceWithMinMax() { + const data = sparklineInteractiveData.hour.map((d) => d.value); + + const minPrice = Math.min(...data); + const maxPrice = Math.max(...data); + + const formatPrice = useCallback((price) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(price); + }, []); + + return ( + { + const isMin = dataY === minPrice; + const isMax = dataY === maxPrice; + + if (isMin) { + return { label: formatPrice(dataY), labelPosition: 'bottom' }; + } + + if (isMax) { + return { label: formatPrice(dataY), labelPosition: 'top' }; + } + }} + series={[ + { + id: 'btc', + data: data, + color: assets.btc.color, + }, + ]} + style={{ outlineColor: assets.btc.color }} + /> + ); +} +``` + +## Interaction + +Points can be made interactive by adding click handlers, allowing users to explore data in more detail. + +```jsx live + { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return { + radius: 4, + onClick: () => alert(`${months[dataX]}: ${dataY} units sold`), + accessibilityLabel: `${months[dataX]} sales: ${dataY} units`, + }; + }} + series={[ + { + id: 'sales', + data: [120, 132, 101, 134, 90, 230, 210, 120, 180, 190, 210, 176], + }, + ]} + yAxis={{ + showGrid: true, + label: 'Sales (units)', + }} +/> +``` + +## Styling + +Points support customization through various properties including colors, sizes, and labels. + +```jsx live + { + const isHighPerformance = dataY >= 90; + const isLowPerformance = dataY < 75; + + return { + fill: isHighPerformance + ? 'var(--color-bgPositive)' + : isLowPerformance + ? 'var(--color-bgNegative)' + : 'var(--color-fgPrimary)', + radius: isHighPerformance ? 6 : 4, + strokeWidth: 2, + stroke: 'var(--color-bg)', + label: isHighPerformance || isLowPerformance ? `${dataY}%` : undefined, + labelPosition: isHighPerformance ? 'top' : 'bottom', + }; + }} + series={[ + { + id: 'performance', + data: [65, 70, 72, 85, 88, 92, 78, 82, 90, 95, 91, 94], + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + label: 'Performance Score', + }} +/> +``` + +### Labels + +You can use `labelPosition`, `labelOffset`, and `labelFont` to adjust Point's label. + +```jsx live +function Scatterplot() { + const dataPoints = [ + { x: 20, y: 30, label: 'A' }, + { x: 40, y: 65, label: 'B' }, + { x: 60, y: 45, label: 'C' }, + { x: 75, y: 80, label: 'D' }, + ]; + + return ( + + + + {dataPoints.map((point, index) => ( + + ))} + + ); +} +``` + +### Custom Label Position + +You can also use `LabelComponent` to create custom label components. + +```jsx live +function ScatterplotWithCustomLabels() { + const dataPoints = [ + { x: 12, y: 34, label: 'A', color: 'var(--color-fgAccent)' }, + { x: 28, y: 67, label: 'B', color: 'var(--color-fgAccent)' }, + { x: 45, y: 23, label: 'C', color: 'var(--color-fgAccent)' }, + { x: 67, y: 89, label: 'D', color: 'var(--color-bgPositive)' }, + { x: 82, y: 76, label: 'E', color: 'var(--color-bgPositive)' }, + { x: 34, y: 91, label: 'F', color: 'var(--color-bgPositive)' }, + { x: 56, y: 45, label: 'G', color: 'var(--color-bgPositive)' }, + { x: 19, y: 12, label: 'H', color: 'var(--color-fgWarning)' }, + { x: 73, y: 28, label: 'I', color: 'var(--color-fgWarning)' }, + { x: 91, y: 54, label: 'J', color: 'var(--color-fgWarning)' }, + { x: 15, y: 58, label: 'K', color: 'var(--color-fgPrimary)' }, + { x: 39, y: 72, label: 'L', color: 'var(--color-fgPrimary)' }, + { x: 88, y: 15, label: 'M', color: 'var(--color-fgPrimary)' }, + { x: 52, y: 82, label: 'N', color: 'var(--color-fgPrimary)' }, + ]; + + // Calculate domain based on data + const xValues = dataPoints.map((p) => p.x); + const yValues = dataPoints.map((p) => p.y); + const xMin = Math.min(...xValues); + const xMax = Math.max(...xValues); + const yMin = Math.min(...yValues); + const yMax = Math.max(...yValues); + + // Custom label component that places labels to the top-right + const TopRightPointLabel = ({ x, y, offset = 0, children }) => { + return ( + + {children} + + ); + }; + + return ( + + + + {dataPoints.map((point, index) => ( + + ))} + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/Point/_webPropsTable.mdx b/apps/docs/docs/components/charts/Point/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Point/_webPropsTable.mdx rename to apps/docs/docs/components/charts/Point/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/Point/index.mdx b/apps/docs/docs/components/charts/Point/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Point/index.mdx rename to apps/docs/docs/components/charts/Point/index.mdx diff --git a/apps/docs/docs/components/charts/Point/mobileMetadata.json b/apps/docs/docs/components/charts/Point/mobileMetadata.json new file mode 100644 index 0000000000..c198727fe7 --- /dev/null +++ b/apps/docs/docs/components/charts/Point/mobileMetadata.json @@ -0,0 +1,25 @@ +{ + "import": "import { Point } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/Point.tsx", + "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and labels.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Point/webMetadata.json b/apps/docs/docs/components/charts/Point/webMetadata.json new file mode 100644 index 0000000000..1334831df4 --- /dev/null +++ b/apps/docs/docs/components/charts/Point/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { Point } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/Point.tsx", + "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and interactivity.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Scrubber", + "url": "/components/charts/Scrubber/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/ReferenceLine/_mobileExamples.mdx b/apps/docs/docs/components/charts/ReferenceLine/_mobileExamples.mdx new file mode 100644 index 0000000000..9655ddb5cd --- /dev/null +++ b/apps/docs/docs/components/charts/ReferenceLine/_mobileExamples.mdx @@ -0,0 +1,347 @@ +## Basics + +ReferenceLine can be used to add important details to a chart, such as a reference price or date. You can create horizontal lines using `dataY` or vertical lines using `dataX`. + +```jsx +function SimpleReferenceLineExample() { + const theme = useTheme(); + + return ( + + } + dataY={10} + stroke={theme.color.fg} + /> + + ); +} +``` + +### With Labels + +You can add text labels to reference lines and position them using alignment and offset props: + +```jsx +function WithLabelsExample() { + return ( + + + + + ); +} +``` + +## Data Values + +ReferenceLine relies on `dataX` or `dataY` to position the line. Passing in `dataY` will create a horizontal line across the y axis at that value, and passing in `dataX` will do the same along the x axis. + +```jsx +function DataValuesExample() { + const theme = useTheme(); + + return ( + + + + + ); +} +``` + +## Labels + +### Customization + +You can customize label appearance using `labelFont`, `labelDx`, `labelDy`, `labelHorizontalAlignment`, and `labelVerticalAlignment` props. + +```jsx +function LabelCustomizationExample() { + return ( + + + + + ); +} +``` + +### Bounds + +Use `labelBoundsInset` to prevent labels from getting too close to chart edges. + +```jsx +function BoundsExample() { + return ( + + + + + ); +} +``` + +### Custom Components + +You can adjust the style of the label using a custom `LabelComponent`. + +```jsx +function CustomLabelExample() { + const StartPriceLabel = memo((props) => { + const theme = useTheme(); + return ( + + ); + }); + + function Example() { + const theme = useTheme(); + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? theme.color.fgPositive : theme.color.fgNegative; + + const formattedStartPrice = useMemo( + () => + startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [startPrice], + ); + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + ( + + )} + dataY={startPrice} + label={formattedStartPrice} + labelDx={-12} + labelHorizontalAlignment="right" + stroke={theme.color.fgMuted} + /> + + ); + } + + return ; +} +``` + +You can also optionally hide the label based on user scrubbing. + +```jsx +function StartPriceReferenceLine() { + const StartPriceLabel = memo((props) => { + const theme = useTheme(); + const { scrubberPosition } = useScrubberContext(); + const { getXSerializableScale, drawingArea } = useCartesianChartContext(); + const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + + const fadeZone = 128; + + const opacity = useDerivedValue(() => { + if (scrubberPosition.value === undefined) return withTiming(0, { duration: 250 }); + if (!xScale) return withTiming(1, { duration: 250 }); + const scrubX = getPointOnSerializableScale(scrubberPosition.value, xScale); + const rightEdge = drawingArea.x + drawingArea.width; + const target = rightEdge - scrubX >= fadeZone ? 1 : 0; + return withTiming(target, { duration: 250 }); + }, [scrubberPosition, xScale, drawingArea]); + + return ( + + ); + }); + + function Example() { + const theme = useTheme(); + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? theme.color.fgPositive : theme.color.fgNegative; + + const formattedStartPrice = useMemo( + () => + startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [startPrice], + ); + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + ( + + )} + dataY={startPrice} + label={formattedStartPrice} + labelDx={-12} + labelHorizontalAlignment="right" + stroke={theme.color.fgMuted} + /> + + ); + } + + return ; +} +``` diff --git a/apps/docs/docs/components/graphs/ReferenceLine/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/ReferenceLine/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/ReferenceLine/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/ReferenceLine/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx b/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx new file mode 100644 index 0000000000..e18f00c94c --- /dev/null +++ b/apps/docs/docs/components/charts/ReferenceLine/_webExamples.mdx @@ -0,0 +1,618 @@ +## Basics + +ReferenceLine can be used to add important details to a chart, such as a reference price or date. You can create horizontal lines using `dataY` or vertical lines using `dataX`. + +```jsx live + + } + dataY={10} + stroke="var(--color-fg)" + /> + +``` + +### With Labels + +You can add text labels to reference lines and position them using alignment and offset props: + +```jsx live + + + + +``` + +## Data Values + +ReferenceLine relies on `dataX` or `dataY` to position the line. Passing in `dataY` will create a horizontal line across the y axis at that value, and passing in `dataX` will do the same along the x axis. + +```jsx live + + + + +``` + +## Labels + +### Customization + +You can customize label appearance using `labelFont`, `labelDx`, `labelDy`, `labelHorizontalAlignment`, and `labelVerticalAlignment` props. + +```jsx live + + + + +``` + +### Bounds + +Use `labelBoundsInset` to prevent labels from getting too close to chart edges. + +```jsx live + + + + + + +``` + +### Custom Components + +You can adjust the style of the label using a custom `LabelComponent`. + +```jsx live +function CustomLabelExample() { + const PriceLabel = memo((props) => ( + + )); + + function Example() { + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + + const formattedStartPrice = useMemo( + () => + startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [startPrice], + ); + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + ( + + )} + dataY={startPrice} + label={formattedStartPrice} + stroke="var(--color-fgMuted)" + labelDx={-12} + labelHorizontalAlignment="right" + /> + + ); + } + + return ; +} +``` + +You can also optionally hide the label based on user scrubbing. + +```jsx live +function StartPriceReferenceLine() { + const PriceLabel = memo((props) => { + const { scrubberPosition } = useScrubberContext(); + const { getXScale, drawingArea } = useCartesianChartContext(); + const isScrubbing = scrubberPosition !== undefined; + + const fadeZone = 128; + + const opacity = useMemo(() => { + if (!isScrubbing) return 0; + const xScale = getXScale(); + if (!xScale) return 1; + const scrubX = xScale(scrubberPosition) ?? 0; + const rightEdge = drawingArea.x + drawingArea.width; + return rightEdge - scrubX >= fadeZone ? 1 : 0; + }, [isScrubbing, scrubberPosition, getXScale, drawingArea]); + + return ( + + ); + }); + + function Example() { + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + + const formattedStartPrice = useMemo( + () => + startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [startPrice], + ); + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + ( + + )} + dataY={startPrice} + label={formattedStartPrice} + stroke="var(--color-fgMuted)" + labelDx={-12} + labelHorizontalAlignment="right" + /> + + ); + } + + return ; +} +``` + +## Draggable Price Target + +You can pair a ReferenceLine with a custom drag component to create a draggable price target. + +```tsx live +function DraggablePriceTarget() { + const DragIcon = ({ x, y }) => { + const DragCircle = (props) => ; + + return ( + + + + + + + + + + + ); + }; + + const TrendArrowIcon = ({ x, y, isPositive, color }) => { + return ( + + + + + + ); + }; + + const DynamicPriceLabel = memo(({ color, ...props }) => ( + + )); + + const DraggableReferenceLine = memo(({ baselineAmount, startAmount, chartRef }) => { + const theme = useTheme(); + const { isPhone } = useBreakpoints(); + + const formatPrice = useCallback((value) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const { getYScale, drawingArea } = useCartesianChartContext(); + const [amount, setAmount] = useState(startAmount); + const [isDragging, setIsDragging] = useState(false); + const [textDimensions, setTextDimensions] = useState({ width: 0, height: 0 }); + const color = amount >= baselineAmount ? 'var(--color-bgPositive)' : 'var(--color-bgNegative)'; + + const yScale = getYScale(); + + const labelComponent = useCallback( + (props) => , + [color], + ); + + // Set up persistent event listeners on the chart SVG element + useEffect(() => { + const element = chartRef.current; + + if (!element || !yScale || !('invert' in yScale && typeof yScale.invert === 'function')) { + return; + } + + const updatePosition = (clientX, clientY) => { + const point = element.createSVGPoint(); + point.x = clientX; + point.y = clientY; + + const svgPoint = point.matrixTransform(element.getScreenCTM()?.inverse()); + + // Clamp the Y position to the chart area + const clampedY = Math.max( + drawingArea.y, + Math.min(drawingArea.y + drawingArea.height, svgPoint.y), + ); + + const rawAmount = yScale.invert(clampedY); + + const rawPercentage = ((rawAmount - baselineAmount) / baselineAmount) * 100; + + let targetPercentage = Math.round(rawPercentage); + + if (targetPercentage === 0) { + targetPercentage = rawPercentage >= 0 ? 1 : -1; + } + + const newAmount = baselineAmount * (1 + targetPercentage / 100); + setAmount(newAmount); + }; + + const handleMouseMove = (event: MouseEvent) => { + if (!isDragging) { + return; + } + updatePosition(event.clientX, event.clientY); + }; + + const handleTouchMove = (event: TouchEvent) => { + if (!isDragging || event.touches.length === 0) { + return; + } + const touch = event.touches[0]; + updatePosition(touch.clientX, touch.clientY); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleTouchEnd = () => { + setIsDragging(false); + }; + + const handleMouseLeave = () => { + setIsDragging(false); + }; + + element.addEventListener('mousemove', handleMouseMove); + element.addEventListener('mouseup', handleMouseUp); + element.addEventListener('mouseleave', handleMouseLeave); + element.addEventListener('touchmove', handleTouchMove); + element.addEventListener('touchend', handleTouchEnd); + element.addEventListener('touchcancel', handleTouchEnd); + + return () => { + element.removeEventListener('mousemove', handleMouseMove); + element.removeEventListener('mouseup', handleMouseUp); + element.removeEventListener('mouseleave', handleMouseLeave); + element.removeEventListener('touchmove', handleTouchMove); + element.removeEventListener('touchend', handleTouchEnd); + element.removeEventListener('touchcancel', handleTouchEnd); + }; + }, [isDragging, yScale, chartRef, baselineAmount, drawingArea.y, drawingArea.height]); + + if (!yScale) return null; + + const yPixel = yScale(amount); + + if (yPixel === undefined || yPixel === null) return null; + + const difference = amount - baselineAmount; + const percentageChange = Math.round((difference / baselineAmount) * 100); + const isPositive = difference > 0; + + const percentageLabel = isPhone + ? `${Math.abs(percentageChange)}%` + : `${Math.abs(percentageChange)}% (${formatPrice(Math.abs(difference))})`; + const dollarLabel = formatPrice(amount); + + const handleMouseDown = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleTouchStart = (e) => { + e.preventDefault(); + setIsDragging(true); + }; + + const padding = 16; + const dragIconSize = 16; + const trendArrowIconSize = 16; + const iconGap = 8; + const totalPadding = padding * 2 + iconGap; + + const rectWidth = textDimensions.width + totalPadding + dragIconSize + trendArrowIconSize; + + return ( + <> + + + + + + setTextDimensions(dimensions)} + verticalAlignment="middle" + x={drawingArea.x + padding + dragIconSize + iconGap + trendArrowIconSize} + y={yPixel + 1} + > + {percentageLabel} + + + + ); + }); + + const BaselinePriceLabel = useMemo( + () => + memo((props) => ), + [], + ); + + const PriceTargetChart = () => { + const priceData = useMemo(() => sparklineInteractiveData.year.map((d) => d.value), []); + const { isPhone } = useBreakpoints(); + + const chartRef = useRef(null); + + const formatPrice = useCallback((value) => { + return `$${value.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + return ( + ({ min: min * 0.7, max: max * 1.3 }) }} + > + {!isPhone && ( + + )} + + + ); + }; + return ; +} +``` diff --git a/apps/docs/docs/components/graphs/ReferenceLine/_webPropsTable.mdx b/apps/docs/docs/components/charts/ReferenceLine/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/ReferenceLine/_webPropsTable.mdx rename to apps/docs/docs/components/charts/ReferenceLine/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/ReferenceLine/index.mdx b/apps/docs/docs/components/charts/ReferenceLine/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/ReferenceLine/index.mdx rename to apps/docs/docs/components/charts/ReferenceLine/index.mdx diff --git a/apps/docs/docs/components/charts/ReferenceLine/mobileMetadata.json b/apps/docs/docs/components/charts/ReferenceLine/mobileMetadata.json new file mode 100644 index 0000000000..e624da923a --- /dev/null +++ b/apps/docs/docs/components/charts/ReferenceLine/mobileMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { ReferenceLine } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx", + "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/ReferenceLine/webMetadata.json b/apps/docs/docs/components/charts/ReferenceLine/webMetadata.json new file mode 100644 index 0000000000..1b630e43c9 --- /dev/null +++ b/apps/docs/docs/components/charts/ReferenceLine/webMetadata.json @@ -0,0 +1,18 @@ +{ + "import": "import { ReferenceLine } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/ReferenceLine.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-referenceline--all", + "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Scrubber/_mobileExamples.mdx b/apps/docs/docs/components/charts/Scrubber/_mobileExamples.mdx new file mode 100644 index 0000000000..060f6b8e32 --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/_mobileExamples.mdx @@ -0,0 +1,980 @@ +## Basics + +Scrubber can be used to provide horizontal interaction with a chart. As you drag over the chart, you will see a line and scrubber beacon following. + +The Scrubber component is optional. Charts like [BarChart](/components/charts/BarChart) can use `enableScrubbing` with `getScrubberAccessibilityLabel` for screen reader accessibility without adding Scrubber—invisible tap targets allow users to navigate segments. Add Scrubber when you want the visual beacon, overlay, and labels for touch users. + +```jsx + + + +``` + +All series will be scrubbed by default. You can set `seriesIds` to show only specific series. +In `layout="horizontal"`, beacon labels are intentionally hidden to avoid overlap with scrubber beacons. + +```jsx + + + +``` + +## Labels + +Setting `label` on a series will display a label to the side of the scrubber beacon, and +setting `label` on Scrubber displays a label above the scrubber line. + +```tsx + + `Day ${dataIndex + 1}`} /> + +``` + +## Pulsing + +Pulses will show even when animation is disabled for the chart or scrubber. + +Set `idlePulse` to cause scrubber beacons to pulse when the user is not actively scrubbing. + +```jsx + + } + dataY={10} + stroke="var(--color-fg)" + /> + + +``` + +You can also use the imperative handle to pulse the scrubber beacons programmatically. + +```jsx +function ImperativeHandle() { + const scrubberRef = useRef(null); + return ( + + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} + > + + + + + ); +} +``` + +## Styling + +### Beacons + +You can use the `beaconStroke` prop to customize the stroke color of the scrubber beacon. + +```jsx +function CustomStrokeColor() { + const theme = useTheme(); + const backgroundColor = `rgb(${theme.spectrum.red40})`; + const foregroundColor = `rgb(${theme.spectrum.gray0})`; + + return ( + + + + + + ); +} +``` + +For more advanced customizations, you can pass a custom component to `BeaconComponent`. + +```tsx +function OutlineBeacon() { + const theme = useTheme(); + + const dataCount = 14; + const minDataValue = 0; + const maxDataValue = 100; + const minStepOffset = 5; + const maxStepOffset = 20; + const updateInterval = 2000; + + function generateNextValue(previousValue: number) { + const range = maxStepOffset - minStepOffset; + const offset = Math.random() * range + minStepOffset; + + let direction; + if (previousValue >= maxDataValue) { + direction = -1; + } else if (previousValue <= minDataValue) { + direction = 1; + } else { + direction = Math.random() < 0.5 ? -1 : 1; + } + + const newValue = previousValue + offset * direction; + return Math.max(minDataValue, Math.min(maxDataValue, newValue)); + } + + function generateInitialData() { + const data = []; + let previousValue = Math.random() * (maxDataValue - minDataValue) + minDataValue; + data.push(previousValue); + + for (let i = 1; i < dataCount; i++) { + const newValue = generateNextValue(previousValue); + data.push(newValue); + previousValue = newValue; + } + return data; + } + + const InvertedBeacon = useMemo( + () => (props) => ( + + ), + [theme.color.fg, theme.color.bg], + ); + + const OutlineBeaconChart = memo(() => { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((currentData) => { + const lastValue = currentData[currentData.length - 1] ?? 50; + const newValue = generateNextValue(lastValue); + return [...currentData.slice(1), newValue]; + }); + }, updateInterval); + + return () => clearInterval(intervalId); + }, []); + + return ( + ({ min, max: max - 16 }), + }} + yAxis={{ + showGrid: true, + domain: { min: 0, max: 100 }, + }} + > + + + ); + }); + + return ; +} +``` + +### Labels + +You can use `BeaconLabelComponent` to customize the labels for each scrubber beacon. + +```tsx +function CustomBeaconLabel() { + const theme = useTheme(); + // This custom component label shows the percentage value of the data at the scrubber position. + const MyScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel = useDerivedValue(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex.value]; + return `${unwrapAnimatedValue(label)} · ${dataAtPosition}%`; + } + return unwrapAnimatedValue(label); + }, [label, seriesData, dataIndex]); + + return ( + + ); + }, + ); + + return ( + + + + ); +} +``` + +You can use `hideBeaconLabels` to hide beacon labels, while still being able to provide a label for a series. + +```tsx + + `Day ${dataIndex + 1}`} labelElevated /> + +``` + +Using `labelElevated` will elevate the Scrubber's reference line label with a shadow. + +```tsx + + `Day ${dataIndex + 1}`} labelElevated /> + +``` + +You can use `LabelComponent` to customize this label even further. + +```tsx +function CustomLabelComponent() { + const CustomLabelComponent = memo((props: ScrubberLabelProps) => { + const theme = useTheme(); + const { drawingArea } = useCartesianChartContext(); + + if (!drawingArea) return; + + return ( + + ); + }); + return ( + + `Day ${dataIndex + 1}`} + /> + + ); +} +``` + +#### Multi-line Centered Text + +You can create custom multi-line centered labels using Skia's `ParagraphBuilder` with `TextAlign.Center`. Set `paragraphAlignment={TextAlign.Center}` on your custom label component to ensure proper positioning. + +```tsx +function TwoLineCenteredLabel() { + const theme = useTheme(); + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + const fontMgr = useMemo(() => Skia.TypefaceFontProvider.Make(), []); + + const formatPrice = useCallback((price: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(price); + }, []); + + const scrubberLabel = useCallback( + (index: number) => { + const price = formatPrice(data[index]); + const day = `Day ${index + 1}`; + + const priceStyle: SkTextStyle = { + fontFamilies: ['Inter'], + fontSize: 16, + fontStyle: { weight: FontWeight.Bold }, + color: Skia.Color(theme.color.fg), + }; + + const dayStyle: SkTextStyle = { + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { weight: FontWeight.Normal }, + color: Skia.Color(theme.color.fgMuted), + }; + + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Center }, fontMgr); + + builder.pushStyle(priceStyle); + builder.addText(price); + builder.addText('\n'); + + builder.pushStyle(dayStyle); + builder.addText(day); + + const para = builder.build(); + para.layout(384); + return para; + }, + [data, formatPrice, theme.color.fg, theme.color.fgMuted, fontMgr], + ); + + // Custom label component that sets paragraphAlignment to center + const CenteredScrubberLabel = memo((props: ScrubberLabelProps) => ( + + )); + + return ( + + + + ); +} +``` + +#### Fonts + +You can use `labelFont` to customize the font of the scrubber line label and `beaconLabelFont` to customize the font of the beacon labels. + +```tsx +function CustomLabelFonts() { + const theme = useTheme(); + + return ( + + `Day ${dataIndex + 1}`} + labelFont="legal" + beaconLabelFont="legal" + /> + + ); +} +``` + +#### Bounds + +Use `labelBoundsInset` to prevent the scrubber line label from getting too close to chart edges. + +```jsx +function WithoutBoundsExample() { + return ( + + + + ); +} +``` + +```jsx +function WithBoundsExample() { + return ( + + + + ); +} +``` + +### Line + +You can use `LineComponent` to customize Scrubber's line. In this case, as a user scrubs, they will see a solid line instead of dotted. + +```jsx + + + +``` + +### Opacity + +You can use `BeaconComponent` and `BeaconLabelComponent` with the `opacity` prop to hide scrubber beacons and labels when idle. + +```tsx +function HiddenScrubberWhenIdle() { + const MyScrubberBeacon = memo( + forwardRef((props: ScrubberBeaconProps, ref) => { + const { scrubberPosition } = useScrubberContext(); + const beaconOpacity = useDerivedValue( + () => (scrubberPosition.value !== undefined ? 1 : 0), + [scrubberPosition], + ); + + return ; + }), + ); + + const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { + const { scrubberPosition } = useScrubberContext(); + const labelOpacity = useDerivedValue( + () => (scrubberPosition.value !== undefined ? 1 : 0), + [scrubberPosition], + ); + + return ; + }); + + return ( + + + + ); +} +``` + +### Overlay + +By default, Scrubber will show an overlay to de-emphasize future data. You can hide this by setting `hideOverlay` to `true`. + +```jsx + + + +``` + +## Composed Examples + +### Percentage Beacon Labels + +You can use `BeaconLabelComponent` to display a label with the percentage value of the data at the scrubber position. + +```tsx +function PercentageBeaconLabels() { + const theme = useTheme(); + + const PercentageScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, series, fontProvider } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataLength = useMemo( + () => + series?.reduce((max, s) => { + const data = getSeriesData(s.id); + return Math.max(max, data?.length ?? 0); + }, 0) ?? 0, + [series, getSeriesData], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const labelColor = `rgb(${theme.spectrum.gray0})`; + + const regularStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(labelColor), + }), + [labelColor], + ); + + const boldStyle: SkTextStyle = useMemo( + () => ({ + ...regularStyle, + fontStyle: { + weight: FontWeight.Bold, + }, + }), + [regularStyle], + ); + + const percentageLabel = useDerivedValue(() => { + const labelValue = unwrapAnimatedValue(label); + + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex.value]; + + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Left }, fontProvider); + + builder.pushStyle(boldStyle); + builder.addText(`${dataAtPosition}%`); + builder.pushStyle(regularStyle); + builder.addText(` ${labelValue}`); + + const para = builder.build(); + para.layout(512); + return para; + } + + return labelValue; + }, [label, seriesData, dataIndex, fontProvider, boldStyle, regularStyle]); + + return ( + + ); + }, + ); + + const isLightTheme = theme.activeColorScheme === 'light'; + const background = isLightTheme + ? `rgb(${theme.spectrum.gray90})` + : `rgb(${theme.spectrum.gray0})`; + const scrubberLineStroke = isLightTheme + ? `rgb(${theme.spectrum.gray0})` + : `rgb(${theme.spectrum.gray90})`; + + return ( + + ({ min, max: max - 92 }), + }} + > + + + + ); +} +``` + +### Multi Line Beacon Label + +You can render two-line beacon labels by returning a custom Skia paragraph from `BeaconLabelComponent`. + +```tsx +const matchupBlueData = [ + 47, 50, 51, 52, 53, 53, 53, 53, 52, 51, 51, 52, 53, 55, 57, 58, 59, 61, 63, 65, 64, 64, 64, 64, + 64, 63, 63, 63, 64, 66, 68, 70, 71, 72, 74, 76, 76, 75, 74, 73, 74, 75, 75, 78, +]; +const matchupRedData = matchupBlueData.map((value) => 100 - value); +const matchupTeamLabels: Record = { + blue: 'BLUE', + red: 'RED', +}; + +function MatchupBeaconLabels() { + const theme = useTheme(); + + const MatchupScrubberBeaconLabel = memo( + ({ seriesId, color, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, series, fontProvider } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataLength = useMemo( + () => + series?.reduce((max, currentSeries) => { + const data = getSeriesData(currentSeries.id); + return Math.max(max, data?.length ?? 0); + }, 0) ?? 0, + [series, getSeriesData], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const teamLabel = matchupTeamLabels[seriesId] ?? String(seriesId).toUpperCase(); + const labelColor = color ?? theme.color.fgPrimary; + const legalFontSize = theme.fontSize.legal; + const title3FontSize = theme.fontSize.title3; + + const teamStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: legalFontSize, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(labelColor), + }), + [labelColor, legalFontSize], + ); + + const percentageStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: title3FontSize, + fontStyle: { + weight: FontWeight.Bold, + }, + color: Skia.Color(labelColor), + }), + [title3FontSize, labelColor], + ); + + const matchupLabel = useDerivedValue(() => { + if (seriesData === undefined) { + return teamLabel; + } + + const value = seriesData[dataIndex.value]; + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Left }, fontProvider); + + builder.pushStyle(teamStyle); + builder.addText(teamLabel); + builder.addText('\n'); + builder.pushStyle(percentageStyle); + builder.addText(`${value}%`); + + const paragraph = builder.build(); + paragraph.layout(240); + return paragraph; + }, [dataIndex, fontProvider, percentageStyle, seriesData, teamLabel, teamStyle]); + + return ( + + ); + }, + ); + + return ( + ({ min, max: max - 72 }), + }} + yAxis={{ + domain: { min: 0, max: 100 }, + }} + > + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/Scrubber/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/Scrubber/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Scrubber/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/Scrubber/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx b/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx new file mode 100644 index 0000000000..1bba914cd2 --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/_webExamples.mdx @@ -0,0 +1,896 @@ +## Basics + +Scrubber can be used to provide horizontal interaction with a chart. As your mouse hovers over the chart, you will see a line and scrubber beacon following. + +```jsx live + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} +> + + +``` + +All series will be scrubbed by default. You can set `seriesIds` to show only specific series. + +```jsx live + , + }, + { + id: 'bottom', + data: [4, 8, 11, 15, 16, 14, 16, 10, 12, 14], + color: '#800080', + curve: 'step', + AreaComponent: DottedArea, + showArea: true, + }, + ]} +> + + +``` + +## Labels + +Setting `label` on a series will display a label to the side of the scrubber beacon, and +setting `label` on Scrubber displays a label above the scrubber line. +In `layout="horizontal"`, beacon labels are intentionally hidden to avoid overlap with scrubber beacons. + +```jsx live + + `Day ${dataIndex + 1}`} /> + +``` + +## Pulsing + +Pulses will show even when animation is disabled for the chart or scrubber. + +Set `idlePulse` to cause scrubber beacons to pulse when the user is not actively scrubbing. + +```jsx live + + } + dataY={10} + stroke="var(--color-fg)" + /> + + +``` + +You can also use the imperative handle to pulse the scrubber beacons programmatically. + +```jsx live +function ImperativeHandle() { + const scrubberRef = useRef(null); + return ( + + + + + + + ); +} +``` + +## Styling + +### Beacons + +You can use the `beaconStroke` prop to customize the stroke color of the scrubber beacon. + +```jsx live + + + + + +``` + +For more advanced customizations, you can pass a custom component to `BeaconComponent`. + +```jsx live +function OutlineBeacon() { + const dataCount = 14; + const minDataValue = 0; + const maxDataValue = 100; + const minStepOffset = 5; + const maxStepOffset = 20; + const updateInterval = 2000; + + function generateNextValue(previousValue) { + const range = maxStepOffset - minStepOffset; + const offset = Math.random() * range + minStepOffset; + + let direction; + if (previousValue >= maxDataValue) { + direction = -1; + } else if (previousValue <= minDataValue) { + direction = 1; + } else { + direction = Math.random() < 0.5 ? -1 : 1; + } + + let newValue = previousValue + offset * direction; + return Math.max(minDataValue, Math.min(maxDataValue, newValue)); + } + + function generateInitialData() { + const data = []; + let previousValue = Math.random() * (maxDataValue - minDataValue) + minDataValue; + data.push(previousValue); + + for (let i = 1; i < dataCount; i++) { + const newValue = generateNextValue(previousValue); + data.push(newValue); + previousValue = newValue; + } + return data; + } + + const InvertedBeacon = useMemo( + () => (props) => ( + + ), + [], + ); + + const OutlineBeaconChart = memo(() => { + const [data, setData] = useState(generateInitialData); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((currentData) => { + const lastValue = currentData[currentData.length - 1] ?? 50; + const newValue = generateNextValue(lastValue); + return [...currentData.slice(1), newValue]; + }); + }, updateInterval); + + return () => clearInterval(intervalId); + }, []); + + return ( + ({ min, max: max - 16 }), + }} + yAxis={{ + showGrid: true, + domain: { min: 0, max: 100 }, + }} + > + + + ); + }); + + return ; +} +``` + +### Labels + +You can use `BeaconLabelComponent` to customize the labels for each scrubber beacon. + +```jsx live +function CustomBeaconLabel() { + // This custom component label shows the percentage value of the data at the scrubber position. + const MyScrubberBeaconLabel = memo(({ seriesId, color, label, ...props }) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel = useMemo(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex]; + return `${label} · ${dataAtPosition}%`; + } + return label; + }, [label, seriesData, dataIndex]); + + return ( + + ); + }); + + return ( + + + + ); +} +``` + +You can use `hideBeaconLabels` to hide beacon labels, while still being able to provide a label for a series. + +```jsx live + + `Day ${dataIndex + 1}`} labelElevated /> + +``` + +Using `labelElevated` will elevate the Scrubber's reference line label with a shadow. + +```jsx live + + `Day ${dataIndex + 1}`} labelElevated /> + +``` + +You can use `LabelComponent` to customize this label even further. + +```jsx live +function CustomLabelComponent() { + const CustomLabelComponent = memo((props) => { + const { drawingArea } = useCartesianChartContext(); + + if (!drawingArea) return; + + return ( + + ); + }); + return ( + + `Day ${dataIndex + 1}`} + /> + + ); +} +``` + +#### Fonts + +You can use `labelFont` to customize the font of the scrubber line label and `beaconLabelFont` to customize the font of the beacon labels. + +```jsx live + + `Day ${dataIndex + 1}`} + labelFont="legal" + beaconLabelFont="legal" + /> + +``` + +#### Bounds + +Use `labelBoundsInset` to prevent the scrubber line label from getting too close to chart edges. + +```jsx live + + + + + +``` + +```jsx live + + + + + +``` + +### Line + +You can use `LineComponent` to customize Scrubber's line. In this case, as a user scrubs, they will see a solid line instead of dotted. + +```jsx live + + + +``` + +### Opacity + +You can use `BeaconComponent` and `BeaconLabelComponent` with the `opacity` prop to hide scrubber beacons and labels when idle. + +```jsx live +function HiddenScrubberWhenIdle() { + const MyScrubberBeacon = memo( + forwardRef((props, ref) => { + const { scrubberPosition } = useScrubberContext(); + const isScrubbing = scrubberPosition !== undefined; + + return ; + }), + ); + + const MyScrubberBeaconLabel = memo((props) => { + const { scrubberPosition } = useScrubberContext(); + const isScrubbing = scrubberPosition !== undefined; + + return ; + }); + + return ( + + + + ); +} +``` + +### Overlay + +By default, Scrubber will show an overlay to de-emphasize future data. You can hide this by setting `hideOverlay` to `true`. + +```jsx live + + + +``` + +## Composed Examples + +### Percentage Beacon Labels + +You can use `BeaconLabelComponent` to display a label with the percentage value of the data at the scrubber position. + +```jsx live +function PercentageBeaconLabels() { + const PercentageScrubberBeaconLabel = memo(({ seriesId, color, label, ...props }) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel = useMemo(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex]; + return ( + <> + {dataAtPosition}% {label} + + ); + } + return label; + }, [label, seriesData, dataIndex]); + + return ( + + ); + }); + + const PercentageBeaconLabelChart = ({ background, scrubberLineStroke, ...props }) => { + return ( + + + + + + ); + }; + + function Example() { + const theme = useTheme(); + + const isLightTheme = theme.activeColorScheme === 'light'; + const background = isLightTheme ? 'rgb(var(--gray90))' : 'rgb(var(--gray0))'; + const scrubberLineStroke = isLightTheme ? 'rgb(var(--gray0))' : 'rgb(var(--gray90))'; + + return ( + ({ min, max: max - 92 }), + }} + background={background} + scrubberLineStroke={scrubberLineStroke} + /> + ); + } + + return ; +} +``` + +### Multi Line Beacon Label + +You can use a custom `BeaconLabelComponent` to render each beacon label as two lines (team name + percentage). + +```jsx live +function MatchupBeaconLabels() { + const matchupBlueData = [ + 47, 50, 51, 52, 53, 53, 53, 53, 52, 51, 51, 52, 53, 55, 57, 58, 59, 61, 63, 65, 64, 64, 64, 64, + 64, 63, 63, 63, 64, 66, 68, 70, 71, 72, 74, 76, 76, 75, 74, 73, 74, 75, 75, 78, + ]; + const matchupRedData = matchupBlueData.map((value) => 100 - value); + const matchupTeamLabels = { + blue: 'BLUE', + red: 'RED', + }; + + const TeamBeaconLabel = memo( + ({ + color = 'var(--color-fgPrimary)', + teamLabel, + percentageLabel, + transition, + x, + y, + dx, + horizontalAlignment, + onDimensionsChange, + ...chartTextProps + }) => { + const teamLabelDimensionsRef = useRef(null); + const percentageLabelDimensionsRef = useRef(null); + + const emitCombinedDimensions = useCallback(() => { + if (!onDimensionsChange) { + return; + } + + const teamRect = teamLabelDimensionsRef.current; + const percentageRect = percentageLabelDimensionsRef.current; + + if (!teamRect || !percentageRect) { + return; + } + + const minX = Math.min(teamRect.x, percentageRect.x); + const minY = Math.min(teamRect.y, percentageRect.y); + const maxX = Math.max(teamRect.x + teamRect.width, percentageRect.x + percentageRect.width); + const maxY = Math.max( + teamRect.y + teamRect.height, + percentageRect.y + percentageRect.height, + ); + + onDimensionsChange({ + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }); + }, [onDimensionsChange]); + + const handleTeamLabelDimensionsChange = useCallback( + (rect) => { + teamLabelDimensionsRef.current = rect; + emitCombinedDimensions(); + }, + [emitCombinedDimensions], + ); + + const handlePercentageLabelDimensionsChange = useCallback( + (rect) => { + percentageLabelDimensionsRef.current = rect; + emitCombinedDimensions(); + }, + [emitCombinedDimensions], + ); + + return ( + + + {teamLabel} + + + {percentageLabel} + + + ); + }, + ); + + const MatchupScrubberBeaconLabel = memo(({ seriesId, color, ...props }) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const teamLabel = matchupTeamLabels[seriesId] ?? String(seriesId).toUpperCase(); + + const value = useMemo(() => { + if (seriesData === undefined) { + return null; + } + + return seriesData[dataIndex]; + }, [dataIndex, seriesData]); + + return ( + + ); + }); + + return ( + ({ min, max: max - 64 }), + }} + yAxis={{ + domain: { min: 0, max: 100 }, + }} + > + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/Scrubber/_webPropsTable.mdx b/apps/docs/docs/components/charts/Scrubber/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Scrubber/_webPropsTable.mdx rename to apps/docs/docs/components/charts/Scrubber/_webPropsTable.mdx diff --git a/apps/docs/docs/components/charts/Scrubber/_webStyles.mdx b/apps/docs/docs/components/charts/Scrubber/_webStyles.mdx new file mode 100644 index 0000000000..e6184834d8 --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/_webStyles.mdx @@ -0,0 +1,38 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { LineChart, Scrubber } from '@coinbase/cds-web-visualization'; + +import webStylesData from ':docgen/web-visualization/chart/scrubber/Scrubber/styles-data'; + +## Explorer + + + {(classNames) => ( + ({ min, max: max - 8 }), + }} + > + `$${dataIndex}`} + labelElevated + /> + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/charts/Scrubber/index.mdx b/apps/docs/docs/components/charts/Scrubber/index.mdx new file mode 100644 index 0000000000..416c9b57c1 --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/index.mdx @@ -0,0 +1,38 @@ +--- +id: scrubber +title: Scrubber +platform_switcher_options: { web: true, mobile: true } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; + +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web-visualization/chart/scrubber/Scrubber/toc-props'; +import mobilePropsToc from ':docgen/mobile-visualization/chart/scrubber/Scrubber/toc-props'; + +import WebPropsTable from './_webPropsTable.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import webMetadata from './webMetadata.json'; +import mobileMetadata from './mobileMetadata.json'; + + + + } + webStyles={} + webExamples={} + mobilePropsTable={} + mobileExamples={} + webExamplesToc={webExamplesToc} + mobileExamplesToc={mobileExamplesToc} + webPropsToc={webPropsToc} + webStylesToc={webStylesToc} + mobilePropsToc={mobilePropsToc} + /> + diff --git a/apps/docs/docs/components/charts/Scrubber/mobileMetadata.json b/apps/docs/docs/components/charts/Scrubber/mobileMetadata.json new file mode 100644 index 0000000000..7c085dce7c --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/mobileMetadata.json @@ -0,0 +1,25 @@ +{ + "import": "import { Scrubber } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx", + "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Point", + "url": "/components/charts/Point/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Scrubber/webMetadata.json b/apps/docs/docs/components/charts/Scrubber/webMetadata.json new file mode 100644 index 0000000000..efe89223aa --- /dev/null +++ b/apps/docs/docs/components/charts/Scrubber/webMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { Scrubber } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/scrubber/Scrubber.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chart-scrubber--all", + "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "Point", + "url": "/components/charts/Point/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/graphs/Sparkline/_mobileExamples.mdx b/apps/docs/docs/components/charts/Sparkline/_mobileExamples.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Sparkline/_mobileExamples.mdx rename to apps/docs/docs/components/charts/Sparkline/_mobileExamples.mdx diff --git a/apps/docs/docs/components/graphs/Sparkline/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/Sparkline/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Sparkline/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/Sparkline/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/graphs/Sparkline/_webExamples.mdx b/apps/docs/docs/components/charts/Sparkline/_webExamples.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Sparkline/_webExamples.mdx rename to apps/docs/docs/components/charts/Sparkline/_webExamples.mdx diff --git a/apps/docs/docs/components/graphs/Sparkline/_webPropsTable.mdx b/apps/docs/docs/components/charts/Sparkline/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Sparkline/_webPropsTable.mdx rename to apps/docs/docs/components/charts/Sparkline/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/Sparkline/index.mdx b/apps/docs/docs/components/charts/Sparkline/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Sparkline/index.mdx rename to apps/docs/docs/components/charts/Sparkline/index.mdx diff --git a/apps/docs/docs/components/charts/Sparkline/mobileMetadata.json b/apps/docs/docs/components/charts/Sparkline/mobileMetadata.json new file mode 100644 index 0000000000..5644e8ea99 --- /dev/null +++ b/apps/docs/docs/components/charts/Sparkline/mobileMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { Sparkline } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/Sparkline.tsx", + "description": "A small line chart component for displaying data trends.", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineGradient", + "url": "/components/charts/SparklineGradient/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/Sparkline/webMetadata.json b/apps/docs/docs/components/charts/Sparkline/webMetadata.json new file mode 100644 index 0000000000..561e15b61e --- /dev/null +++ b/apps/docs/docs/components/charts/Sparkline/webMetadata.json @@ -0,0 +1,19 @@ +{ + "import": "import { Sparkline } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/Sparkline.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractive--default", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "A small line chart component for displaying data trends.", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineGradient", + "url": "/components/charts/SparklineGradient/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/SparklineGradient/_mobileExamples.mdx b/apps/docs/docs/components/charts/SparklineGradient/_mobileExamples.mdx new file mode 100644 index 0000000000..a53abdc832 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineGradient/_mobileExamples.mdx @@ -0,0 +1,77 @@ +Expands upon the [Sparkline](/components/charts/Sparkline) component to provide a gradient stroke. However, for dark mode we disable the gradient effect. These are typically used at a larger size for portfolio charts or on detail Asset pages. + +### Dynamic path colors + +```jsx +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + return ( + + {assetColors.map((color) => ( + + ))} + + ); +} +``` + +### Dynamic background colors + +```jsx +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + return ( + + {assetColors.map((background) => ( + + + + ))} + + ); +} +``` + +### y axis scaling + +```jsx +function Example() { + const yAxisScalingFactor = 0.2; + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices, yAxisScalingFactor }); + return ( + + + Scale {yAxisScalingFactor} + + + + ); +} +``` + +### Sparkline fill + +```jsx +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + const area = useSparklineArea({ ...dimensions, data: prices }); + return ( + + {assetColors.map((color) => ( + + + + ))} + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/SparklineGradient/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/SparklineGradient/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineGradient/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/SparklineGradient/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/SparklineGradient/_webExamples.mdx b/apps/docs/docs/components/charts/SparklineGradient/_webExamples.mdx new file mode 100644 index 0000000000..92888d5eb3 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineGradient/_webExamples.mdx @@ -0,0 +1,77 @@ +Expands upon the [Sparkline](/components/charts/Sparkline) component to provide a gradient stroke. However, for dark mode we disable the gradient effect. These are typically used at a larger size for portfolio charts or on detail Asset pages. + +### Dynamic path colors + +```jsx live +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + return ( + + {assetColors.map((color) => ( + + ))} + + ); +} +``` + +### Dynamic background colors + +```jsx live +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + return ( + + {assetColors.map((background) => ( + + + + ))} + + ); +} +``` + +### y axis scaling + +```jsx live +function Example() { + const yAxisScalingFactor = 0.2; + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices, yAxisScalingFactor }); + return ( + + + Scale {yAxisScalingFactor} + + + + ); +} +``` + +### Sparkline fill + +```jsx live +function Example() { + const dimensions = { width: 400, height: 200 }; + const path = useSparklinePath({ ...dimensions, data: prices }); + const area = useSparklineArea({ ...dimensions, data: prices }); + return ( + + {assetColors.map((color) => ( + + + + ))} + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/SparklineGradient/_webPropsTable.mdx b/apps/docs/docs/components/charts/SparklineGradient/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineGradient/_webPropsTable.mdx rename to apps/docs/docs/components/charts/SparklineGradient/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/SparklineGradient/index.mdx b/apps/docs/docs/components/charts/SparklineGradient/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineGradient/index.mdx rename to apps/docs/docs/components/charts/SparklineGradient/index.mdx diff --git a/apps/docs/docs/components/charts/SparklineGradient/mobileMetadata.json b/apps/docs/docs/components/charts/SparklineGradient/mobileMetadata.json new file mode 100644 index 0000000000..becd67dea0 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineGradient/mobileMetadata.json @@ -0,0 +1,22 @@ +{ + "import": "import { SparklineGradient } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx", + "description": "A small line chart component with gradient fill below the line.", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "Sparkline", + "url": "/components/charts/Sparkline/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/SparklineGradient/webMetadata.json b/apps/docs/docs/components/charts/SparklineGradient/webMetadata.json new file mode 100644 index 0000000000..e736d09522 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineGradient/webMetadata.json @@ -0,0 +1,19 @@ +{ + "import": "import { SparklineGradient } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/SparklineGradient.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparkline--sparkline-gradient", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "A small line chart component with gradient fill below the line.", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "Sparkline", + "url": "/components/charts/Sparkline/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/SparklineInteractive/_mobileExamples.mdx b/apps/docs/docs/components/charts/SparklineInteractive/_mobileExamples.mdx new file mode 100644 index 0000000000..109d7e2489 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractive/_mobileExamples.mdx @@ -0,0 +1,299 @@ +### Default usage + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Fill Type + +The fill will be added by default with a gradient style. You can set `fillType="dotted"` to get a dotted gradient fill. + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Compact + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Hide period selector + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Scaling Factor + +The scaling factor is usually used when you want to show less variance in the chart. An example of this is a stable coin that doesn't change price by more than a few cents. + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### With header + +```jsx + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + +``` + +### Custom hover data + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Period selector placement + +`periodSelectorPlacement` can be used to place the period selector in different positions (`above` or `below`). + +```jsx +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Custom styles + +You can also provide custom styles, such as to remove bottom padding from the header. + +```jsx + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + styles={{ header: { paddingBottom: 0 } }} + /> + +``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/SparklineInteractive/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractive/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/SparklineInteractive/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/SparklineInteractive/_webExamples.mdx b/apps/docs/docs/components/charts/SparklineInteractive/_webExamples.mdx new file mode 100644 index 0000000000..d603d65f0b --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractive/_webExamples.mdx @@ -0,0 +1,451 @@ +### Default usage + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Fill Type + +The fill will be added by default with a gradient style. You can set `fillType="dotted"` to get a dotted gradient fill. + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Compact + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Hide period selector + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Scaling Factor + +The scaling factor is usually used when you want to show less variance in the chart. An example of this is a stable coin that doesn't change price by more than a few cents. + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### With header + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + + ); +}; +``` + +### Custom hover data + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Period selector placement + +`periodSelectorPlacement` can be used to place the period selector in different positions (`above` or `below`). + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + return ( + + + + ); +}; +``` + +### Custom styles + +You can also provide custom styles, such as to remove any horizontal padding from the header. + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + styles={{ header: { paddingLeft: 0, paddingRight: 0 } }} + /> + + ); +}; +``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/_webPropsTable.mdx b/apps/docs/docs/components/charts/SparklineInteractive/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractive/_webPropsTable.mdx rename to apps/docs/docs/components/charts/SparklineInteractive/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/index.mdx b/apps/docs/docs/components/charts/SparklineInteractive/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractive/index.mdx rename to apps/docs/docs/components/charts/SparklineInteractive/index.mdx diff --git a/apps/docs/docs/components/charts/SparklineInteractive/mobileMetadata.json b/apps/docs/docs/components/charts/SparklineInteractive/mobileMetadata.json new file mode 100644 index 0000000000..d0bafa6d7b --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractive/mobileMetadata.json @@ -0,0 +1,31 @@ +{ + "import": "import { SparklineInteractive } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "The SparklineInteractive is used to display a Sparkline that has multiple time periods", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineInteractiveHeader", + "url": "/components/charts/SparklineInteractiveHeader/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [ + { + "name": "react-native-gesture-handler", + "version": "^2.16.2" + }, + { + "name": "react-native-reanimated", + "version": "^3.14.0" + }, + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/SparklineInteractive/webMetadata.json b/apps/docs/docs/components/charts/SparklineInteractive/webMetadata.json new file mode 100644 index 0000000000..26c772aa6b --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractive/webMetadata.json @@ -0,0 +1,19 @@ +{ + "import": "import { SparklineInteractive } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractive--default", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "The SparklineInteractive is used to display a Sparkline that has multiple time periods", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineInteractiveHeader", + "url": "/components/charts/SparklineInteractiveHeader/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/SparklineInteractiveHeader/_mobileExamples.mdx b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_mobileExamples.mdx new file mode 100644 index 0000000000..5e28f0fcfa --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_mobileExamples.mdx @@ -0,0 +1,91 @@ +### Default usage + +```jsx + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + +``` + +### Fill + +The fill will be added by default + +```jsx + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + +``` + +### Compact + +```jsx + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + +``` + +### Custom Label + +```jsx + + + + + CustomHeader + + + } + /> + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + +``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractiveHeader/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/SparklineInteractiveHeader/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/SparklineInteractiveHeader/_webExamples.mdx b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_webExamples.mdx new file mode 100644 index 0000000000..5cea67b98e --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_webExamples.mdx @@ -0,0 +1,393 @@ +:::tip Accessibility tip +When possible combining content that is contextually related benefits screen reader users. The interactive header within Sparkline is one of these moments. Use an accessibilityLabel prop or aria-label to set the entire context of the interactive header. This way screen reader users will hear the asset name, price, and direction all in one sentence. +::: + +### Default usage + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + + ); +}; +``` + +### Fill + +The fill will be added by default + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + + ); +}; +``` + +### Compact + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + + ); +}; +``` + +### Custom Label + +```jsx live +() => { + const periods = [ + { label: '1H', value: 'hour' }, + { label: '1D', value: 'day' }, + { label: '1W', value: 'week' }, + { label: '1M', value: 'month' }, + { label: '1Y', value: 'year' }, + { label: 'All', value: 'all' }, + ]; + + const formatDate = useCallback((value, period) => { + if (period === 'hour' || period === 'day') + return value.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' }); + if (period === 'week' || period === 'month') + return value.toLocaleDateString('en-US', { month: 'numeric', day: 'numeric' }); + return value.toLocaleDateString('en-US', { month: 'numeric', year: 'numeric' }); + }, []); + + const formatPrice = (num) => num.toLocaleString('en-US', { maximumFractionDigits: 2 }); + + const generateSubHead = useCallback((point, period) => { + const firstPoint = sparklineInteractiveData[period][0]; + const increase = point.value > firstPoint.value; + return { + percent: `${formatPrice(Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100)}%`, + sign: increase ? 'upwardTrend' : 'downwardTrend', + variant: increase ? 'positive' : 'negative', + priceChange: `$${formatPrice(Math.abs(point.value - firstPoint.value))}`, + }; + }, []); + + const headerRef = useRef(null); + const [currentPeriod, setCurrentPeriod] = useState('day'); + const data = sparklineInteractiveData[currentPeriod]; + const lastPoint = data[data.length - 1]; + + const handleScrub = useCallback( + ({ point, period }) => { + headerRef.current?.update({ + title: `$${point.value.toLocaleString('en-US')}`, + subHead: generateSubHead(point, period), + }); + }, + [generateSubHead], + ); + + const handleScrubEnd = useCallback(() => { + headerRef.current?.update({ + title: `$${formatPrice(lastPoint.value)}`, + subHead: generateSubHead(lastPoint, currentPeriod), + }); + }, [lastPoint, currentPeriod, generateSubHead]); + + const handlePeriodChanged = useCallback( + (period) => { + setCurrentPeriod(period); + const newData = sparklineInteractiveData[period]; + const newLastPoint = newData[newData.length - 1]; + headerRef.current?.update({ + title: `$${formatPrice(newLastPoint.value)}`, + subHead: generateSubHead(newLastPoint, period), + }); + }, + [generateSubHead], + ); + + return ( + + + + + CustomHeader + + + } + /> + } + onPeriodChanged={handlePeriodChanged} + onScrub={handleScrub} + onScrubEnd={handleScrubEnd} + periods={periods} + strokeColor="#F7931A" + /> + + ); +}; +``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_webPropsTable.mdx b/apps/docs/docs/components/charts/SparklineInteractiveHeader/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractiveHeader/_webPropsTable.mdx rename to apps/docs/docs/components/charts/SparklineInteractiveHeader/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/index.mdx b/apps/docs/docs/components/charts/SparklineInteractiveHeader/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/SparklineInteractiveHeader/index.mdx rename to apps/docs/docs/components/charts/SparklineInteractiveHeader/index.mdx diff --git a/apps/docs/docs/components/charts/SparklineInteractiveHeader/mobileMetadata.json b/apps/docs/docs/components/charts/SparklineInteractiveHeader/mobileMetadata.json new file mode 100644 index 0000000000..bb8963a9db --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractiveHeader/mobileMetadata.json @@ -0,0 +1,18 @@ +{ + "import": "import { SparklineInteractiveHeader } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "The SparklineInteractiveHeader is used to display chart information that changes over time", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineInteractive", + "url": "/components/charts/SparklineInteractive/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/SparklineInteractiveHeader/webMetadata.json b/apps/docs/docs/components/charts/SparklineInteractiveHeader/webMetadata.json new file mode 100644 index 0000000000..c1f2d79636 --- /dev/null +++ b/apps/docs/docs/components/charts/SparklineInteractiveHeader/webMetadata.json @@ -0,0 +1,19 @@ +{ + "import": "import { SparklineInteractiveHeader } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractiveheader--default", + "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", + "description": "The SparklineInteractiveHeader is used to display chart information that changes over time", + "warning": "Sparkline components are deprecated. Please use LineChart instead.", + "relatedComponents": [ + { + "label": "SparklineInteractive", + "url": "/components/charts/SparklineInteractive/" + }, + { + "label": "LineChart", + "url": "/components/charts/LineChart/" + } + ], + "dependencies": [] +} diff --git a/apps/docs/docs/components/charts/XAxis/_mobileExamples.mdx b/apps/docs/docs/components/charts/XAxis/_mobileExamples.mdx new file mode 100644 index 0000000000..e2a0a2da2a --- /dev/null +++ b/apps/docs/docs/components/charts/XAxis/_mobileExamples.mdx @@ -0,0 +1,766 @@ +## Basic Example + +The XAxis component provides a horizontal axis for charts with automatic tick generation and labeling. + +```jsx + + + + + +``` + +## Multiple X Axes (Horizontal Layout) + +When `layout="horizontal"`, you can configure multiple x-axes and bind each series to an axis with `xAxisId`. +Use `XAxis`'s `axisId` prop to render each axis configuration. + +```jsx + + + + + + + + +``` + +## Axis Config + +Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. + +### Scale Type + +XAxis supports `linear` (default), `log`, and `band` scale types. +`linear` and `log` are numeric scales while `band` is a categorical scale. +`band` scale is required for bar charts. + +```jsx + + + + +``` + +### Domain + +An axis's domain is the range of values that the axis will display. +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx + ({ min: min - 5, max: max + 5 }), + }} +> + + + +``` + +#### Domain Limit + +For numeric scales, you can set the domain limit to `nice` or `strict` (default for XAxis). `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. + +### Range + +An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. + +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx + ({ min, max: max - 64 }), + }} +> + + +``` + +### Data + +Data sets x values for the axis. + +#### String Data + +Using string data will allow you to set string x values for each data point. + +```jsx + + + + +``` + +#### Number Data + +Using number data with a numeric scale will allow you to adjust the x values for each data point. + +```jsx + + + +``` + +### Category Padding + +For band scales, you can set the category padding to adjust the spacing between categories. The default is 0.1. This is a value between 0 and 1, where 0.1 = 10% spacing. + +```jsx + +``` + +## Axis Props + +Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. + +### Position + +You can set the position of an axis to `top` or `bottom` (default). + +```tsx +function XAxisPositionExample() { + const theme = useTheme(); + const lineA = [5, 5, 10, 90, 85, 70, 30, 25, 25]; + const lineB = [90, 85, 70, 25, 23, 40, 45, 40, 50]; + + const timeData = useMemo( + () => + [ + new Date(2023, 7, 31), + new Date(2023, 7, 31, 12), + new Date(2023, 8, 1), + new Date(2023, 8, 1, 12), + new Date(2023, 8, 2), + new Date(2023, 8, 2, 12), + new Date(2023, 8, 3), + new Date(2023, 8, 3, 12), + new Date(2023, 8, 4), + ].map((d) => d.getTime()), + [], + ); + + const dateFormatter = useCallback( + (index: number) => { + return new Date(timeData[index]).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + }); + }, + [timeData], + ); + + const timeOfDayFormatter = useCallback( + (index: number) => { + return new Date(timeData[index]).toLocaleTimeString('en-US', { + hour: '2-digit', + }); + }, + [timeData], + ); + + const timeOfDayTicks = useMemo(() => { + return timeData.map((d, index) => index); + }, [timeData]); + + const dateTicks = useMemo(() => { + return timeData.map((d, index) => index).filter((d) => d % 2 === 0); + }, [timeData]); + + return ( + + + + + + ); +} +``` + +### Grid + +You can show grid lines at each tick position using the `showGrid` prop. + +```jsx +function XAxisGridExample() { + const [showGrid, setShowGrid] = useState(true); + return ( + + + setShowGrid(!showGrid)}> + Show Grid + + + + + + + + + ); +} +``` + +You can also customize the grid lines using the `GridLineComponent` prop. + +```tsx +function CustomGridLineExample() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + return ( + + + + + + ); +} +``` + +On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. + +Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. + +```jsx +function BandGridPlacement() { + const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState('edges'); + + return ( + + + + + ); +} +``` + +### Line + +You can show the axis line using the `showLine` prop. + +```jsx +function XAxisLineExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +You can also customize the axis line using the `styles` props. + +```jsx +function XAxisLineStylesExample() { + const theme = useTheme(); + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +### Size + +The `size` prop sets the size of the axis in pixels. The default is 32 for XAxis, but can be adjusted to fit the size of your data. + +```jsx + + + + + +``` + +### Ticks + +You can use the `ticks`, `requestedTickCount`, and `tickInterval` (default for XAxis) props to control the number and placement of ticks on the XAxis. + +`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. + +```jsx + + + + + +``` + +Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. + +```jsx + + + + + +``` + +`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. + +```jsx + + + + + +``` + +### Tick Marks + +You can show tick marks on the axis using the `showTickMarks` prop. You can also customize the tick mark size using the `tickMarkSize` prop. + +```jsx +function XAxisTickMarksExample() { + const [showTickMarks, setShowTickMarks] = useState(true); + return ( + + + setShowTickMarks(!showTickMarks)}> + Show Tick Marks + + + + + + + + + ); +} +``` + +On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. + +Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. + +```jsx +function BandTickMarkPlacement() { + const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState('middle'); + + return ( + + + + + ); +} +``` + +### Tick Labels + +You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. + +```jsx + + `Day of ${value}`} /> + + + +``` + +If no data is set for the axis, it will receive the regular number value of the tick, which is normally the index corresponding to each value in the series. + +```jsx + + value * 2} /> + + + +``` + +### Label + +You can add a label to the axis using the `label` prop. + +```jsx + + + + + + +``` + +#### Custom Tick Labels + +You can create custom tick label components using the `TickLabelComponent` prop for advanced styling that works cross-platform. + +```jsx +function CustomTickLabelExample() { + const theme = useTheme(); + + const CustomXAxisTickLabel = useCallback( + (props) => , + [theme], + ); + + return ( + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/XAxis/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/XAxis/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/XAxis/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/XAxis/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/XAxis/_webExamples.mdx b/apps/docs/docs/components/charts/XAxis/_webExamples.mdx new file mode 100644 index 0000000000..4106d8b28b --- /dev/null +++ b/apps/docs/docs/components/charts/XAxis/_webExamples.mdx @@ -0,0 +1,802 @@ +## Basic Example + +The XAxis component provides a horizontal axis for charts with automatic tick generation and labeling. + +```jsx live + + + + + +``` + +## Multiple X Axes (Horizontal Layout) + +When `layout="horizontal"`, you can configure multiple x-axes and bind each series to an axis with `xAxisId`. +Use `XAxis`'s `axisId` prop to render each axis configuration. + +```jsx live + + + + + + + + +``` + +## Axis Config + +Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. + +### Scale Type + +XAxis supports `linear` (default), `log`, and `band` scale types. +`linear` and `log` are numeric scales while `band` is a categorical scale. +`band` scale is required for bar charts. + +```jsx live + + + + +``` + +### Domain + +An axis's domain is the range of values that the axis will display. +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx live + ({ min: min - 5, max: max + 5 }), + }} +> + + + +``` + +#### Domain Limit + +For numeric scales, you can set the domain limit to `nice` or `strict` (default for XAxis). `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. + +### Range + +An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. + +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx live + ({ min, max: max - 64 }), + }} +> + + +``` + +### Data + +Data sets x values for the axis. + +#### String Data + +Using string data will allow you to set string x values for each data point. + +```jsx live + + + + +``` + +#### Number Data + +Using number data with a numeric scale will allow you to adjust the x values for each data point. + +```jsx live + + + +``` + +### Category Padding + +For band scales, you can set the category padding to adjust the spacing between categories. The default is 0.1. This is a value between 0 and 1, where 0.1 = 10% spacing. + +```jsx live + +``` + +## Axis Props + +Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. + +### Position + +You can set the position of an axis to `top` or `bottom` (default). + +```jsx live +function XAxisPositionExample() { + const lineA = [5, 5, 10, 90, 85, 70, 30, 25, 25]; + const lineB = [90, 85, 70, 25, 23, 40, 45, 40, 50]; + + const timeData = useMemo( + () => + [ + new Date(2023, 7, 31), + new Date(2023, 7, 31, 12), + new Date(2023, 8, 1), + new Date(2023, 8, 1, 12), + new Date(2023, 8, 2), + new Date(2023, 8, 2, 12), + new Date(2023, 8, 3), + new Date(2023, 8, 3, 12), + new Date(2023, 8, 4), + ].map((d) => d.getTime()), + [], + ); + + const dateFormatter = useCallback( + (index) => { + return new Date(timeData[index]).toLocaleDateString('en-US', { + month: '2-digit', + day: '2-digit', + }); + }, + [timeData], + ); + + const timeOfDayFormatter = useCallback( + (index) => { + return new Date(timeData[index]).toLocaleTimeString('en-US', { + hour: '2-digit', + }); + }, + [timeData], + ); + + const timeOfDayTicks = useMemo(() => { + return timeData.map((d, index) => index); + }, [timeData]); + + const dateTicks = useMemo(() => { + return timeData.map((d, index) => index).filter((d) => d % 2 === 0); + }, [timeData]); + + return ( + + + + + + ); +} +``` + +### Grid + +You can show grid lines at each tick position using the `showGrid` prop. + +```jsx live +function XAxisGridExample() { + const [showGrid, setShowGrid] = useState(true); + return ( + + + setShowGrid(!showGrid)}> + Show Grid + + + + + + + + + ); +} +``` + +You can also customize the grid lines using the `GridLineComponent` prop. + +```jsx live +function CustomGridLineExample() { + const ThinSolidLine = memo((props) => ); + + return ( + + + + + + ); +} +``` + +On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. + +Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. + +```jsx live +function BandGridPlacement() { + const bandGridLinePlacements = [ + { id: 'edges', label: 'Edges' }, + { id: 'start', label: 'Start' }, + { id: 'middle', label: 'Middle' }, + { id: 'end', label: 'End' }, + ]; + const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState( + bandGridLinePlacements[0], + ); + + return ( + + + + Band Grid Placement + + + + + + + + + ); +} +``` + +### Line + +You can show the axis line using the `showLine` prop. + +```jsx live +function XAxisLineExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +You can also customize the axis line using the `classNames` and `styles` props. + +```jsx live +function XAxisLineStylesExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +### Size + +The `size` prop sets the size of the axis in pixels. The default is 32 for XAxis, but can be adjusted to fit the size of your data. + +```jsx live + + + + + +``` + +### Ticks + +You can use the `ticks`, `requestedTickCount`, and `tickInterval` (default for XAxis) props to control the number and placement of ticks on the XAxis. + +`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. + +```jsx live + + + + + +``` + +Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. + +```jsx live + + + + + +``` + +`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. + +```jsx live + + + + + +``` + +### Tick Marks + +You can show tick marks on the axis using the `showTickMarks` prop. You can also customize the tick mark size using the `tickMarkSize` prop. + +```jsx live +function XAxisTickMarksExample() { + const [showTickMarks, setShowTickMarks] = useState(true); + return ( + + + setShowTickMarks(!showTickMarks)}> + Show Tick Marks + + + + + + + + + ); +} +``` + +On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. + +Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. + +```jsx live +function BandTickMarkPlacement() { + const bandTickMarkPlacements = [ + { id: 'middle', label: 'Middle' }, + { id: 'edges', label: 'Edges' }, + { id: 'start', label: 'Start' }, + { id: 'end', label: 'End' }, + ]; + const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState( + bandTickMarkPlacements[0], + ); + + return ( + + + + Band Tick Mark Placement + + + + + + + + + ); +} +``` + +### Tick Labels + +You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. + +```jsx live + + `Day of ${value}`} /> + + + +``` + +If no data is set for the axis, it will receive the regular number value of the tick, which is normally the index corresponding to each value in the series. + +```jsx live + + value * 2} /> + + + +``` + +### Label + +You can add a label to the axis using the `label` prop. + +```jsx live + + + + + + +``` + +#### Custom Tick Labels + +You can create custom tick label components using the `TickLabelComponent` prop for advanced styling that works cross-platform. + +```jsx live +function CustomTickLabelExample() { + const CustomXAxisTickLabel = useCallback( + (props) => , + [], + ); + + return ( + + + + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/XAxis/_webPropsTable.mdx b/apps/docs/docs/components/charts/XAxis/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/XAxis/_webPropsTable.mdx rename to apps/docs/docs/components/charts/XAxis/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/XAxis/index.mdx b/apps/docs/docs/components/charts/XAxis/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/XAxis/index.mdx rename to apps/docs/docs/components/charts/XAxis/index.mdx diff --git a/apps/docs/docs/components/charts/XAxis/mobileMetadata.json b/apps/docs/docs/components/charts/XAxis/mobileMetadata.json new file mode 100644 index 0000000000..ab2345b573 --- /dev/null +++ b/apps/docs/docs/components/charts/XAxis/mobileMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { XAxis } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/XAxis.tsx", + "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/XAxis/webMetadata.json b/apps/docs/docs/components/charts/XAxis/webMetadata.json new file mode 100644 index 0000000000..6f372e36d2 --- /dev/null +++ b/apps/docs/docs/components/charts/XAxis/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { XAxis } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/XAxis.tsx", + "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting and data domains.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "YAxis", + "url": "/components/charts/YAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx b/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx new file mode 100644 index 0000000000..153c0fc898 --- /dev/null +++ b/apps/docs/docs/components/charts/YAxis/_mobileExamples.mdx @@ -0,0 +1,587 @@ +## Basic Example + +The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. + +```jsx + + + + + +``` + +## Axis Config + +Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. + +### Scale Type + +YAxis supports `linear` (default) and `log` scale types. Both `linear` and `log` are numeric scales. + +```jsx +function ScaleTypeExample() { + const theme = useTheme(); + return ( + + + value.toLocaleString()} + /> + + ); +} +``` + +### Domain + +An axis's domain is the range of values that the axis will display. +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx + ({ min: min - 50, max: max + 50 }), + }} +> + + + +``` + +#### Domain Limit + +You can set the domain limit to `nice` (default for YAxis) or `strict`. `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. + +### Range + +An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. + +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx + ({ min: min + 96, max: max - 96 }), + }} +> + + +``` + +## Axis Props + +Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. + +### Position + +You can set the position of an axis to `left` or `right` (default). + +```jsx + + + + + + + +``` + +When `layout="horizontal"`, CartesianChart supports only one y-axis configuration. + +### Grid + +You can show grid lines at each tick position using the `showGrid` prop. + +```jsx +function YAxisGridExample() { + const [showGrid, setShowGrid] = useState(true); + return ( + + + setShowGrid(!showGrid)}> + Show Grid + + + + + + + + + ); +} +``` + +You can also customize the grid lines using the `GridLineComponent` prop. + +```tsx +function CustomGridLineExample() { + const theme = useTheme(); + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + const gains = [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, + 0, 0, 0, + ]; + const losses = [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, + 0, 0, 0, -12, -10, + ]; + const series = [ + { id: 'gains', data: gains, color: theme.color.fgPositive, stackId: 'bars' }, + { id: 'losses', data: losses, color: theme.color.fgNegative, stackId: 'bars' }, + ]; + + return ( + + + `$${value}M`} + /> + + + + ); +} +``` + +### Line + +You can show the axis line using the `showLine` prop. + +```jsx +function YAxisLineExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +You can also customize the axis line using the `classNames` and `styles` props. + +```jsx +function YAxisLineStylesExample() { + const theme = useTheme(); + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +### Size + +The `size` prop sets the size of the axis in pixels. The default is 44 for YAxis, but can be adjusted to fit the size of your data. + +```jsx +function YAxisSizeExample() { + const theme = useTheme(); + return ( + + + value.toLocaleString()} + /> + + ); +} +``` + +### Ticks + +You can use the `ticks`, `requestedTickCount` (default for YAxis), and `tickInterval` props to control the number and placement of ticks on the YAxis. + +`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. + +```jsx + + + + + +``` + +Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. + +This is the default behavior for YAxis, and defaults to `5`. + +```jsx + + + + + +``` + +`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. + +```jsx + + + + + +``` + +### Tick Marks + +You can show tick marks on the axis using the `showTickMarks` prop. +You can also customize the tick mark size using the `tickMarkSize` prop. + +```jsx +function YAxisTickMarksExample() { + const [showTickMarks, setShowTickMarks] = useState(true); + return ( + + + setShowTickMarks(!showTickMarks)}> + Show Tick Marks + + + + + + + + + ); +} +``` + +### Tick Labels + +You can customize the tick labels using the `tickLabelFormatter` prop. + +```jsx + + `$${value}`} /> + + + +``` + +### Label + +You can add a label to the axis using the `label` prop. + +```jsx + + `$${value}`} /> + + + +``` + +#### Custom Tick Labels + +You can create custom tick label components using the `TickLabelComponent` prop for advanced styling and positioning that works cross-platform. + +```jsx +function CustomTickLabelExample() { + const CustomYAxisTickLabel = useCallback( + (props) => , + [], + ); + + return ( + + `$${value}`} + TickLabelComponent={CustomYAxisTickLabel} + /> + + + + ); +} +``` + +## Customization + +### Multiple Y Axes + +```jsx +function MultipleYAxesExample() { + const theme = useTheme(); + return ( + + + + `$${value}k`} + /> + `${value}%`} + /> + + + + + + Revenue ($) + + + + Profit Margin (%) + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/YAxis/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/YAxis/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/YAxis/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/YAxis/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/charts/YAxis/_webExamples.mdx b/apps/docs/docs/components/charts/YAxis/_webExamples.mdx new file mode 100644 index 0000000000..559ff4a900 --- /dev/null +++ b/apps/docs/docs/components/charts/YAxis/_webExamples.mdx @@ -0,0 +1,550 @@ +## Basic Example + +The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. + +```jsx live + + + + + +``` + +## Axis Config + +Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. + +### Scale Type + +YAxis supports `linear` (default) and `log` scale types. Both `linear` and `log` are numeric scales. + +```jsx live + + + value.toLocaleString()} + /> + +``` + +### Domain + +An axis's domain is the range of values that the axis will display. +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx live + ({ min: min - 50, max: max + 50 }), + }} +> + + + +``` + +#### Domain Limit + +You can set the domain limit to `nice` (default for YAxis) or `strict`. `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. + +### Range + +An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. + +You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. + +```jsx live + ({ min: min + 96, max: max - 96 }), + }} +> + + +``` + +## Axis Props + +Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. + +### Position + +You can set the position of an axis to `left` or `right` (default). + +```jsx live + + + + + + + +``` + +When `layout="horizontal"`, CartesianChart supports only one y-axis configuration. + +### Grid + +You can show grid lines at each tick position using the `showGrid` prop. + +```jsx live +function YAxisGridExample() { + const [showGrid, setShowGrid] = useState(true); + return ( + + + setShowGrid(!showGrid)}> + Show Grid + + + + + + + + + ); +} +``` + +You can also customize the grid lines using the `GridLineComponent` prop. + +```jsx live +function CustomGridLineExample() { + const ThinSolidLine = memo((props) => ); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + const gains = [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, + 0, 0, 0, + ]; + const losses = [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, + 0, 0, 0, -12, -10, + ]; + const series = [ + { id: 'gains', data: gains, color: 'var(--color-fgPositive)', stackId: 'bars' }, + { id: 'losses', data: losses, color: 'var(--color-fgNegative)', stackId: 'bars' }, + ]; + + return ( + + + `$${value}M`} + /> + + + + ); +} +``` + +### Line + +You can show the axis line using the `showLine` prop. + +```jsx live +function YAxisLineExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +You can also customize the axis line using the `classNames` and `styles` props. + +```jsx live +function YAxisLineStylesExample() { + const [showLine, setShowLine] = useState(true); + return ( + + + setShowLine(!showLine)}> + Show Line + + + + + + + + + ); +} +``` + +### Size + +The `size` prop sets the size of the axis in pixels. The default is 44 for YAxis, but can be adjusted to fit the size of your data. + +```jsx live + + + value.toLocaleString()} + /> + +``` + +### Ticks + +You can use the `ticks`, `requestedTickCount` (default for YAxis), and `tickInterval` props to control the number and placement of ticks on the YAxis. + +`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. + +```jsx live + + + + + +``` + +Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. + +This is the default behavior for YAxis, and defaults to `5`. + +```jsx live + + + + + +``` + +`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. + +```jsx live + + + + + +``` + +### Tick Marks + +You can show tick marks on the axis using the `showTickMarks` prop. +You can also customize the tick mark size using the `tickMarkSize` prop. + +```jsx live +function YAxisTickMarksExample() { + const [showTickMarks, setShowTickMarks] = useState(true); + return ( + + + setShowTickMarks(!showTickMarks)}> + Show Tick Marks + + + + + + + + + ); +} +``` + +### Tick Labels + +You can customize the tick labels using the `tickLabelFormatter` prop. + +```jsx live + + `$${value}`} /> + + + +``` + +### Label + +You can add a label to the axis using the `label` prop. + +```jsx live + + `$${value}`} /> + + + +``` + +#### Custom Tick Labels + +You can create custom tick label components using the `TickLabelComponent` prop for advanced styling and positioning that works cross-platform. + +```jsx live +function CustomTickLabelExample() { + const CustomYAxisTickLabel = useCallback( + (props) => , + [], + ); + + return ( + + `$${value}`} + TickLabelComponent={CustomYAxisTickLabel} + /> + + + + ); +} +``` + +## Customization + +### Multiple Y Axes + +```jsx live + + + `$${value}k`} + /> + `${value}%`} + /> + + +``` diff --git a/apps/docs/docs/components/graphs/YAxis/_webPropsTable.mdx b/apps/docs/docs/components/charts/YAxis/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/YAxis/_webPropsTable.mdx rename to apps/docs/docs/components/charts/YAxis/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/YAxis/index.mdx b/apps/docs/docs/components/charts/YAxis/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/YAxis/index.mdx rename to apps/docs/docs/components/charts/YAxis/index.mdx diff --git a/apps/docs/docs/components/charts/YAxis/mobileMetadata.json b/apps/docs/docs/components/charts/YAxis/mobileMetadata.json new file mode 100644 index 0000000000..3b488f1217 --- /dev/null +++ b/apps/docs/docs/components/charts/YAxis/mobileMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { YAxis } from '@coinbase/cds-mobile-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/YAxis.tsx", + "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + } + ], + "dependencies": [ + { + "name": "@shopify/react-native-skia", + "version": "^1.12.4 || ^2.0.0" + } + ] +} diff --git a/apps/docs/docs/components/charts/YAxis/webMetadata.json b/apps/docs/docs/components/charts/YAxis/webMetadata.json new file mode 100644 index 0000000000..cd95425f7d --- /dev/null +++ b/apps/docs/docs/components/charts/YAxis/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { YAxis } from '@coinbase/cds-web-visualization'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/YAxis.tsx", + "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", + "relatedComponents": [ + { + "label": "CartesianChart", + "url": "/components/charts/CartesianChart/" + }, + { + "label": "XAxis", + "url": "/components/charts/XAxis/" + } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] +} diff --git a/apps/docs/docs/components/data-display/ContentCell/_mobileStyles.mdx b/apps/docs/docs/components/data-display/ContentCell/_mobileStyles.mdx new file mode 100644 index 0000000000..2ff087cc19 --- /dev/null +++ b/apps/docs/docs/components/data-display/ContentCell/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/cells/ContentCell/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/data-display/ContentCell/_webStyles.mdx b/apps/docs/docs/components/data-display/ContentCell/_webStyles.mdx new file mode 100644 index 0000000000..b8fb90314b --- /dev/null +++ b/apps/docs/docs/components/data-display/ContentCell/_webStyles.mdx @@ -0,0 +1,24 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { ContentCell } from '@coinbase/cds-web/cells'; +import { Avatar } from '@coinbase/cds-web/media'; + +import webStylesData from ':docgen/web/cells/ContentCell/styles-data'; + +## Explorer + + + {(classNames) => ( + } + /> + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/data-display/ContentCell/index.mdx b/apps/docs/docs/components/data-display/ContentCell/index.mdx index 818e6828b1..fc16b1e4d1 100644 --- a/apps/docs/docs/components/data-display/ContentCell/index.mdx +++ b/apps/docs/docs/components/data-display/ContentCell/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/cells/ContentCell/toc-props'; import mobilePropsToc from ':docgen/mobile/cells/ContentCell/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -22,12 +24,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/data-display/ListCell/_mobileExamples.mdx b/apps/docs/docs/components/data-display/ListCell/_mobileExamples.mdx index 708500e85a..abf65cd979 100644 --- a/apps/docs/docs/components/data-display/ListCell/_mobileExamples.mdx +++ b/apps/docs/docs/components/data-display/ListCell/_mobileExamples.mdx @@ -19,7 +19,7 @@ A ListCell row is divided into the following 5 columns: ``` :::tip -Prefer `spacingVariant="condensed"` for the new ListCell design. The `compact` may be removed in a future major release. +Prefer `spacingVariant="condensed"` for the new ListCell design. Both `normal` and `compact` are deprecated and may be removed in a future major release. ::: ### Spacing Variant @@ -54,7 +54,7 @@ Prefer `spacingVariant="condensed"` for the new ListCell design. The `compact` m spacingVariant="normal" media={} onPress={console.log} - title="Normal" + title="Normal (deprecated)" variant="positive" /> diff --git a/apps/docs/docs/components/data-display/ListCell/_mobileStyles.mdx b/apps/docs/docs/components/data-display/ListCell/_mobileStyles.mdx new file mode 100644 index 0000000000..12ac29e745 --- /dev/null +++ b/apps/docs/docs/components/data-display/ListCell/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/cells/ListCell/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/data-display/ListCell/_webExamples.mdx b/apps/docs/docs/components/data-display/ListCell/_webExamples.mdx index dbe4855d35..a032976e90 100644 --- a/apps/docs/docs/components/data-display/ListCell/_webExamples.mdx +++ b/apps/docs/docs/components/data-display/ListCell/_webExamples.mdx @@ -19,7 +19,7 @@ A ListCell row is divided into the following 5 columns: ``` :::tip -Prefer `spacingVariant="condensed"` for the new ListCell design. The `compact` may be removed in a future major release. +Prefer `spacingVariant="condensed"` for the new ListCell design. Both `normal` and `compact` are deprecated and may be removed in a future major release. ::: ### Spacing Variant @@ -54,7 +54,7 @@ Prefer `spacingVariant="condensed"` for the new ListCell design. The `compact` m spacingVariant="normal" media={} onClick={console.log} - title="Normal" + title="Normal (deprecated)" variant="positive" /> diff --git a/apps/docs/docs/components/data-display/ListCell/_webStyles.mdx b/apps/docs/docs/components/data-display/ListCell/_webStyles.mdx new file mode 100644 index 0000000000..c94a68f394 --- /dev/null +++ b/apps/docs/docs/components/data-display/ListCell/_webStyles.mdx @@ -0,0 +1,36 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { ListCell } from '@coinbase/cds-web/cells'; +import { Avatar } from '@coinbase/cds-web/media'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { CellHelperText } from '@coinbase/cds-web/cells/CellHelperText'; + +import webStylesData from ':docgen/web/cells/ListCell/styles-data'; + +## Explorer + + + {(classNames) => ( + } + onClick={console.log} + title="List item" + variant="positive" + helperText={ + + This is a default helper message. + + } + /> + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/data-display/ListCell/index.mdx b/apps/docs/docs/components/data-display/ListCell/index.mdx index 914c8d3562..f83d4bbb30 100644 --- a/apps/docs/docs/components/data-display/ListCell/index.mdx +++ b/apps/docs/docs/components/data-display/ListCell/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/cells/ListCell/toc-props'; import mobilePropsToc from ':docgen/mobile/cells/ListCell/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -28,12 +30,16 @@ import { ListCellBanner } from '@site/src/components/page/ComponentBanner/ListCe /> } + webStyles={} webExamples={} mobilePropsTable={} + mobileStyles={} mobileExamples={} webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} webPropsToc={webPropsToc} + webStylesToc={webStylesToc} mobilePropsToc={mobilePropsToc} + mobileStylesToc={mobileStylesToc} /> diff --git a/apps/docs/docs/components/feedback/Banner/_webExamples.mdx b/apps/docs/docs/components/feedback/Banner/_webExamples.mdx index b610254e9c..85b843fab0 100644 --- a/apps/docs/docs/components/feedback/Banner/_webExamples.mdx +++ b/apps/docs/docs/components/feedback/Banner/_webExamples.mdx @@ -133,3 +133,25 @@ You can customize `borderRadius` to soften contextual and in-line banners. :::tip Avoid setting `borderRadius` for `styleVariant="global"` so the vertical status bar remains aligned. ::: + +### Bleed with Margin Props + +When using negative `margin*` props to create a bleed effect, explicitly set `width` so the Banner expands beyond its container. + +```tsx live + + + + Use with prop to override the default 100% width + + + +``` diff --git a/apps/docs/docs/components/feedback/Banner/mobileMetadata.json b/apps/docs/docs/components/feedback/Banner/mobileMetadata.json index 8a05f60ba9..f241f912d2 100644 --- a/apps/docs/docs/components/feedback/Banner/mobileMetadata.json +++ b/apps/docs/docs/components/feedback/Banner/mobileMetadata.json @@ -9,5 +9,10 @@ "url": "/components/cards/NudgeCard/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/feedback/Banner/webMetadata.json b/apps/docs/docs/components/feedback/Banner/webMetadata.json index defdb79622..03a9131e79 100644 --- a/apps/docs/docs/components/feedback/Banner/webMetadata.json +++ b/apps/docs/docs/components/feedback/Banner/webMetadata.json @@ -10,5 +10,10 @@ "url": "/components/cards/NudgeCard/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/feedback/Fallback/_mobileExamples.mdx b/apps/docs/docs/components/feedback/Fallback/_mobileExamples.mdx index 648d78733d..7148d8a62f 100644 --- a/apps/docs/docs/components/feedback/Fallback/_mobileExamples.mdx +++ b/apps/docs/docs/components/feedback/Fallback/_mobileExamples.mdx @@ -19,15 +19,11 @@ The shape of the fallback can further be customized with the `shape` prop. ``` -:::tip Rectangular fallback width +### Rectangular fallback width -If the fallback shape is a rectangle and the width is specified as a number, then by default, the width value will be recalculated and randomized within a predetermined threshold (e.g. to add some variety when mulitple fallbacks are presented together). If this behavior is undesirable (e.g. in server-side rendered web apps), randomization can be disabled with the `disableRandomRectWidth` prop. +If the fallback shape is a rectangle and the width is specified as a number, then by default, the width value will be recalculated and randomized within a predetermined threshold (e.g. to add some variety when multiple fallbacks are presented together). If this behavior is undesirable, randomization can be disabled with the `disableRandomRectWidth` prop. -
- -Alternatively, you may create a rectangle width variant by setting a number value on the `rectWidthVariant` prop. Variants map to a predetermined set of width values, which are cycled through repeatedly when the set is exhausted. Therefore, it's still possible to achieve some variety, but in a deterministic manner (i.e. safe for server-side rendering). Here's an example: - -
+Alternatively, you may create a rectangle width variant by setting a number value on the `rectWidthVariant` prop. Variants map to a predetermined set of width values, which are cycled through repeatedly when the set is exhausted. Therefore, it's still possible to achieve some variety, but in a deterministic manner. ```jsx function RenderFallbacksInList() { @@ -42,4 +38,61 @@ function RenderFallbacksInList() { } ``` -::: +### Accessibility + +Fallback has an `accessibilityLabel` prop to describe the loading state for assistive technologies. Wrap Fallback in a live region container to announce loading state changes. + +```jsx +function AccessibleFallback() { + const [isLoading, setIsLoading] = React.useState(true); + + return ( + + + {isLoading ? ( + + ) : ( + Profile content here + )} + + + + ); +} +``` + +If you render multiple Fallbacks in an area, you may use `aria-hidden` prop on each Fallback to disable individual announcements from assistive technologies. If you choose to do so, please add your own label in the parent container to indicate the loading state for the entire area. While the label element can be visually hidden, it is still crucial to mark the container as a live region for the label to be announced when state changes. + +```jsx +function AccessibleFallbackGroup() { + const [isLoading, setIsLoading] = React.useState(true); + + const visuallyHiddenStyle = { + position: 'absolute', + width: 1, + height: 1, + margin: -1, + overflow: 'hidden', + }; + + return ( + + + {isLoading ? ( + <> + Loading table data + + + + + + + ) : ( + Table content here + )} + + + + ); +} +``` diff --git a/apps/docs/docs/components/feedback/Fallback/_webExamples.mdx b/apps/docs/docs/components/feedback/Fallback/_webExamples.mdx index 6cbadee7fa..1f0b45042b 100644 --- a/apps/docs/docs/components/feedback/Fallback/_webExamples.mdx +++ b/apps/docs/docs/components/feedback/Fallback/_webExamples.mdx @@ -19,15 +19,11 @@ The shape of the fallback can further be customized with the `shape` prop. ``` -:::tip Rectangular fallback width +### Rectangular fallback width -If the fallback shape is a rectangle and the width is specified as a number, then by default, the width value will be recalculated and randomized within a predetermined threshold (e.g. to add some variety when mulitple fallbacks are presented together). If this behavior is undesirable (e.g. in server-side rendered web apps), randomization can be disabled with the `disableRandomRectWidth` prop. +If the fallback shape is a rectangle and the width is specified as a number, then by default, the width value will be recalculated and randomized within a predetermined threshold (e.g. to add some variety when multiple fallbacks are presented together). If this behavior is undesirable (e.g. in server-side rendered web apps), randomization can be disabled with the `disableRandomRectWidth` prop. -
- -Alternatively, you may create a rectangle width variant by setting a number value on the `rectWidthVariant` prop. Variants map to a predetermined set of width values, which are cycled through repeatedly when the set is exhausted. Therefore, it's still possible to achieve some variety, but in a deterministic manner (i.e. safe for server-side rendering). Here's an example: - -
+Alternatively, you may create a rectangle width variant by setting a number value on the `rectWidthVariant` prop. Variants map to a predetermined set of width values, which are cycled through repeatedly when the set is exhausted. Therefore, it's still possible to achieve some variety, but in a deterministic manner (i.e. safe for server-side rendering). ```jsx live function RenderFallbacksInList() { @@ -42,4 +38,65 @@ function RenderFallbacksInList() { } ``` -::: +### Accessibility + +Fallback has an `accessibilityLabel` prop to describe the loading state for assistive technologies. Wrap Fallback in a live region container to announce loading state changes. + +```jsx live +function AccessibleFallback() { + const [isLoading, setIsLoading] = React.useState(true); + + return ( + + + {isLoading ? ( + + ) : ( + Profile content here + )} + + + + ); +} +``` + +If you render multiple Fallbacks in an area, you may use `aria-hidden` prop on each Fallback to disable individual announcements from assistive technologies. If you choose to do so, please add your own label in the parent container to indicate the loading state for the entire area. While the label element can be visually hidden, it is still crucial to mark the container as a live region for the label to be announced when state changes. + +```jsx live +function AccessibleFallbackGroup() { + const [isLoading, setIsLoading] = React.useState(true); + + const visuallyHiddenStyle = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, + }; + + return ( + + + {isLoading ? ( + <> + Loading table data + + + + + + + ) : ( + Table content here + )} + + + + ); +} +``` diff --git a/apps/docs/docs/components/feedback/Fallback/mobileMetadata.json b/apps/docs/docs/components/feedback/Fallback/mobileMetadata.json index 996cf1beb9..852c8539f7 100644 --- a/apps/docs/docs/components/feedback/Fallback/mobileMetadata.json +++ b/apps/docs/docs/components/feedback/Fallback/mobileMetadata.json @@ -9,5 +9,10 @@ "url": "/components/feedback/Spinner/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/feedback/Fallback/webMetadata.json b/apps/docs/docs/components/feedback/Fallback/webMetadata.json index 3c495f3f0d..7a2f25e526 100644 --- a/apps/docs/docs/components/feedback/Fallback/webMetadata.json +++ b/apps/docs/docs/components/feedback/Fallback/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Fallback } from '@coinbase/cds-web/layout/Fallback'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/layout/Fallback.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-feedback-fallback--fallback-default", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-fallback--basic", "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=731-14933", "description": "A component that displays a fallback animation.", "relatedComponents": [ diff --git a/apps/docs/docs/components/feedback/ProgressBar/_mobileStyles.mdx b/apps/docs/docs/components/feedback/ProgressBar/_mobileStyles.mdx new file mode 100644 index 0000000000..748f55f3aa --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBar/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/visualizations/ProgressBar/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBar/_webStyles.mdx b/apps/docs/docs/components/feedback/ProgressBar/_webStyles.mdx new file mode 100644 index 0000000000..da1a45f238 --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBar/_webStyles.mdx @@ -0,0 +1,15 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { ProgressBar } from '@coinbase/cds-web/visualizations'; + +import webStylesData from ':docgen/web/visualizations/ProgressBar/styles-data'; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBar/index.mdx b/apps/docs/docs/components/feedback/ProgressBar/index.mdx index 76ddad5cde..237c7255dd 100644 --- a/apps/docs/docs/components/feedback/ProgressBar/index.mdx +++ b/apps/docs/docs/components/feedback/ProgressBar/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/visualizations/ProgressBar/toc-props'; import mobilePropsToc from ':docgen/mobile/visualizations/ProgressBar/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -21,13 +23,17 @@ import mobileMetadata from './mobileMetadata.json'; } - webExamples={} - mobilePropsTable={} mobileExamples={} - webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/feedback/ProgressBar/webMetadata.json b/apps/docs/docs/components/feedback/ProgressBar/webMetadata.json index c0eacd442e..b675196014 100644 --- a/apps/docs/docs/components/feedback/ProgressBar/webMetadata.json +++ b/apps/docs/docs/components/feedback/ProgressBar/webMetadata.json @@ -1,6 +1,7 @@ { "import": "import { ProgressBar } from '@coinbase/cds-web/visualizations/ProgressBar'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/visualizations/ProgressBar.tsx", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-progressbar--default", "description": "A visual indicator of completion progress.", "relatedComponents": [ { diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_mobileStyles.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_mobileStyles.mdx new file mode 100644 index 0000000000..2909e95924 --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/visualizations/ProgressBarWithFixedLabels/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_webStyles.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_webStyles.mdx new file mode 100644 index 0000000000..06a6d674db --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/_webStyles.mdx @@ -0,0 +1,19 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { ProgressBar, ProgressBarWithFixedLabels } from '@coinbase/cds-web/visualizations'; + +import webStylesData from ':docgen/web/visualizations/ProgressBarWithFixedLabels/styles-data'; + +## Explorer + + + {(classNames) => ( + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/index.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/index.mdx index fae968f67a..c5a65a84c7 100644 --- a/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/index.mdx +++ b/apps/docs/docs/components/feedback/ProgressBarWithFixedLabels/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/visualizations/ProgressBarWithFixedLabels/t import mobilePropsToc from ':docgen/mobile/visualizations/ProgressBarWithFixedLabels/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -25,13 +27,17 @@ import mobileMetadata from './mobileMetadata.json'; mobileMetadata={mobileMetadata} /> } - webExamples={} - mobilePropsTable={} mobileExamples={} - webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_mobileStyles.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_mobileStyles.mdx new file mode 100644 index 0000000000..ce177d50d6 --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/visualizations/ProgressBarWithFloatLabel/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_webStyles.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_webStyles.mdx new file mode 100644 index 0000000000..319e777cde --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/_webStyles.mdx @@ -0,0 +1,19 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { ProgressBar, ProgressBarWithFloatLabel } from '@coinbase/cds-web/visualizations'; + +import webStylesData from ':docgen/web/visualizations/ProgressBarWithFloatLabel/styles-data'; + +## Explorer + + + {(classNames) => ( + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/index.mdx b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/index.mdx index 2cae3ebc99..960119d881 100644 --- a/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/index.mdx +++ b/apps/docs/docs/components/feedback/ProgressBarWithFloatLabel/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/visualizations/ProgressBarWithFloatLabel/to import mobilePropsToc from ':docgen/mobile/visualizations/ProgressBarWithFloatLabel/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -25,13 +27,17 @@ import mobileMetadata from './mobileMetadata.json'; mobileMetadata={mobileMetadata} /> } - webExamples={} - mobilePropsTable={} mobileExamples={} - webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/feedback/ProgressCircle/_mobileExamples.mdx b/apps/docs/docs/components/feedback/ProgressCircle/_mobileExamples.mdx index 682ef8ef5c..4adfaa1440 100644 --- a/apps/docs/docs/components/feedback/ProgressCircle/_mobileExamples.mdx +++ b/apps/docs/docs/components/feedback/ProgressCircle/_mobileExamples.mdx @@ -8,6 +8,65 @@ ``` +## Indeterminate + +Use the `indeterminate` prop when progress is unknown (e.g. loading). The circle shows a spinning partial arc with no percentage text. This is the recommended replacement for the deprecated [Spinner](/components/feedback/Spinner) in loading contexts such as [IconButton](/components/buttons/IconButton) or button loading states. + +When `indeterminate` is true, the default color is `fgMuted`; you can override `color` as needed. Always provide `accessibilityLabel` so screen readers announce the loading state. + +### Thickness (weight) + +Indeterminate uses the same `weight` prop as determinate progress. The default is **`"normal"`** (4px stroke). Use `"thin"` (2px), `"semiheavy"` (8px), or `"heavy"` (12px) to change thickness. + +```jsx + + + Default (normal) + + + + weight="thin" + + + + weight="semiheavy" + + + +``` + +### Progress (arc length) + +When `indeterminate` is true, the **`progress` prop controls the length of the visible arc** (how much of the circle is drawn), not a completion percentage. It defaults to `0.75` (a 270° arc). Override it to change the arc length—e.g. `0.5` for a half circle or `0.25` for a shorter arc. + +```jsx + + + progress=0.25 + + + + progress=0.5 + + + + progress=0.75 (default) + + + +``` + +### Sizes and color + +```jsx + + + + + + +``` + ## Thin ```jsx diff --git a/apps/docs/docs/components/feedback/ProgressCircle/_mobileStyles.mdx b/apps/docs/docs/components/feedback/ProgressCircle/_mobileStyles.mdx new file mode 100644 index 0000000000..fa1647b5df --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressCircle/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/visualizations/ProgressCircle/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressCircle/_webExamples.mdx b/apps/docs/docs/components/feedback/ProgressCircle/_webExamples.mdx index c98bbf3045..85bf6a7b21 100644 --- a/apps/docs/docs/components/feedback/ProgressCircle/_webExamples.mdx +++ b/apps/docs/docs/components/feedback/ProgressCircle/_webExamples.mdx @@ -8,6 +8,65 @@ ``` +## Indeterminate + +Use the `indeterminate` prop when progress is unknown (e.g. loading). The circle shows a spinning partial arc with no percentage text. This is the recommended replacement for the deprecated [Spinner](/components/feedback/Spinner) in loading contexts such as [IconButton](/components/inputs/IconButton) or button loading states. + +When `indeterminate` is true, the default color is `fgMuted`; you can override `color` as needed. Always provide `accessibilityLabel` so screen readers announce the loading state. + +### Thickness (weight) + +Indeterminate uses the same `weight` prop as determinate progress. The default is **`"normal"`** (4px stroke). Use `"thin"` (2px), `"semiheavy"` (8px), or `"heavy"` (12px) to change thickness. + +```jsx live + + + Default (normal) + + + + weight="thin" + + + + weight="semiheavy" + + + +``` + +### Progress (arc length) + +When `indeterminate` is true, the **`progress` prop controls the length of the visible arc** (how much of the circle is drawn), not a completion percentage. It defaults to `0.75` (a 270° arc). Override it to change the arc length—e.g. `0.5` for a half circle or `0.25` for a shorter arc. + +```jsx live + + + progress=0.25 + + + + progress=0.5 + + + + progress=0.75 (default) + + + +``` + +### Sizes and color + +```jsx live + + + + + + +``` + ## Thin ```jsx live diff --git a/apps/docs/docs/components/feedback/ProgressCircle/_webStyles.mdx b/apps/docs/docs/components/feedback/ProgressCircle/_webStyles.mdx new file mode 100644 index 0000000000..5bf9013009 --- /dev/null +++ b/apps/docs/docs/components/feedback/ProgressCircle/_webStyles.mdx @@ -0,0 +1,20 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Box } from '@coinbase/cds-web/layout'; +import { ProgressCircle } from '@coinbase/cds-web/visualizations'; + +import webStylesData from ':docgen/web/visualizations/ProgressCircle/styles-data'; + +## Explorer + + + {(classNames) => ( + + + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/feedback/ProgressCircle/index.mdx b/apps/docs/docs/components/feedback/ProgressCircle/index.mdx index 8b5d481077..39819c84cd 100644 --- a/apps/docs/docs/components/feedback/ProgressCircle/index.mdx +++ b/apps/docs/docs/components/feedback/ProgressCircle/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/visualizations/ProgressCircle/toc-props'; import mobilePropsToc from ':docgen/mobile/visualizations/ProgressCircle/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -25,13 +27,17 @@ import mobileMetadata from './mobileMetadata.json'; mobileMetadata={mobileMetadata} /> } - webExamples={} - mobilePropsTable={} mobileExamples={} - webExamplesToc={webExamplesToc} mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} + mobileStyles={} + mobileStylesToc={mobileStylesToc} + webExamples={} + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + webStyles={} + webStylesToc={webStylesToc} /> diff --git a/apps/docs/docs/components/feedback/ProgressCircle/mobileMetadata.json b/apps/docs/docs/components/feedback/ProgressCircle/mobileMetadata.json index 8fa668a0c6..2a7feae799 100644 --- a/apps/docs/docs/components/feedback/ProgressCircle/mobileMetadata.json +++ b/apps/docs/docs/components/feedback/ProgressCircle/mobileMetadata.json @@ -1,7 +1,7 @@ { "import": "import { ProgressCircle } from '@coinbase/cds-mobile/visualizations/ProgressCircle'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/visualizations/ProgressCircle.tsx", - "description": "A circular visual indicator of completion progress.", + "description": "A circular visual indicator of completion progress. Supports both determinate progress (0–100%) and an indeterminate variant for loading states.", "relatedComponents": [ { "label": "ProgressBar", diff --git a/apps/docs/docs/components/feedback/ProgressCircle/webMetadata.json b/apps/docs/docs/components/feedback/ProgressCircle/webMetadata.json index adfe8dc94d..9fed3a93aa 100644 --- a/apps/docs/docs/components/feedback/ProgressCircle/webMetadata.json +++ b/apps/docs/docs/components/feedback/ProgressCircle/webMetadata.json @@ -1,7 +1,8 @@ { "import": "import { ProgressCircle } from '@coinbase/cds-web/visualizations/ProgressCircle'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/visualizations/ProgressCircle.tsx", - "description": "A circular visual indicator of completion progress.", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-progresscircle--default", + "description": "A circular visual indicator of completion progress. Supports both determinate progress (0–100%) and an indeterminate variant for loading states.", "relatedComponents": [ { "label": "ProgressBar", diff --git a/apps/docs/docs/components/feedback/Spinner/_mobileExamples.mdx b/apps/docs/docs/components/feedback/Spinner/_mobileExamples.mdx index c926d6989f..9ad870abbc 100644 --- a/apps/docs/docs/components/feedback/Spinner/_mobileExamples.mdx +++ b/apps/docs/docs/components/feedback/Spinner/_mobileExamples.mdx @@ -1,11 +1,62 @@ -Basic example +On mobile, Spinner wraps React Native's `ActivityIndicator` component. + +## Basics + +By default, the spinner renders at the `small` size with the theme's primary background color. ```jsx ``` -Large spinner +## Buttons + +Use the `loading` prop on [Button](/components/inputs/Button) and [IconButton](/components/inputs/IconButton) to show a spinner during async operations. The button becomes non-interactive while preserving its dimensions. + +```jsx + + + + + +``` + +## Styling + +### Sizing + +Use the `size` prop to control the spinner dimensions. Available values are `small` (default) and `large`. + +```jsx + + + + +``` + +### Animating + +Use the `animating` prop to control whether the spinner is spinning. Set to `false` to show a static indicator, which can be useful for loading state transitions. + +```jsx + + + + +``` + +## Accessibility + +The underlying `ActivityIndicator` provides built-in accessibility support. Use `accessibilityLabel` to provide additional context for screen readers. ```jsx - + ``` diff --git a/apps/docs/docs/components/feedback/Spinner/_webExamples.mdx b/apps/docs/docs/components/feedback/Spinner/_webExamples.mdx index dd99fa0fa6..9e50558ce3 100644 --- a/apps/docs/docs/components/feedback/Spinner/_webExamples.mdx +++ b/apps/docs/docs/components/feedback/Spinner/_webExamples.mdx @@ -1,11 +1,124 @@ -Basic example +## Basics + +The `size` prop is required and controls the spinner dimensions. The value is in pixels and determines the font size from which the width (10em), height (10em), and border width (1.1em) are calculated. ```jsx live ``` -Change color of spinner +## Buttons + +Use `loading` on [Button](/components/inputs/Button) and [IconButton](/components/inputs/IconButton) to show a spinner during async operations. The button becomes non-interactive while preserving its dimensions. + +```jsx live +function LoadingButtons() { + const [isLoading, setIsLoading] = useState(false); + + const handleClick = useCallback(() => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }, []); + + return ( + + + + + + ); +} +``` + +## Styling + +### Color + +Use the `color` prop to customize the spinner's color. The default is `fgMuted`. Any valid CDS design token color can be used. + +```jsx live + + + + + + +``` + +#### On Colored Backgrounds + +When placing a spinner on a colored background, choose a color with sufficient contrast. + +```jsx live + + + + + + + + + + + + + + +``` + +### Sizing + +Use different sizes to match the context - smaller spinners work well inline or within buttons, while larger spinners are appropriate for page or section loading states. + +```jsx live + + + + + + +``` + +## Accessibility + +Use `accessibilityLabel` to provide context for screen readers. The spinner uses `role="status"` and `aria-live="polite"` to announce the loading state. ```jsx live - + ``` diff --git a/apps/docs/docs/components/feedback/Spinner/mobileMetadata.json b/apps/docs/docs/components/feedback/Spinner/mobileMetadata.json index b12dc46661..36c1b874b7 100644 --- a/apps/docs/docs/components/feedback/Spinner/mobileMetadata.json +++ b/apps/docs/docs/components/feedback/Spinner/mobileMetadata.json @@ -1,11 +1,20 @@ { "import": "import { Spinner } from '@coinbase/cds-mobile/loaders/Spinner'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/loaders/Spinner.tsx", - "description": "A component that displays a spinning animation.", + "description": "A loading indicator that displays a rotating animation to communicate that content is loading or a background process is in progress.", + "warning": "This component is deprecated. Use indeterminate ProgressCircle for loading indicators instead.", "relatedComponents": [ { "label": "Fallback", "url": "/components/feedback/Fallback/" + }, + { + "label": "ProgressBar", + "url": "/components/feedback/ProgressBar/" + }, + { + "label": "ProgressCircle", + "url": "/components/feedback/ProgressCircle/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/feedback/Spinner/webMetadata.json b/apps/docs/docs/components/feedback/Spinner/webMetadata.json index d7ebcdc581..f6ed70c5a7 100644 --- a/apps/docs/docs/components/feedback/Spinner/webMetadata.json +++ b/apps/docs/docs/components/feedback/Spinner/webMetadata.json @@ -2,11 +2,24 @@ "import": "import { Spinner } from '@coinbase/cds-web/loaders/Spinner'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/loaders/Spinner.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-loaders-spinner--spinner-default", - "description": "A component that displays a spinning animation.", + "description": "A loading indicator that displays a rotating animation to communicate that content is loading or a background process is in progress.", + "warning": "This component is deprecated. Use indeterminate ProgressCircle for loading indicators instead.", "relatedComponents": [ { "label": "Fallback", "url": "/components/feedback/Fallback/" + }, + { + "label": "ProgressBar", + "url": "/components/feedback/ProgressBar/" + }, + { + "label": "ProgressCircle", + "url": "/components/feedback/ProgressCircle/" + }, + { + "label": "Button", + "url": "/components/inputs/Button/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx deleted file mode 100644 index d70e905a0e..0000000000 --- a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx +++ /dev/null @@ -1,132 +0,0 @@ -AreaChart is a cartesian chart variant that allows for easy visualization of stacked data. - -## Basic Example - -```jsx - -``` - -## Simple - -```jsx - -``` - -## Stacking - -You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/graphs/CartesianChart/#series-stacks) for more details. - -```jsx -function StackingExample() { - const theme = useTheme(); - return ( - } - type="dotted" - /> - ); -} -``` - -## Negative Values - -When an area chart contains negative values, the baseline automatically adjusts to zero instead of the bottom of the chart. The area fills from the data line to the zero baseline, properly showing both positive and negative regions. - -```jsx - } - showYAxis - yAxis={{ - showGrid: true, - }} -/> -``` - -## Area Styles - -You can have different area styles for each series. - -```jsx - -``` diff --git a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx deleted file mode 100644 index 2eb5751cb3..0000000000 --- a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx +++ /dev/null @@ -1,594 +0,0 @@ -AreaChart is a cartesian chart variant that allows for easy visualization of stacked data. - -## Basic Example - -```jsx live - -``` - -## Simple - -```jsx live - -``` - -## Stacking - -You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/graphs/CartesianChart/#series-stacks) for more details. - -```jsx live - } - type="dotted" -/> -``` - -## Negative Values - -When an area chart contains negative values, the baseline automatically adjusts to zero instead of the bottom of the chart. The area fills from the data line to the zero baseline, properly showing both positive and negative regions. - -```jsx live - } - showLines - showYAxis - yAxis={{ - showGrid: true, - }} -/> -``` - -## Area Styles - -You can have different area styles for each series. - -```jsx live - -``` - -## Animations - -You can configure chart transitions using the `transition` prop. - -### Customized Transitions - -You can pass in a custom spring based transition to your `AreaChart` for a custom transition. - -```jsx live -function AnimatedStackedAreas() { - const dataCount = 20; - const minYValue = 5000; - const maxDataOffset = 15000; - const minStepOffset = 2500; - const maxStepOffset = 10000; - const updateInterval = 500; - const seriesSpacing = 2000; - const myTransition = { type: 'spring', stiffness: 700, damping: 20 }; - - const seriesConfig = [ - { id: 'red', label: 'Red', color: 'rgb(var(--red40))' }, - { id: 'orange', label: 'Orange', color: 'rgb(var(--orange40))' }, - { id: 'yellow', label: 'Yellow', color: 'rgb(var(--yellow40))' }, - { id: 'green', label: 'Green', color: 'rgb(var(--green40))' }, - { id: 'blue', label: 'Blue', color: 'rgb(var(--blue40))' }, - { id: 'indigo', label: 'Indigo', color: 'rgb(var(--indigo40))' }, - { id: 'purple', label: 'Purple', color: 'rgb(var(--purple40))' }, - ]; - - const domainLimit = maxDataOffset + seriesConfig.length * seriesSpacing; - - function generateNextValue(previousValue) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataOffset) { - direction = -1; - } else if (previousValue <= minYValue) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - newValue = Math.max(minYValue, Math.min(maxDataOffset, newValue)); - return newValue; - } - - function generateInitialData() { - const data = []; - - let previousValue = minYValue + Math.random() * (maxDataOffset - minYValue); - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - - return data; - } - - const MemoizedDottedArea = memo((props) => ( - - )); - - function AnimatedChart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 0; - const newValue = generateNextValue(lastValue); - - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - const series = seriesConfig.map((config, index) => ({ - id: config.id, - label: config.label, - color: config.color, - data: index === 0 ? data : Array(dataCount).fill(seriesSpacing), - })); - - return ( - '', - domain: { min: 0, max: domainLimit }, - }} - /> - ); - } - - return ; -} -``` - -### Disable Animations - -You can also disable animations by setting the `animate` prop to `false`. - -```jsx live -function AnimatedStackedAreas() { - const dataCount = 20; - const minYValue = 5000; - const maxDataOffset = 15000; - const minStepOffset = 2500; - const maxStepOffset = 10000; - const updateInterval = 500; - const seriesSpacing = 2000; - const myTransition = { type: 'spring', stiffness: 700, damping: 20 }; - - const seriesConfig = [ - { id: 'red', label: 'Red', color: 'rgb(var(--red40))' }, - { id: 'orange', label: 'Orange', color: 'rgb(var(--orange40))' }, - { id: 'yellow', label: 'Yellow', color: 'rgb(var(--yellow40))' }, - { id: 'green', label: 'Green', color: 'rgb(var(--green40))' }, - { id: 'blue', label: 'Blue', color: 'rgb(var(--blue40))' }, - { id: 'indigo', label: 'Indigo', color: 'rgb(var(--indigo40))' }, - { id: 'purple', label: 'Purple', color: 'rgb(var(--purple40))' }, - ]; - - const domainLimit = maxDataOffset + seriesConfig.length * seriesSpacing; - - function generateNextValue(previousValue) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataOffset) { - direction = -1; - } else if (previousValue <= minYValue) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - newValue = Math.max(minYValue, Math.min(maxDataOffset, newValue)); - return newValue; - } - - function generateInitialData() { - const data = []; - - let previousValue = minYValue + Math.random() * (maxDataOffset - minYValue); - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - - return data; - } - - const MemoizedDottedArea = memo((props) => ( - - )); - - function AnimatedChart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 0; - const newValue = generateNextValue(lastValue); - - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - const series = seriesConfig.map((config, index) => ({ - id: config.id, - label: config.label, - color: config.color, - // First series gets animated data, others get constant height - data: index === 0 ? data : Array(dataCount).fill(seriesSpacing), - })); - - return ( - '', - domain: { min: 0, max: domainLimit }, - }} - /> - ); - } - - return ; -} -``` - -## Gradients - -You can use the `gradient` prop on `series` or `Area` components to enable gradients. - -Each stop requires an `offset`, which is based on the data within the x/y scale and `color`, with an optional `opacity` (defaults to 1). - -Values in between stops will be interpolated smoothly using [srgb color space](https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty). - -```jsx live -function ContinuousGradient() { - const spectrumColors = [ - 'blue', - 'green', - 'orange', - 'yellow', - 'gray', - 'indigo', - 'pink', - 'purple', - 'red', - 'teal', - 'chartreuse', - ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); - - return ( - - - {spectrumColors.map((color) => ( - setCurrentSpectrumColor(color)} - accessibilityLabel={`Select ${color}`} - style={{ - backgroundColor: `rgb(var(--${color}20))`, - border: `2px solid rgb(var(--${color}50))`, - outlineColor: `rgb(var(--${color}80))`, - outline: - currentSpectrumColor === color ? `2px solid rgb(var(--${color}80))` : undefined, - }} - width={{ base: 16, tablet: 24, desktop: 24 }} - height={{ base: 16, tablet: 24, desktop: 24 }} - borderRadius={1000} - /> - ))} - - [ - // Allows a function which accepts min/max or direct array - { offset: min, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: max, color: `rgb(var(--${currentSpectrumColor}20))` }, - ], - }, - }, - ]} - showYAxis - yAxis={{ - showGrid: true, - }} - > - - - - ); -} -``` - -### Discrete - -You can set multiple stops at the same offset to create a discrete gradient. - -```jsx live -function DiscreteGradient() { - const spectrumColors = [ - 'blue', - 'green', - 'orange', - 'yellow', - 'gray', - 'indigo', - 'pink', - 'purple', - 'red', - 'teal', - 'chartreuse', - ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); - - return ( - - - {spectrumColors.map((color) => ( - setCurrentSpectrumColor(color)} - accessibilityLabel={`Select ${color}`} - style={{ - backgroundColor: `rgb(var(--${color}20))`, - border: `2px solid rgb(var(--${color}50))`, - outlineColor: `rgb(var(--${color}80))`, - outline: - currentSpectrumColor === color ? `2px solid rgb(var(--${color}80))` : undefined, - }} - width={{ base: 16, tablet: 24, desktop: 24 }} - height={{ base: 16, tablet: 24, desktop: 24 }} - borderRadius={1000} - /> - ))} - - [ - { offset: min, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}50))` }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(var(--${currentSpectrumColor}50))`, - }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(var(--${currentSpectrumColor}20))`, - }, - { offset: max, color: `rgb(var(--${currentSpectrumColor}20))` }, - ], - }, - }, - ]} - showLines - strokeWidth={4} - showYAxis - yAxis={{ - showGrid: true, - }} - fillOpacity={0.5} - > - - - - ); -} -``` - -### Axes - -By default, gradients will be applied to the y-axis. You can apply a gradient to the x-axis by setting `axis` to `x` in the gradient definition. - -```jsx live -function XAxisGradient() { - const spectrumColors = [ - 'blue', - 'green', - 'orange', - 'yellow', - 'gray', - 'indigo', - 'pink', - 'purple', - 'red', - 'teal', - 'chartreuse', - ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); - - return ( - - - {spectrumColors.map((color) => ( - setCurrentSpectrumColor(color)} - accessibilityLabel={`Select ${color}`} - style={{ - backgroundColor: `rgb(var(--${color}20))`, - border: `2px solid rgb(var(--${color}50))`, - outlineColor: `rgb(var(--${color}80))`, - outline: - currentSpectrumColor === color ? `2px solid rgb(var(--${color}80))` : undefined, - }} - width={{ base: 16, tablet: 24, desktop: 24 }} - height={{ base: 16, tablet: 24, desktop: 24 }} - borderRadius={1000} - /> - ))} - - [ - { offset: min, color: `rgb(var(--${currentSpectrumColor}80))`, opacity: 0 }, - { offset: max, color: `rgb(var(--${currentSpectrumColor}20))`, opacity: 1 }, - ], - }, - }, - ]} - showYAxis - yAxis={{ - showGrid: true, - }} - > - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json b/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json deleted file mode 100644 index 9c76f0451a..0000000000 --- a/apps/docs/docs/components/graphs/AreaChart/mobileMetadata.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "import": "import { AreaChart } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/area/AreaChart.tsx", - "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/AreaChart/webMetadata.json b/apps/docs/docs/components/graphs/AreaChart/webMetadata.json deleted file mode 100644 index af041ad544..0000000000 --- a/apps/docs/docs/components/graphs/AreaChart/webMetadata.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "import": "import { AreaChart } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/area/AreaChart.tsx", - "description": "A chart component that displays data as filled areas beneath lines. Ideal for showing cumulative values, stacked data, or emphasizing volume over time.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx deleted file mode 100644 index ff0815032a..0000000000 --- a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx +++ /dev/null @@ -1,681 +0,0 @@ -## Basic Example - -Bar charts are a useful component for comparing discrete categories of data. -They are helpful for highlighting trends to users or allowing them to compare proportions at a glance. - -To start, pass in a series of data to the chart. - -```jsx - -``` - -## Multiple Series - -You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. - -```jsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Series Stacking - -You can also configure stacking for your chart using the `stacked` prop. - -```jsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -You can also configure multiple stacks by setting the `stackId` prop on each series. - -```jsx -function MonthlyGainsMultipleStacks() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -### Stack Gap - -```jsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Border Radius - -Bars have a default border radius of `100`. You can change this by setting the `borderRadius` prop on the chart. - -Stacks will only round the top corners of touching bars. - -```jsx - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -### Round Baseline - -You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. - -```jsx - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -## Negative Data - -```jsx -function PositiveAndNegativeCashFlow() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); - const gains = [ - 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, - 0, 0, 0, - ]; - - const losses = [ - -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, - 0, 0, 0, -12, -10, - ]; - const series = [ - { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, - { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, - ]; - - return ( - `$${value}M`, - }} - /> - ); -} -``` - -## Missing Bars - -You can pass in `null` or `0` values to not render a bar for that data point. - -```jsx - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} -/> -``` - -You can also use the `BarStackComponent` prop to render an empty circle for zero values. - -```jsx -function MonthlyRewards() { - const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; - const currentMonth = 7; - const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; - const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; - const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; - const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; - - const series = [ - { id: 'purple', data: purple, color: '#b399ff' }, - { id: 'blue', data: blue, color: '#4f7cff' }, - { id: 'cyan', data: cyan, color: '#00c2df' }, - { id: 'green', data: green, color: '#33c481' }, - ]; - - const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { - if (props.height === 0) { - const diameter = props.width; - return ( - - ); - } - - return {children}; - }; - - return ( - { - if (index == currentMonth) { - return {months[index]}; - } - return months[index]; - }, - categoryPadding: 0.27, - }} - /> - ); -}; -``` - -## Customization - -### Bar Spacing - -There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. - -```jsx - -``` - -### Minimum Size - -To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. -It is recommended to only use `stackMinSize` for stacked charts and `barMinSize` for non-stacked charts. - -#### Minimum Stack Size - -You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. - -```jsx - -``` - -#### Minimum Bar Size - -You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. - -```jsx - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} - barMinSize={4} -/> -``` - -### Multiple Y Axes - -You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stack. - -```jsx -function MultipleYAxes() { - const theme = useTheme(); - - return ( - - - - `$${value}k`} - /> - `${value}%`} - /> - - - - - - Revenue ($) - - - - Profit Margin (%) - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx deleted file mode 100644 index 5a34f3a887..0000000000 --- a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx +++ /dev/null @@ -1,997 +0,0 @@ -## Basic Example - -Bar charts are a useful component for comparing discrete categories of data. -They are helpful for highlighting trends to users or allowing them to compare proportions at a glance. - -To start, pass in a series of data to the chart. - -```jsx live - -``` - -## Multiple Series - -You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. - -```jsx live -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Series Stacking - -You can also configure stacking for your chart using the `stacked` prop. - -```jsx live -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -You can also configure multiple stacks by setting the `stackId` prop on each series. - -```jsx live -function MonthlyGainsMultipleStacks() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -### Stack Gap - -```jsx live -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Border Radius - -Bars have a default border radius of `100`. You can change this by setting the `borderRadius` prop on the chart. - -Stacks will only round the top corners of touching bars. - -```jsx live - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -### Round Baseline - -You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. - -```jsx live - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -## Negative Data - -```jsx live -function PositiveAndNegativeCashFlow() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); - const gains = [ - 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, - 0, 0, 0, - ]; - - const losses = [ - -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, - 0, 0, 0, -12, -10, - ]; - const series = [ - { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, - { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, - ]; - - return ( - `$${value}M`, - }} - /> - ); -} -``` - -## Missing Bars - -You can pass in `null` or `0` values to not render a bar for that data point. - -```jsx live - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} -/> -``` - -You can also use the `BarStackComponent` prop to render an empty circle for zero values. - -```jsx live -function MonthlyRewards() { - const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; - const currentMonth = 7; - const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; - const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; - const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; - const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; - - const series = [ - { id: 'purple', data: purple, color: '#b399ff' }, - { id: 'blue', data: blue, color: '#4f7cff' }, - { id: 'cyan', data: cyan, color: '#00c2df' }, - { id: 'green', data: green, color: '#33c481' }, - ]; - - const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { - if (props.height === 0) { - const diameter = props.width; - return ( - - ); - } - - return {children}; - }; - - return ( - { - if (index == currentMonth) { - return {months[index]}; - } - return months[index]; - }, - categoryPadding: 0.27, - }} - /> - ); -}; -``` - -## Customization - -### Bar Spacing - -There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. - -```jsx live - -``` - -### Minimum Size - -To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. -It is recommended to only use `stackMinSize` for stacked charts and `barMinSize` for non-stacked charts. - -#### Minimum Stack Size - -You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. - -```jsx live - -``` - -#### Minimum Bar Size - -You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. - -```jsx live - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} - barMinSize={4} -/> -``` - -### Multiple Y Axes - -You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stack. - -```jsx live - - - - `$${value}k`} - /> - `${value}%`} - /> - - - - - - Revenue ($) - - - - Profit Margin (%) - - - -``` - -### Custom Components - -#### Candlesticks - -You can set the `BarComponent` prop to render a custom component for bars. - -```jsx live -function Candlesticks() { - const infoTextId = useId(); - const infoTextRef = React.useRef(null); - const selectedIndexRef = React.useRef(null); - const stockData = btcCandles.slice(0, 90) - .reverse(); - const min = Math.min(...stockData.map((data) => parseFloat(data.low))); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - - // Custom line component that renders a rect to highlight the entire bandwidth - const BandwidthHighlight = memo(({ d, stroke }) => { - const { getXScale, drawingArea, getXAxis } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - const xScale = getXScale(); - const xAxis = getXAxis(); - - if (!xScale || scrubberPosition === undefined) return - - const xPos = xScale(scrubberPosition); - - if (xPos === undefined) return - - return ( - - ); - }); - - const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]) as [ - number, - number, - ][]; - - const CandlestickBarComponent = memo( - ({ x, y, width, height, originY, dataX, ...props }) => { - const { getYScale } = useCartesianChartContext(); - const yScale = getYScale(); - - const wickX = x + width / 2; - - const timePeriodValue = stockData[dataX as number]; - - const open = parseFloat(timePeriodValue.open); - const close = parseFloat(timePeriodValue.close); - - const bullish = open < close; - const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; - const openY = yScale?.(open) ?? 0; - const closeY = yScale?.(close) ?? 0; - - const bodyHeight = Math.abs(openY - closeY); - const bodyY = openY < closeY ? openY : closeY; - - return ( - - - - - ); - }, - ); - - const formatPrice = React.useCallback((price: string) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(parseFloat(price)); - }, []); - - - const formatThousandsPrice = React.useCallback((price: string) => { - const formattedPrice = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(parseFloat(price) / 1000); - - return `${formattedPrice}k`; - }, []); - - - const formatVolume = React.useCallback((volume: string) => { - const volumeInThousands = parseFloat(volume) / 1000; - return ( - new Intl.NumberFormat('en-US', { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(volumeInThousands) + 'k' - ); - }, []); - - const formatTime = React.useCallback( - (index) => { - if (index === null || index === undefined || index >= stockData.length) return ''; - const ts = parseInt(stockData[index].start); - return new Date(ts * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }, - [stockData], - ); - - const updateInfoText = React.useCallback( - (index) => { - if (!infoTextRef.current) return; - - const text = - index !== null && index !== undefined - ? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice( - stockData[index].close, - )}, Volume: ${(parseFloat(stockData[index].volume) / 1000).toFixed(2)}k` - : formatPrice(stockData[stockData.length - 1].close); - - infoTextRef.current.textContent = text; - selectedIndexRef.current = index; - }, - [stockData, formatPrice, formatVolume], - ); - const initialInfo = formatPrice(stockData[stockData.length - 1].close); - - return ( - - - {initialInfo} - - {children}} - animate={false} - borderRadius={0} - height={{ base: 150, tablet: 200, desktop: 250 }} - inset={{ top: 8, bottom: 8, left: 0, right: 0 }} - onScrubberPositionChange={updateInfoText} - series={[ - { - id: 'stock-prices', - data: candlesData, - }, - ]} - xAxis={{ - tickLabelFormatter: formatTime, - }} - yAxis={{ - domain: { min }, - tickLabelFormatter: formatThousandsPrice, - width: 40, - showGrid: true, - GridLineComponent: ThinSolidLine, - }} - aria-labelledby={infoTextId} - > - - - - ); -}; -``` - -#### Outlined Stacks - -You can set the `BarStackComponent` prop to render a custom component for stacks. - -```jsx live -function MonthlyRewards() { - const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { - return ( - <> - - {children} - - ); - }; - - return ( - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - yAxis={{ range: ({ min, max }) => ({ min, max: max - 4 }) }} - style={{ margin: '0 auto' }} - /> - ); -} -``` - -## Custom Transitions - -You can customize the transition animations for your bar chart using the `transition` prop. -This allows you to control enter, update, and exit animations separately. - -```jsx live -function UpdatingChartValues() { - const [data, setData] = React.useState([45, 80, 120, 95, 150, 110, 85]); - const [nullIndex, setNullIndex] = React.useState(null); - - const displayData = React.useMemo(() => { - if (nullIndex === null) return data; - return data.map((d, i) => (i === nullIndex ? null : d)); - }, [data, nullIndex]); - - return ( - - - - - - - Default Animations - - - Custom Update Animations - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json b/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json deleted file mode 100644 index d2037bf2bb..0000000000 --- a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "import": "import { BarChart } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/bar/BarChart.tsx", - "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/BarChart/webMetadata.json b/apps/docs/docs/components/graphs/BarChart/webMetadata.json deleted file mode 100644 index cecea286aa..0000000000 --- a/apps/docs/docs/components/graphs/BarChart/webMetadata.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "import": "import { BarChart } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/bar/BarChart.tsx", - "description": "A bar chart component for comparing values across categories. Supports horizontal and vertical orientations, stacked bars, and grouped series.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx deleted file mode 100644 index c61f3307f2..0000000000 --- a/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx +++ /dev/null @@ -1,734 +0,0 @@ -CartesianChart is a customizable, SVG based component that can be used to display a variety of data in a x/y coordinate space. The underlying logic is handled by D3. - -## Basics - -[AreaChart](/components/graphs/AreaChart/), [BarChart](/components/graphs/BarChart/), and [LineChart](/components/graphs/LineChart/) are built on top of CartesianChart and have default functionality for your chart. - -```jsx - - - - - - - - - - - -``` - -## Setup - -All charts uses Skia Canvas for rendering, which requires a context bridge to share React contexts with the Skia renderer. You need to wrap your app with `ChartBridgeProvider` at the root of your app to enable charts to access theme and other React contexts. - -```jsx -import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization/chart'; -import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; - -function App() { - return ( - - - {/* Your app content with charts */} - - - ); -} -``` - -## Series - -Series are the data that will be displayed on the chart. Each series must have a defined `id`. - -### Series Data - -You can pass in an array of numbers or an array of tuples for the `data` prop. Passing in null values is equivalent to no data at that index. - -```jsx -function ForecastedPrice() { - const theme = useTheme(); - - const ForecastRect = memo(({ startIndex, endIndex }) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - - const xScale = getXScale(); - - if (!xScale) return; - - const startX = xScale(startIndex); - const endX = xScale(endIndex); - return ( - - ); - }); - return ( - - - - - - ); -} -``` - -### Series Axis IDs - -Each series can have a different `yAxisId`, allowing you to compare data from different contexts. - -```jsx -function SeriesAxisIds() { - const theme = useTheme(); - - return ( - - - `$${value}k`} - width={60} - /> - `$${value}k`} - /> - - - ); -} -``` - -### Series Stacks - -You can provide a `stackId` to stack series together. - -```jsx -function SeriesStacks() { - const theme = useTheme(); - - return ( - - -
- ); -} -``` - -## Axes - -You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` accepts an object while `yAxis` accepts an object or array. - -```jsx - - - - - -``` - -For more info, learn about [XAxis](/components/graphs/XAxis/#axis-config) and [YAxis](/components/graphs/YAxis/#axis-config) configuration. - -## Inset - -You can adjust the inset around the entire chart (outside the axes) with the `inset` prop. This is useful for when you want to have components that are outside of the drawing area of the data but still within the chart svg. - -You can also remove the default inset, such as to have a compact line chart. - -```jsx -function Insets() { - const theme = useTheme(); - - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const formatPrice = useCallback((dataIndex: number) => { - const price = data[dataIndex]; - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - return ( - - - No inset - - - - Custom inset - - - - - - Default inset - - - - - - ); -} -``` - -## Scrubbing - -CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. - -One example of using the scrubber is to provide haptic feedback when the user interacts with the chart. You can trigger a light impact each time the scrubber position changes or even do a dynamic impact depending on the value change, such as a heavy impact when the user crosses a significant boundary of time or reaches a significant market event. - -```jsx -function Scrubbing() { - const [scrubIndex, setScrubIndex] = useState(undefined); - - const onScrubberPositionChange = useCallback((index: number | undefined) => { - // Do a light impact when the scrubber position changes - // An initial and final impact is already configured by the chart - if (scrubIndex !== undefined && index !== undefined) { - void Haptics.lightImpact(); - } - setScrubIndex(index); - }, [scrubIndex]); - - return ( - - Scrubber index: {scrubIndex ?? 'none'} - - - - - ); -} -``` - -### Allow Overflow Gestures - -By default, the scrubber will not allow overflow gestures. You can allow overflow gestures by setting the `allowOverflowGestures` prop to `true`. - -```jsx - - ... - -``` - -## Customization - -### Price with Volume - -You can showcase the price and volume of an asset over time within one chart. - -```jsx -function PriceWithVolume() { - const theme = useTheme(); - - const [scrubIndex, setScrubIndex] = useState(null); - const btcData = btcCandles - .slice(0, 180) - .reverse() - - const btcPrices = btcData.map((candle) => parseFloat(candle.close)); - const btcVolumes = btcData.map((candle) => parseFloat(candle.volume)); - const btcDates = btcData.map((candle) => new Date(parseInt(candle.start) * 1000)); - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const formatPriceInThousands = useCallback((price: number) => { - return `$${(price / 1000).toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - })}k`; - }, []); - - const formatVolume = useCallback((volume: number) => { - return `${(volume / 1000).toFixed(2)}K`; - }, []); - - const formatDate = useCallback((date: Date) => { - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }, []); - - const displayIndex = scrubIndex ?? btcPrices.length - 1; - const currentPrice = btcPrices[displayIndex]; - const currentVolume = btcVolumes[displayIndex]; - const currentDate = btcDates[displayIndex]; - const priceChange = displayIndex > 0 - ? ((currentPrice - btcPrices[displayIndex - 1]) / btcPrices[displayIndex - 1]) - : 0; - - const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const headerId = useId(); - - return ( - - Bitcoin} - balance={{formatPrice(currentPrice)}} - end={ - - - {formatDate(currentDate)} - {formatVolume(currentVolume)} - - - - - - } - /> - ({ min, max: max - 16 }) }} - yAxis={[ - { - id: 'price', - domain: ({ min, max }) => ({ min: min * 0.9, max }), - }, - { - id: 'volume', - range: ({ min, max }) => ({ min: max - 32, max }), - }, - ]} - accessibilityLabel={accessibilityLabel} - aria-labelledby={headerId} - inset={{ top: 8, left: 8, right: 0, bottom: 0 }} - > - - - - - - - ); -} -``` - -### Earnings History - -You can also create your own type of cartesian chart by using `getSeriesData`, `getXScale`, and `getYScale` directly. - -```jsx -function EarningsHistory() { - const theme = useTheme(); - const CirclePlot = memo(({ seriesId, opacity = 1 }: { seriesId: string, opacity?: number }) => { - const { drawingArea, getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); - const series = getSeries(seriesId); - const data = getSeriesData(seriesId); - const xScale = getXScale(); - const yScale = getYScale(series?.yAxisId); - - if (!xScale || !yScale || !data || !isCategoricalScale(xScale)) return null; - - const yScaleSize = Math.abs(yScale.range()[1] - yScale.range()[0]); - - // Have circle diameter be the smaller of the x scale bandwidth or 10% of the y space available - const diameter = Math.min(xScale.bandwidth(), yScaleSize / 10); - - return ( - - {data.map((value, index) => { - if (value === null || value === undefined) return null; - - // Get x position from band scale - center of the band - const xPos = xScale(index); - if (xPos === undefined) return null; - - const centerX = xPos + xScale.bandwidth() / 2; - - // Get y position from value - const yValue = Array.isArray(value) ? value[1] : value; - const centerY = yScale(yValue); - if (centerY === undefined) return null; - - return ( - - ); - })} - - ); - }); - - const quarters = useMemo(() => ['Q1', 'Q2', 'Q3', 'Q4'], []); - const estimatedEPS = useMemo(() => [1.71, 1.82, 1.93, 2.34], []); - const actualEPS = useMemo(() => [1.68, 1.83, 2.01, 2.24], []); - - const formatEarningAmount = useCallback((value: number) => { - return `$${value.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const surprisePercentage = useCallback( - (index: number): ChartTextChildren => { - const percentage = (actualEPS[index] - estimatedEPS[index]) / estimatedEPS[index]; - const percentageString = percentage.toLocaleString('en-US', { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - - return ( - 0 ? theme.color.fgPositive : theme.color.fgNegative, - fontWeight: 'bold', - }} - > - {percentage > 0 ? '+' : ''} - {percentageString} - - ); - }, - [actualEPS, estimatedEPS], - ); - - const LegendItem = memo(({ opacity = 1, label }: { opacity?: number, label: string }) => { - return ( - - - {label} - - ); - }); - - const LegendDot = memo((props: BoxBaseProps) => { - return ; - }); - - return ( - - - - quarters[index]} /> - - - - - - - - - - ); -} -``` - -### Trading Trends - -You can have multiple axes with different domains and ranges to showcase different pieces of data over the time time period. - -```jsx -function TradingTrends() { - const theme = useTheme(); - - function TradingTrends() { - const profitData = [34, 24, 28, -4, 8, -16, -3, 12, 24, 18, 20, 28]; - const gains = profitData.map((value) => (value > 0 ? value : 0)); - const losses = profitData.map((value) => (value < 0 ? value : 0)); - - const renderProfit = useCallback((value: number) => { - return `$${value}M`; - }, []); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - const ThickSolidLine = memo((props: SolidLineProps) => ); - - return ( - ({ min: min, max: max - 64 }), domain: { min: -40, max: 40 } }, - { id: 'revenue', range: ({ min, max }) => ({ min: max - 64, max }), domain: { min: 100 } }, - ]} - > - - - - - - - ); - } -} -``` diff --git a/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx b/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx deleted file mode 100644 index fc2b83d082..0000000000 --- a/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx +++ /dev/null @@ -1,672 +0,0 @@ -CartesianChart is a customizable, SVG based component that can be used to display a variety of data in a x/y coordinate space. The underlying logic is handled by D3. - -## Basic Example - -[AreaChart](/components/graphs/AreaChart/), [BarChart](/components/graphs/BarChart/), and [LineChart](/components/graphs/LineChart/) are built on top of CartesianChart and have default functionality for your chart. - -```jsx live - - - - - - - - - - - -``` - -## Series - -Series are the data that will be displayed on the chart. Each series must have a defined `id`. - -### Series Data - -You can pass in an array of numbers or an array of tuples for the `data` prop. Passing in null values is equivalent to no data at that index. - -```jsx live -function ForecastedPrice() { - const ForecastRect = memo(({ startIndex, endIndex }) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - - const xScale = getXScale(); - - if (!xScale) return; - - const startX = xScale(startIndex); - const endX = xScale(endIndex); - return ( - - ); - }); - return ( - - - - - - ); -} -``` - -### Series Axis IDs - -Each series can have a different `yAxisId`, allowing you to compare data from different contexts. - -```jsx live - - - `$${value}k`} - width={60} - /> - `$${value}k`} - /> - - -``` - -### Series Stacks - -You can provide a `stackId` to stack series together. - -```jsx live - - -
-``` - -## Axes - -You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` accepts an object while `yAxis` accepts an object or array. - -```jsx live - - - - - -``` - -For more info, learn about [XAxis](/components/graphs/XAxis/#axis-config) and [YAxis](/components/graphs/YAxis/#axis-config) configuration. - -## Inset - -You can adjust the inset around the entire chart (outside the axes) with the `inset` prop. This is useful for when you want to have components that are outside of the drawing area of the data but still within the chart svg. - -You can also remove the default inset, such as to have a compact line chart. - -```jsx live -function Insets() { - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - -const formatPrice = useCallback((dataIndex: number) => { - const price = data[dataIndex]; - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; -}, []); - - return ( - - - No inset - - - - Custom inset - - - - - - Default inset - - - - - - ); -} -``` - -## Scrubbing - -CartesianChart has built-in scrubbing functionality that can be enabled with the `enableScrubbing` prop. This will then enable the usage of `onScrubberPositionChange` to get the current position of the scrubber as the user interacts with the chart. - -```jsx live -function Scrubbing() { - const [scrubIndex, setScrubIndex] = useState(undefined); - - const onScrubberPositionChange = useCallback((index: number | undefined) => { - setScrubIndex(index); - }, []); - - return ( - - Scrubber index: {scrubIndex ?? 'none'} - - - - - ); -} -``` - -## Customization - -### Price with Volume - -You can showcase the price and volume of an asset over time within one chart. - -```jsx live -function PriceWithVolume() { - const [scrubIndex, setScrubIndex] = useState(null); - const btcData = btcCandles - .slice(0, 180) - .reverse() - - const btcPrices = btcData.map((candle) => parseFloat(candle.close)); - const btcVolumes = btcData.map((candle) => parseFloat(candle.volume)); - const btcDates = btcData.map((candle) => new Date(parseInt(candle.start) * 1000)); - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const formatPriceInThousands = useCallback((price: number) => { - return `$${(price / 1000).toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 2, - })}k`; - }, []); - - const formatVolume = useCallback((volume: number) => { - return `${(volume / 1000).toFixed(2)}K`; - }, []); - - const formatDate = useCallback((date: Date) => { - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }, []); - - const displayIndex = scrubIndex ?? btcPrices.length - 1; - const currentPrice = btcPrices[displayIndex]; - const currentVolume = btcVolumes[displayIndex]; - const currentDate = btcDates[displayIndex]; - const priceChange = displayIndex > 0 - ? ((currentPrice - btcPrices[displayIndex - 1]) / btcPrices[displayIndex - 1]) - : 0; - - const accessibilityLabel = useMemo(() => { - if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const headerId = useId(); - - return ( - - Bitcoin} - balance={{formatPrice(currentPrice)}} - end={ - - - {formatDate(currentDate)} - {formatVolume(currentVolume)} - - - - - - } - /> - ({ min, max: max - 16 }) }} - yAxis={[ - { - id: 'price', - domain: ({ min, max }) => ({ min: min * 0.9, max }), - }, - { - id: 'volume', - range: ({ min, max }) => ({ min: max - 32, max }), - }, - ]} - accessibilityLabel={accessibilityLabel} - aria-labelledby={headerId} - inset={{ top: 8, left: 8, right: 0, bottom: 0 }} - > - - - - - - - ); -} -``` - -### Earnings History - -You can also create your own type of cartesian chart by using `getSeriesData`, `getXScale`, and `getYScale` directly. - -```jsx live -function EarningsHistory() { - const CirclePlot = memo(({ seriesId, opacity = 1 }: { seriesId: string, opacity?: number }) => { - const { drawingArea, getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); - const series = getSeries(seriesId); - const data = getSeriesData(seriesId); - const xScale = getXScale(); - const yScale = getYScale(series?.yAxisId); - - if (!xScale || !yScale || !data || !isCategoricalScale(xScale)) return null; - - const yScaleSize = Math.abs(yScale.range()[1] - yScale.range()[0]); - - // Have circle diameter be the smaller of the x scale bandwidth or 10% of the y space available - const diameter = Math.min(xScale.bandwidth(), yScaleSize / 10); - - return ( - - {data.map((value, index) => { - if (value === null || value === undefined) return null; - - // Get x position from band scale - center of the band - const xPos = xScale(index); - if (xPos === undefined) return null; - - const centerX = xPos + xScale.bandwidth() / 2; - - // Get y position from value - const yValue = Array.isArray(value) ? value[1] : value; - const centerY = yScale(yValue); - if (centerY === undefined) return null; - - return ( - - ); - })} - - ); - }); - - const quarters = useMemo(() => ['Q1', 'Q2', 'Q3', 'Q4'], []); - const estimatedEPS = useMemo(() => [1.71, 1.82, 1.93, 2.34], []); - const actualEPS = useMemo(() => [1.68, 1.83, 2.01, 2.24], []); - - const formatEarningAmount = useCallback((value: number) => { - return `$${value.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const surprisePercentage = useCallback( - (index: number): ChartTextChildren => { - const percentage = (actualEPS[index] - estimatedEPS[index]) / estimatedEPS[index]; - const percentageString = percentage.toLocaleString('en-US', { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - - return ( - 0 ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)', - fontWeight: 'bold', - }} - > - {percentage > 0 ? '+' : ''} - {percentageString} - - ); - }, - [actualEPS, estimatedEPS], - ); - - const LegendItem = memo(({ opacity = 1, label }: { opacity?: number, label: string }) => { - return ( - - - {label} - - ); - }); - - const LegendDot = memo((props: BoxBaseProps) => { - return ; - }); - - return ( - - - - quarters[index]} /> - - - - - - - - - - ); -} -``` - -### Trading Trends - -You can have multiple axes with different domains and ranges to showcase different pieces of data over the time time period. - -```jsx live -function TradingTrends() { - const profitData = [34, 24, 28, -4, 8, -16, -3, 12, 24, 18, 20, 28]; - const gains = profitData.map((value) => (value > 0 ? value : 0)); - const losses = profitData.map((value) => (value < 0 ? value : 0)); - - const renderProfit = useCallback((value: number) => { - return `$${value}M`; - }, []); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - const ThickSolidLine = memo((props: SolidLineProps) => ); - - return ( - ({ min: min, max: max - 64 }), domain: { min: -40, max: 40 } }, - { id: 'revenue', range: ({ min, max }) => ({ min: max - 64, max }), domain: { min: 100 } }, - ]} - > - - - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json b/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json deleted file mode 100644 index 109b74d3bd..0000000000 --- a/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "import": "import { CartesianChart } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/CartesianChart.tsx", - "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", - "relatedComponents": [ - { - "label": "Point", - "url": "/components/graphs/Point/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json b/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json deleted file mode 100644 index d9af116603..0000000000 --- a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "import": "import { CartesianChart } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/CartesianChart.tsx", - "description": "A flexible, low-level chart component for displaying data in an x/y coordinate space. Provides a foundation for building custom chart visualizations with full control over rendering.", - "relatedComponents": [ - { - "label": "Point", - "url": "/components/graphs/Point/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx deleted file mode 100644 index 2a0d681939..0000000000 --- a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx +++ /dev/null @@ -1,1903 +0,0 @@ -LineChart is a wrapper for [CartesianChart](/components/graphs/CartesianChart) that makes it easy to create standard line charts, supporting a single x/y axis pair. Charts are built using `@shopify/react-native-skia`. - -## Setup - -Before using LineChart, you need to wrap your app with `ChartBridgeProvider`. This enables charts to access CDS theming and other React contexts within the Skia renderer. See [CartesianChart](/components/graphs/CartesianChart/#setup) for details. - -## Basics - -The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` array of numbers. - -```jsx - -``` - -LineChart also supports multiple lines, interaction, and axes. -Other props, such as `areaType` can be applied to the chart as a whole or per series. - -```jsx - - - -``` - -## Data - -The data array for each series defines the y values for that series. You can adjust the y values for a series of data by setting the `data` prop on the xAxis. - -```jsx -const yData = [2, 5.5, 2, 8.5, 1.5, 5]; -const xData = [1, 2, 3, 5, 8, 10]; - -return ( - - - -); -``` - -### Live Updates - -You can change the data passed in via `series` prop to update the chart. - -You can also use the `useRef` hook to reference the scrubber and pulse it on each update. - -```jsx -function LiveUpdates() { - const scrubberRef = useRef < ScrubberRef > null; - - const initialData = useMemo(() => { - return sparklineInteractiveData.hour.map((d) => d.value); - }, []); - - const [priceData, setPriceData] = useState(initialData); - - const lastDataPointTimeRef = useRef(Date.now()); - const updateCountRef = useRef(0); - - const intervalSeconds = 3600 / initialData.length; - - const maxPercentChange = Math.abs(initialData[initialData.length - 1] - initialData[0]) * 0.05; - - useEffect(() => { - const priceUpdateInterval = setInterval( - () => { - setPriceData((currentData) => { - const newData = [...currentData]; - const lastPrice = newData[newData.length - 1]; - - const priceChange = (Math.random() - 0.5) * maxPercentChange; - const newPrice = Math.round((lastPrice + priceChange) * 100) / 100; - - // Check if we should roll over to a new data point - const currentTime = Date.now(); - const timeSinceLastPoint = (currentTime - lastDataPointTimeRef.current) / 1000; - - if (timeSinceLastPoint >= intervalSeconds) { - // Time for a new data point - remove first, add new at end - lastDataPointTimeRef.current = currentTime; - newData.shift(); // Remove oldest data point - newData.push(newPrice); // Add new data point - updateCountRef.current = 0; - } else { - // Just update the last data point - newData[newData.length - 1] = newPrice; - updateCountRef.current++; - } - - return newData; - }); - - // Pulse the scrubber on each update - scrubberRef.current?.pulse(); - }, - 2000 + Math.random() * 1000, - ); - - return () => clearInterval(priceUpdateInterval); - }, [intervalSeconds, maxPercentChange]); - - return ( - - - - ); -} -``` - -### Missing Data - -By default, null values in data create gaps in a line. Use `connectNulls` to skip null values and draw a continuous line. -Note that scrubber beacons and points are still only shown at non-null data values. - -```jsx -function MissingData() { - const theme = useTheme(); - const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; - const pageViews = [2400, 1398, null, 3908, 4800, 3800, 4300]; - const uniqueVisitors = [4000, 3000, null, 2780, 1890, 2390, 3490]; - - const numberFormatter = useCallback( - (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), - [], - ); - - return ( - - {/* We can offset the overlay to account for the points being drawn on the lines */} - - - ); -} -``` - -#### Empty State - -```jsx -function EmptyState() { - const theme = useTheme(); - return ( - - ); -} -``` - -### Scales - -LineChart uses `linear` scaling on axes by default, but you can also use other types, such as `log`. See [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis) for more information. - -```jsx - -``` - -## Interaction - -Charts have built in functionality enabled through scrubbing, which can be used by setting `enableScrubbing` to true. You can listen to value changes through `onScrubberPositionChange`. Adding `Scrubber` to LineChart showcases the current scrubber position. - -```jsx -function Interaction() { - const [scrubberPosition, setScrubberPosition] = useState(); - - return ( - - - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} - - - - - - ); -} -``` - -### Points - -You can use `points` from LineChart to render instances of [Point](/components/graphs/Point) at specific data locations with custom styling. - -```jsx -function Points() { - const theme = useTheme(); - const keyMarketShiftIndices = [4, 6, 7, 9, 10]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - return ( - - - - keyMarketShiftIndices.includes(dataX) - ? { - ...props, - strokeWidth: 2, - stroke: theme.color.bg, - radius: 5, - } - : false - } - seriesId="prices" - /> - - ); -} -``` - -### Performance - -Renders are done on JS thread, other code is in UI - -```jsx - -function Performance() { - const tabs = useMemo( - () => [ - { id: 'hour', label: '1H' }, - { id: 'day', label: '1D' }, - { id: 'week', label: '1W' }, - { id: 'month', label: '1M' }, - { id: 'year', label: '1Y' }, - { id: 'all', label: 'All' }, - ], - [], - ); - const [timePeriod, setTimePeriod] = useState(tabs[0]); - const [scrubberPosition, setScrubberPosition] = useState(); - - const sparklineTimePeriodData = useMemo(() => { - return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; - }, [timePeriod]); - - const sparklineTimePeriodDataValues = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.value); - }, [sparklineTimePeriodData]); - - const onPeriodChange = useCallback( - (period: TabValue | null) => { - setTimePeriod(period || tabs[0]); - }, - [tabs], - ); - - return ( - - - - - - ); -} - -const PerformanceHeader = memo( - ({ - scrubberPosition, - sparklineTimePeriodDataValues, - }: { - scrubberPosition: number | undefined; - sparklineTimePeriodDataValues: number[]; - }) => { - const theme = useTheme(); - - const formatPriceThousands = useCallback((price: number) => { - return `${new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price / 1000)}k`; - }, []); - - const shownPosition = - scrubberPosition !== undefined ? scrubberPosition : sparklineTimePeriodDataValues.length - 1; - - return ( - - - - - - ); - }, -); - -const PerformanceChart = memo( - ({ - timePeriod, - onScrubberPositionChange, - }: { - timePeriod: TabValue; - onScrubberPositionChange: (position: number | undefined) => void; - }) => { - const theme = useTheme(); - - const sparklineTimePeriodData = useMemo(() => { - return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; - }, [timePeriod]); - - const sparklineTimePeriodDataValues = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.value); - }, [sparklineTimePeriodData]); - - const sparklineTimePeriodDataTimestamps = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.date); - }, [sparklineTimePeriodData]); - - const formatPriceThousands = useCallback((price: number) => { - return `${new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price / 1000)}k`; - }, []); - - const formatDate = useCallback((date: Date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - const getScrubberLabel = useCallback( - (d: number) => formatDate(sparklineTimePeriodDataTimestamps[d]), - [formatDate, sparklineTimePeriodDataTimestamps], - ); - - return ( - d * 1.2), - color: theme.color.fgPositive, - label: 'High Price', - }, - { - id: 'btc', - data: sparklineTimePeriodDataValues, - color: assets.btc.color, - label: 'Actual Price', - }, - { - id: 'low', - data: sparklineTimePeriodDataValues.map((d) => d * 0.8), - color: theme.color.fgNegative, - label: 'Low Price', - }, - ]} - xAxis={{ range: ({ min, max }) => ({ min, max: max - 16 }) }} - yAxis={{ showGrid: true, tickLabelFormatter: formatPriceThousands }} - > - - - ); - }, -); -``` - -### Gestures - -By default, charts will not track gestures that go outside of the chart bounds. You can allow overflow gestures by setting `allowOverflowGestures` to `true`. - -```jsx - - ... - -``` - -## Animations - -You can configure chart transitions using `transition` on LineChart and `beaconTransitions` on [Scrubber](/components/graphs/Scrubber). You can also disable animations by setting the `animate` on LineChart to `false`. - -```jsx -function Transitions() { - const theme = useTheme(); - const dataCount = 20; - const maxDataOffset = 15000; - const minStepOffset = 2500; - const maxStepOffset = 10000; - const domainLimit = 20000; - const updateInterval = 500; - - const myTransitionConfig: Transition = { type: 'spring', stiffness: 700, damping: 20 }; - const negativeColor = `rgb(${theme.spectrum.gray15})`; - const positiveColor = theme.color.fgPositive; - - function generateNextValue(previousValue: number) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataOffset) { - direction = -1; - } else if (previousValue <= -maxDataOffset) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - newValue = Math.max(-maxDataOffset, Math.min(maxDataOffset, newValue)); - return newValue; - } - - function generateInitialData() { - const data = []; - - let previousValue = Math.random() * 2 * maxDataOffset - maxDataOffset; - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - - return data; - } - - const MyGradient = memo((props: DottedAreaProps) => { - const areaGradient = { - stops: ({ min, max }: AxisBounds) => [ - { offset: min, color: negativeColor, opacity: 1 }, - { offset: 0, color: negativeColor, opacity: 0 }, - { offset: 0, color: positiveColor, opacity: 0 }, - { offset: max, color: positiveColor, opacity: 1 }, - ], - }; - - return ; - }); - - function CustomTransitionsChart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 0; - const newValue = generateNextValue(lastValue); - - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - const tickLabelFormatter = useCallback( - (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value), - [], - ); - - const valueAtIndexFormatter = useCallback( - (dataIndex: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(data[dataIndex]), - [data], - ); - - const lineGradient = { - stops: [ - { offset: 0, color: negativeColor }, - { offset: 0, color: positiveColor }, - ], - }; - - return ( - - - - - - ); - } - - return ; -} -``` - -## Accessibility - -You can use `accessibilityLabel` on the chart to provide a descriptive label. - -```jsx -function BasicAccessible() { - const [scrubberPosition, setScrubberPosition] = useState(); - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - - // Chart-level accessibility label provides overview - const chartAccessibilityLabel = useMemo(() => { - const currentPrice = data[data.length - 1]; - return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; - }, [data]); - - // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; - }, - [data], - ); - - const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); - } - return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); - - return ( - - - - ); -} -``` - -## Styling - -### Axes - -Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis). - -```jsx - `Day ${dataX}`, - }} - yAxis={{ - showGrid: true, - showLine: true, - showTickMarks: true, - }} -/> -``` - -### Fonts - -By default, charts will use the default font of the system. You can use `fontFamily` at the chart level to customize this. For more, see [Skia's documentation on fonts](https://shopify.github.io/react-native-skia/docs/text/paragraph/#fonts). - -```jsx - - ... - -``` - -You can also use `fontProvider` along with `useFonts` from Skia if you need to load a custom font. - -```jsx -const fontProvider = useFonts({ - MyCustomFontFamily: [ - require("./MyCustomFont-Regular.ttf"), - require("./MyCustomFont-Bold.ttf"), - ], -}); - -return ( - - ... - -); -``` - -### Gradients - -Gradients can be applied to the y-axis (default) or x-axis. Each stop requires an `offset`, which is based on the data within the x/y scale and `color`, with an optional `opacity` (defaults to 1). - -Values in between stops will be interpolated smoothly using [srgb color space](https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty). - -```jsx -function Gradients() { - const theme = useTheme(); - const spectrumColors: ThemeVars.SpectrumHue[] = [ - 'blue', - 'green', - 'orange', - 'yellow', - 'gray', - 'indigo', - 'pink', - 'purple', - 'red', - 'teal', - 'chartreuse', - ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); - - return ( - - - {spectrumColors.map((color) => ( - setCurrentSpectrumColor(color)} - style={{ - backgroundColor: `rgb(${theme.spectrum[`${color}20`]})`, - borderColor: `rgb(${theme.spectrum[`${color}50`]})`, - borderWidth: 2, - }} - width={16} - /> - ))} - - d + 50), - // You can create a "discrete" gradient by having multiple stops at the same offset - gradient: { - stops: ({ min, max }) => [ - // Allows a function which accepts min/max or direct array - { offset: min, color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})` }, - { - offset: min + (max - min) / 3, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})`, - }, - { - offset: min + (max - min) / 3, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}50`]})`, - }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}50`]})`, - }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})`, - }, - { offset: max, color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})` }, - ], - }, - }, - { - id: 'xAxisGradient', - data: data.map((d) => d + 100), - gradient: { - // You can also configure by the x-axis. - axis: 'x', - stops: ({ min, max }) => [ - { - offset: min, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})`, - opacity: 0, - }, - { - offset: max, - color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})`, - opacity: 1, - }, - ], - }, - }, - ]} - strokeWidth={4} - yAxis={{ - showGrid: true, - }} - /> - - ); -} -``` - -You can even pass in a separate gradient for your `Line` and `Area` components. - -```jsx -function GainLossChart() { - const theme = useTheme(); - const data = useMemo(() => [-40, -28, -21, -5, 48, -5, -28, 2, -29, -46, 16, -30, -29, 8], []); - const negativeColor = `rgb(${theme.spectrum.gray15})`; - const positiveColor = theme.color.fgPositive; - - const tickLabelFormatter = useCallback( - (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value), - [], - ); - - // Line gradient: hard color change at 0 (full opacity for line) - const lineGradient = { - stops: [ - { offset: 0, color: negativeColor }, - { offset: 0, color: positiveColor }, - ], - }; - - const GradientDottedArea = memo((props: DottedAreaProps) => ( - [ - { offset: min, color: negativeColor, opacity: 0.4 }, - { offset: 0, color: negativeColor, opacity: 0 }, - { offset: 0, color: positiveColor, opacity: 0 }, - { offset: max, color: positiveColor, opacity: 0.4 }, - ], - }} - /> - )); - - return ( - ({ min, max: max - 16 }), - }} - > - - - - - ); -} -``` - -### Lines - -You can customize lines by placing props in `LineChart` or at each individual series. Lines can have a `type` of `solid` or `dotted`. They can optionally show an area underneath them (using `showArea`). - -```jsx - -``` - -You can also add instances of [ReferenceLine](/components/graphs/ReferenceLine) to your LineChart to highlight a specific x or y value. - -```jsx - ({ min, max: max - 24 }), - }} -> - } - dataY={10} - stroke={theme.color.fg} - /> - - -``` - -### Points - -You can also add instances of [Point](/components/graphs/Point) directly inside of a LineChart. - -```jsx -function HighLowPrice() { - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - const minPrice = Math.min(...data); - const maxPrice = Math.max(...data); - - const minPriceIndex = data.indexOf(minPrice); - const maxPriceIndex = data.indexOf(maxPrice); - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - return ( - - - - - ); -} -``` - -### Scrubber - -When using [Scrubber](/components/graphs/Scrubber) with series that have labels, labels will automatically render to the side of the scrubber beacon. - -You can customize the line used for and which series will render a scrubber beacon. - -You can have scrubber beacon's pulse by either adding `idlePulse` to Scrubber or use Scrubber's ref to dynamically pulse. - -```jsx -function StylingScrubber() { - const theme = useTheme(); - const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; - const pageViews = [2400, 1398, 9800, 3908, 4800, 3800, 4300]; - const uniqueVisitors = [4000, 3000, 2000, 2780, 1890, 2390, 3490]; - - const numberFormatter = useCallback( - (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), - [], - ); - - return ( - - - - ); -} -``` - -### Sizing - -Charts by default take up `100%` of the `width` and `height` available, but can be customized as any other component. - -#### Compact - -You can also have charts in a compact form. - -```jsx -function Compact() { - const theme = useTheme(); - const dimensions = { width: 62, height: 18 }; - - const sparklineData = prices - .map((price) => parseFloat(price)) - .filter((price, index) => index % 10 === 0); - const positiveFloor = Math.min(...sparklineData) - 10; - - const negativeData = sparklineData.map((price) => -1 * price).reverse(); - const negativeCeiling = Math.max(...negativeData) + 10; - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - type CompactChartProps = { - data: number[]; - showArea?: boolean; - color?: string; - referenceY: number; - }; - - const CompactChart = memo(({ data, showArea, color, referenceY }: CompactChartProps) => ( - - - - - - )); - - const ChartCell = memo( - ({ - data, - showArea, - color, - referenceY, - subdetail, - }: CompactChartProps & { subdetail: string }) => { - return ( - - } - media={} - onPress={() => console.log('clicked')} - spacingVariant="condensed" - style={{ padding: 0 }} - subdetail={subdetail} - /> - ); - }, - ); - - return ( - - - - - - - ); -} -``` - -## Composed Examples - -### Asset Price with Dotted Area - -You can use [PeriodSelector](/components/graphs/PeriodSelector) to have a chart where the user can select a time period and the chart automatically animates. - -```jsx -function AssetPriceWithDottedArea() { - const fontMgr = useMemo(() => { - const fontProvider = Skia.TypefaceFontProvider.Make(); - // Register system fonts if available, otherwise Skia will use defaults - return fontProvider; - }, []); - - const BTCTab: TabComponent = memo( - forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { - const { activeTab } = useTabsContext(); - const isActive = activeTab?.id === props.id; - - return ( - - {label} - - } - {...props} - /> - ); - }), - ); - const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( - - )); - - const AssetPriceDotted = memo(() => { - const theme = useTheme(); - const currentPrice = - sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value; - const tabs = useMemo( - () => [ - { id: 'hour', label: '1H' }, - { id: 'day', label: '1D' }, - { id: 'week', label: '1W' }, - { id: 'month', label: '1M' }, - { id: 'year', label: '1Y' }, - { id: 'all', label: 'All' }, - ], - [], - ); - const [timePeriod, setTimePeriod] = useState(tabs[0]); - - const sparklineTimePeriodData = useMemo(() => { - return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; - }, [timePeriod]); - - const sparklineTimePeriodDataValues = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.value); - }, [sparklineTimePeriodData]); - - const sparklineTimePeriodDataTimestamps = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.date); - }, [sparklineTimePeriodData]); - - const onPeriodChange = useCallback( - (period: TabValue | null) => { - setTimePeriod(period || tabs[0]); - }, - [tabs, setTimePeriod], - ); - - const priceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }), - [], - ); - - const formatPrice = useCallback( - (price: number) => { - return priceFormatter.format(price); - }, - [priceFormatter], - ); - - const formatDate = useCallback((date: Date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - return ( - - {formatPrice(currentPrice)}} - end={ - - - - } - title={Bitcoin} - /> - - { - const date = formatDate(sparklineTimePeriodDataTimestamps[d]); - const price = formatPrice(sparklineTimePeriodDataValues[d]); - - const regularStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 14, - fontStyle: { - weight: FontWeight.Normal, - }, - color: Skia.Color(theme.color.fgMuted), - }; - - const boldStyle: SkTextStyle = { - fontFamilies: ['Inter'], - ...regularStyle, - fontStyle: { - weight: FontWeight.Bold, - }, - }; - - // 3. Use the ParagraphBuilder - const builder = Skia.ParagraphBuilder.Make( - { - textAlign: TextAlign.Left, - }, - fontMgr, - ); - - builder.pushStyle(boldStyle); - builder.addText(price); - - builder.pushStyle(regularStyle); - builder.addText(` ${date}`); - - const para = builder.build(); - para.layout(512); - return para; - }} - labelElevated - /> - - - - ); - }); - - return ; -} -``` - -### Monotone Asset Price - -You can adjust [YAxis](/components/graphs/YAxis) and [Scrubber](/components/graphs/Scrubber) to have a chart where the y-axis is overlaid and the beacon is inverted in style. - -```jsx -function MonotoneAssetPrice() { - const theme = useTheme(); - const prices = sparklineInteractiveData.hour; - - const fontMgr = useMemo(() => { - const fontProvider = Skia.TypefaceFontProvider.Make(); - // Register system fonts if available, otherwise Skia will use defaults - return fontProvider; - }, []); - - const priceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }), - [], - ); - - const scrubberPriceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), - [], - ); - - const formatPrice = useCallback( - (price: number) => { - return priceFormatter.format(price); - }, - [priceFormatter], - ); - - const formatDate = useCallback((date: Date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - const scrubberLabel = useCallback( - (index: number) => { - const price = scrubberPriceFormatter.format(prices[index].value); - const date = formatDate(prices[index].date); - - const regularStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 14, - fontStyle: { - weight: FontWeight.Normal, - }, - color: Skia.Color(theme.color.fgMuted), - }; - - const boldStyle: SkTextStyle = { - fontFamilies: ['Inter'], - ...regularStyle, - fontStyle: { - weight: FontWeight.Bold, - }, - }; - - const builder = Skia.ParagraphBuilder.Make( - { - textAlign: TextAlign.Left, - }, - fontMgr, - ); - - builder.pushStyle(boldStyle); - builder.addText(`${price} USD`); - - builder.pushStyle(regularStyle); - builder.addText(` ${date}`); - - const para = builder.build(); - para.layout(512); - return para; - }, - [scrubberPriceFormatter, prices, formatDate, theme.color.fgMuted, fontMgr], - ); - - const formatAxisLabelPrice = useCallback( - (price: number) => { - return formatPrice(price); - }, - [formatPrice], - ); - - // Custom tick label component with offset positioning - const CustomYAxisTickLabel = useCallback( - (props: any) => , - [], - ); - - const CustomScrubberBeacon = memo( - forwardRef(({ dataX, dataY, seriesId, isIdle, animate = true }: ScrubberBeaconProps, ref) => { - const { getSeries, getXSerializableScale, getYSerializableScale } = - useCartesianChartContext(); - - const targetSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); - const yScale = useMemo( - () => getYSerializableScale(targetSeries?.yAxisId), - [getYSerializableScale, targetSeries?.yAxisId], - ); - - const animatedX = useSharedValue(0); - const animatedY = useSharedValue(0); - - // Provide a no-op pulse implementation for simple beacons - useImperativeHandle(ref, () => ({ pulse: () => {} }), []); - - // Calculate the target point position - project data to pixels - const targetPoint = useDerivedValue(() => { - if (!xScale || !yScale) return { x: 0, y: 0 }; - return projectPointWithSerializableScale({ - x: unwrapAnimatedValue(dataX), - y: unwrapAnimatedValue(dataY), - xScale, - yScale, - }); - }, [dataX, dataY, xScale, yScale]); - - useAnimatedReaction( - () => { - return { point: targetPoint.value, isIdle: unwrapAnimatedValue(isIdle) }; - }, - (current, previous) => { - // When animation is disabled, on initial render, or when we are starting, - // continuing, or finishing scrubbing we should immediately transition - if (!animate || previous === null || !previous.isIdle || !current.isIdle) { - animatedX.value = current.point.x; - animatedY.value = current.point.y; - return; - } - - animatedX.value = buildTransition(current.point.x, defaultTransition); - animatedY.value = buildTransition(current.point.y, defaultTransition); - }, - [animate], - ); - - // Create animated point using the animated values - const animatedPoint = useDerivedValue(() => { - return { x: animatedX.value, y: animatedY.value }; - }, [animatedX, animatedY]); - - return ( - <> - - - - ); - }), - ); - - return ( - price.value), - color: theme.color.fg, - gradient: { - axis: 'x', - stops: ({ min }) => [ - { offset: min, color: theme.color.fg, opacity: 0 }, - { offset: 32, color: theme.color.fg, opacity: 1 }, - ], - }, - }, - ]} - xAxis={{ - range: ({ max }) => ({ min: 96, max }), - }} - yAxis={{ - position: 'left', - width: 0, - showGrid: true, - tickLabelFormatter: formatAxisLabelPrice, - TickLabelComponent: CustomYAxisTickLabel, - }} - > - - - ); -} -``` - -### Service Availability - -You can have irregular data points by passing in `data` to `xAxis`. - -```jsx -function ServiceAvailability() { - const theme = useTheme(); - const availabilityEvents = useMemo( - () => [ - { date: new Date('2022-01-01'), availability: 79 }, - { date: new Date('2022-01-03'), availability: 81 }, - { date: new Date('2022-01-04'), availability: 82 }, - { date: new Date('2022-01-06'), availability: 91 }, - { date: new Date('2022-01-07'), availability: 92 }, - { date: new Date('2022-01-10'), availability: 86 }, - ], - [], - ); - - return ( - event.availability), - gradient: { - stops: ({ min, max }) => [ - { offset: min, color: theme.color.fgNegative }, - { offset: 85, color: theme.color.fgNegative }, - { offset: 85, color: theme.color.fgWarning }, - { offset: 90, color: theme.color.fgWarning }, - { offset: 90, color: theme.color.fgPositive }, - { offset: max, color: theme.color.fgPositive }, - ], - }, - }, - ]} - xAxis={{ - data: availabilityEvents.map((event) => event.date.getTime()), - }} - yAxis={{ - domain: ({ min, max }) => ({ min: Math.max(min - 2, 0), max: Math.min(max + 2, 100) }), - }} - > - new Date(value).toLocaleDateString()} - /> - `${value}%`} - /> - ({ - ...props, - fill: theme.color.bg, - stroke: props.fill, - })} - seriesId="availability" - /> - - - ); -} -``` - -### Forecast Asset Price - -You can combine multiple lines within a series to change styles dynamically. - -```jsx -function ForecastAssetPrice() { - const startYear = 2020; - const data = [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70]; - const currentIndex = 6; - - const strokeWidth = 3; - // To prevent cutting off the edge of our lines - const clipOffset = strokeWidth; - - const axisFormatter = useCallback( - (dataIndex: number) => { - return `${startYear + dataIndex}`; - }, - [startYear], - ); - - const HistoricalLineComponent = memo((props: SolidLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - const historicalClipPath = useMemo(() => { - if (!xScale || !drawingArea) return null; - - const currentX = xScale(currentIndex); - if (currentX === undefined) return null; - - // Create clip path for historical data (left side) - const clip = Skia.Path.Make(); - clip.addRect({ - x: drawingArea.x - clipOffset, - y: drawingArea.y - clipOffset, - width: currentX + clipOffset - drawingArea.x, - height: drawingArea.height + clipOffset * 2, - }); - return clip; - }, [xScale, drawingArea]); - - if (!historicalClipPath) return null; - - return ( - - - - ); - }); - - // Since the solid and dotted line have different curves, - // we need two separate line components. Otherwise we could - // have one line component with SolidLine and DottedLine inside - // of it and two clipPaths. - const ForecastLineComponent = memo((props: DottedLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - const forecastClipPath = useMemo(() => { - if (!xScale || !drawingArea) return null; - - const currentX = xScale(currentIndex); - if (currentX === undefined) return null; - - // Create clip path for forecast data (right side) - const clip = Skia.Path.Make(); - clip.addRect({ - x: currentX, - y: drawingArea.y - clipOffset, - width: drawingArea.x + drawingArea.width - currentX + clipOffset * 2, - height: drawingArea.height + clipOffset * 2, - }); - return clip; - }, [xScale, drawingArea]); - - if (!forecastClipPath) return null; - - return ( - - - - ); - }); - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - - const idleScrubberOpacity = useDerivedValue( - () => (scrubberPosition.value === undefined ? 1 : 0), - [scrubberPosition], - ); - const scrubberOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); - - // Fade in animation for the Scrubber - const fadeInOpacity = useSharedValue(0); - - useEffect(() => { - fadeInOpacity.value = withDelay(350, withTiming(1, { duration: 150 })); - }, [fadeInOpacity]); - - return ( - - - - - - - - - ); - }); - - return ( - - - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx deleted file mode 100644 index 188d305aea..0000000000 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ /dev/null @@ -1,1920 +0,0 @@ -LineChart is a wrapper for [CartesianChart](/components/graphs/CartesianChart) that makes it easy to create standard line charts, supporting a single x/y axis pair. Charts are built using SVGs. - -## Basics - -The only prop required is `series`, which takes an array of series objects. Each series object needs an `id` and a `data` array of numbers. - -```jsx live - -``` - -LineChart also supports multiple lines, interaction, and axes. -Other props, such as `areaType` can be applied to the chart as a whole or per series. - -```jsx live -function MultipleLine() { - const pages = useMemo( - () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], - [], - ); - const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); - const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); - - const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`; - }, - [pages, pageViews, uniqueVisitors], - ); - - const numberFormatter = useCallback( - (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), - [], - ); - - return ( - - - - ); -} -``` - -## Data - -The data array for each series defines the y values for that series. You can adjust the y values for a series of data by setting the `data` prop on the xAxis. - -```jsx live -function DataFormat() { - const yData = useMemo(() => [2, 5.5, 2, 8.5, 1.5, 5], []); - const xData = useMemo(() => [1, 2, 3, 5, 8, 10], []); - - const chartAccessibilityLabel = `Chart with custom X and Y data. ${yData.length} data points`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`; - }, - [xData, yData], - ); - - return ( - - - - ); -} -``` - -### Live Updates - -You can change the data passed in via `series` prop to update the chart. - -You can also use the `useRef` hook to reference the scrubber and pulse it on each update. - -```jsx live -function LiveUpdates() { - const scrubberRef = useRef(null); - - const initialData = useMemo(() => { - return sparklineInteractiveData.hour.map((d) => d.value); - }, []); - - const [priceData, setPriceData] = useState(initialData); - - const lastDataPointTimeRef = useRef(Date.now()); - const updateCountRef = useRef(0); - - const intervalSeconds = 3600 / initialData.length; - - const maxPercentChange = Math.abs(initialData[initialData.length - 1] - initialData[0]) * 0.05; - - useEffect(() => { - const priceUpdateInterval = setInterval( - () => { - setPriceData((currentData) => { - const newData = [...currentData]; - const lastPrice = newData[newData.length - 1]; - - const priceChange = (Math.random() - 0.5) * maxPercentChange; - const newPrice = Math.round((lastPrice + priceChange) * 100) / 100; - - // Check if we should roll over to a new data point - const currentTime = Date.now(); - const timeSinceLastPoint = (currentTime - lastDataPointTimeRef.current) / 1000; - - if (timeSinceLastPoint >= intervalSeconds) { - // Time for a new data point - remove first, add new at end - lastDataPointTimeRef.current = currentTime; - newData.shift(); // Remove oldest data point - newData.push(newPrice); // Add new data point - updateCountRef.current = 0; - } else { - // Just update the last data point - newData[newData.length - 1] = newPrice; - updateCountRef.current++; - } - - return newData; - }); - - // Pulse the scrubber on each update - scrubberRef.current?.pulse(); - }, - 2000 + Math.random() * 1000, - ); - - return () => clearInterval(priceUpdateInterval); - }, [intervalSeconds, maxPercentChange]); - - const chartAccessibilityLabel = useMemo(() => { - return `Live Bitcoin price chart. Current price: $${priceData[priceData.length - 1].toFixed(2)}`; - }, [priceData]); - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - const price = priceData[index]; - return `Bitcoin price at position ${index + 1}: $${price.toFixed(2)}`; - }, - [priceData], - ); - - return ( - - - - ); -} -``` - -### Missing Data - -By default, null values in data create gaps in a line. Use `connectNulls` to skip null values and draw a continuous line. -Note that scrubber beacons and points are still only shown at non-null data values. - -```jsx live -function MissingData() { - const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; - const pageViews = [2400, 1398, null, 3908, 4800, 3800, 4300]; - const uniqueVisitors = [4000, 3000, null, 2780, 1890, 2390, 3490]; - - const numberFormatter = useCallback( - (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), - [], - ); - - return ( - - {/* We can offset the overlay to account for the points being drawn on the lines */} - - - ); -} -``` - -#### Empty State - -```jsx live - -``` - -### Scales - -LineChart uses `linear` scaling on axes by default, but you can also use other types, such as `log`. See [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis) for more information. - -```jsx live - -``` - -## Interaction - -Charts have built in functionality enabled through scrubbing, which can be used by setting `enableScrubbing` to true. You can listen to value changes through `onScrubberPositionChange`. Adding `Scrubber` to LineChart showcases the current scrubber position. - -```jsx live -function Interaction() { - const [scrubberPosition, setScrubberPosition] = useState(); - - return ( - - - {scrubberPosition !== undefined - ? `Scrubber position: ${scrubberPosition}` - : 'Not scrubbing'} - - - - - - ); -} -``` - -### Points - -You can use `points` from LineChart with `onClick` listeners to render instances of [Point](/components/graphs/Point) that are interactable. - -```jsx live -function Points() { - const keyMarketShiftIndices = [4, 6, 7, 9, 10]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - return ( - - - - keyMarketShiftIndices.includes(dataX) - ? { - ...props, - strokeWidth: 2, - stroke: 'var(--color-bg)', - radius: 5, - onClick: () => - alert( - `You have clicked a key market shift at position ${dataX + 1} with value ${dataY}!`, - ), - accessibilityLabel: `Key market shift point at position ${dataX + 1}, value ${dataY}. Click to view details.`, - } - : false - } - seriesId="prices" - /> - - ); -} -``` - -## Animations - -You can configure chart transitions using `transition` on LineChart and `beaconTransitions` on [Scrubber](/components/graphs/Scrubber). You can also disable animations by setting the `animate` on LineChart to `false`. - -```jsx live -function Transitions() { - const dataCount = 20; - const maxDataOffset = 15000; - const minStepOffset = 2500; - const maxStepOffset = 10000; - const domainLimit = 20000; - const updateInterval = 500; - - const myTransitionConfig = { type: 'spring', stiffness: 700, damping: 20 }; - const negativeColor = 'rgb(var(--gray15))'; - const positiveColor = 'var(--color-fgPositive)'; - - function generateNextValue(previousValue: number) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataOffset) { - direction = -1; - } else if (previousValue <= -maxDataOffset) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - newValue = Math.max(-maxDataOffset, Math.min(maxDataOffset, newValue)); - return newValue; - } - - function generateInitialData() { - const data = []; - - let previousValue = Math.random() * 2 * maxDataOffset - maxDataOffset; - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - - return data; - } - - const MyGradient = memo((props: DottedAreaProps) => { - const areaGradient = { - stops: ({ min, max }: AxisBounds) => [ - { offset: min, color: negativeColor, opacity: 1 }, - { offset: 0, color: negativeColor, opacity: 0 }, - { offset: 0, color: positiveColor, opacity: 0 }, - { offset: max, color: positiveColor, opacity: 1 }, - ], - }; - - return ; - }); - - function CustomTransitionsChart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 0; - const newValue = generateNextValue(lastValue); - - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - const tickLabelFormatter = useCallback( - (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value), - [], - ); - - const valueAtIndexFormatter = useCallback( - (dataIndex: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(data[dataIndex]), - [data], - ); - - const lineGradient = { - stops: [ - { offset: 0, color: negativeColor }, - { offset: 0, color: positiveColor }, - ], - }; - - return ( - - - - - - ); - } - - return ; -} -``` - -## Accessibility - -You can use `accessibilityLabel` on both the chart and the scrubber to provide descriptive labels. The chart's label gives an overview, while the scrubber's label provides specific information about the current data point being viewed. - -```jsx live -function BasicAccessible() { - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - - // Chart-level accessibility label provides overview - const chartAccessibilityLabel = useMemo(() => { - const currentPrice = data[data.length - 1]; - return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; - }, [data]); - - // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; - }, - [data], - ); - - return ( - - - - ); -} -``` - -When a chart has a visible header or title, you can use `aria-labelledby` to reference it, and still provide a dynamic scrubber accessibility label. - -```jsx live -function AccessibleWithHeader() { - const headerId = useId(); - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - - // Display label provides overview - const displayLabel = useMemo( - () => `Revenue chart showing trend. Current value: ${data[data.length - 1]}`, - [data], - ); - - // Scrubber-specific accessibility label - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Viewing position ${index + 1} of ${data.length}, value: ${data[index]}`; - }, - [data], - ); - - return ( - - - {displayLabel} - - - - - - ); -} -``` - -## Styling - -### Axes - -Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis). - -```jsx live - `Day ${dataX}`, - }} - yAxis={{ - showGrid: true, - showLine: true, - showTickMarks: true, - }} -/> -``` - -### Gradients - -Gradients can be applied to the y-axis (default) or x-axis. Each stop requires an `offset`, which is based on the data within the x/y scale and `color`, with an optional `opacity` (defaults to 1). - -Values in between stops will be interpolated smoothly using [srgb color space](https://www.w3.org/TR/SVG11/painting.html#ColorInterpolationProperty). - -```jsx live -function Gradients() { - const spectrumColors = [ - 'blue', - 'green', - 'orange', - 'yellow', - 'gray', - 'indigo', - 'pink', - 'purple', - 'red', - 'teal', - 'chartreuse', - ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); - - return ( - - - {spectrumColors.map((color) => ( - setCurrentSpectrumColor(color)} - style={{ - backgroundColor: `rgb(var(--${color}20))`, - border: `2px solid rgb(var(--${color}50))`, - outlineColor: `rgb(var(--${color}80))`, - outline: - currentSpectrumColor === color ? `2px solid rgb(var(--${color}80))` : undefined, - }} - width={{ base: 16, tablet: 24, desktop: 24 }} - /> - ))} - - d + 50), - // You can create a "discrete" gradient by having multiple stops at the same offset - gradient: { - stops: ({ min, max }) => [ - // Allows a function which accepts min/max or direct array - { offset: min, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: min + (max - min) / 3, color: `rgb(var(--${currentSpectrumColor}50))` }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(var(--${currentSpectrumColor}50))`, - }, - { - offset: min + ((max - min) / 3) * 2, - color: `rgb(var(--${currentSpectrumColor}20))`, - }, - { offset: max, color: `rgb(var(--${currentSpectrumColor}20))` }, - ], - }, - }, - { - id: 'xAxisGradient', - data: data.map((d) => d + 100), - gradient: { - // You can also configure by the x-axis. - axis: 'x', - stops: ({ min, max }) => [ - { offset: min, color: `rgb(var(--${currentSpectrumColor}80))`, opacity: 0 }, - { offset: max, color: `rgb(var(--${currentSpectrumColor}20))`, opacity: 1 }, - ], - }, - }, - ]} - strokeWidth={4} - yAxis={{ - showGrid: true, - }} - /> - - ); -} -``` - -You can even pass in a separate gradient for your `Line` and `Area` components. - -```jsx live -function GainLossChart() { - const data = useMemo(() => [-40, -28, -21, -5, 48, -5, -28, 2, -29, -46, 16, -30, -29, 8], []); - const negativeColor = 'rgb(var(--gray15))'; - const positiveColor = 'var(--color-fgPositive)'; - - const tickLabelFormatter = useCallback( - (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value), - [], - ); - - // Line gradient: hard color change at 0 (full opacity for line) - const lineGradient = { - stops: [ - { offset: 0, color: negativeColor }, - { offset: 0, color: positiveColor }, - ], - }; - - const chartAccessibilityLabel = `Gain/Loss chart showing price changes. Current value: ${tickLabelFormatter(data[data.length - 1])}`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - const value = data[index]; - const status = value >= 0 ? 'gain' : 'loss'; - return `Position ${index + 1} of ${data.length}: ${tickLabelFormatter(value)} ${status}`; - }, - [data, tickLabelFormatter], - ); - - const GradientDottedArea = memo((props: DottedAreaProps) => ( - [ - { offset: min, color: negativeColor, opacity: 0.4 }, - { offset: 0, color: negativeColor, opacity: 0 }, - { offset: 0, color: positiveColor, opacity: 0 }, - { offset: max, color: positiveColor, opacity: 0.4 }, - ], - }} - /> - )); - - return ( - ({ min, max: max - 16 }), - }} - > - - - - - ); -} -``` - -### Lines - -You can customize lines by placing props in `LineChart` or at each individual series. Lines can have a `type` of `solid` or `dotted`. They can optionally show an area underneath them (using `showArea`). - -```jsx live - -``` - -You can also add instances of [ReferenceLine](/components/graphs/ReferenceLine) to your LineChart to highlight a specific x or y value. - -```jsx live - ({ min, max: max - 24 }), - }} -> - } - dataY={10} - stroke="var(--color-fg)" - /> - - -``` - -### Points - -You can also add instances of [Point](/components/graphs/Point) directly inside of a LineChart. - -```jsx live -function HighLowPrice() { - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - const minPrice = Math.min(...data); - const maxPrice = Math.max(...data); - - const minPriceIndex = data.indexOf(minPrice); - const maxPriceIndex = data.indexOf(maxPrice); - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - return ( - - - - - ); -} -``` - -### Scrubber - -When using [Scrubber](/components/graphs/Scrubber) with series that have labels, labels will automatically render to the side of the scrubber beacon. - -You can customize the line used for and which series will render a scrubber beacon. - -You can have scrubber beacon's pulse by either adding `idlePulse` to Scrubber or use Scrubber's ref to dynamically pulse. - -```jsx live -function StylingScrubber() { - const pages = ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G']; - const pageViews = [2400, 1398, 9800, 3908, 4800, 3800, 4300]; - const uniqueVisitors = [4000, 3000, 2000, 2780, 1890, 2390, 3490]; - - const numberFormatter = useCallback( - (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), - [], - ); - - return ( - - - - ); -} -``` - -### Sizing - -Charts by default take up `100%` of the `width` and `height` available, but can be customized as any other component. - -```jsx live -function DynamicChartSizing() { - const candles = [...btcCandles].reverse(); - const prices = candles.map((candle) => parseFloat(candle.close)); - const highs = candles.map((candle) => parseFloat(candle.high)); - const lows = candles.map((candle) => parseFloat(candle.low)); - - const latestPrice = prices[prices.length - 1]; - const previousPrice = prices[prices.length - 2]; - const change24h = ((latestPrice - previousPrice) / previousPrice) * 100; - - function DetailCell({ title, description }: { title: string; description: string }) { - return ( - - - {title} - - {description} - - ); - } - - // Calculate 7-day moving average - const calculateMA = (data: number[], period: number): number[] => { - const ma: number[] = []; - for (let i = 0; i < data.length; i++) { - if (i >= period - 1) { - const sum = data.slice(i - period + 1, i + 1).reduce((a, b) => a + b, 0); - ma.push(sum / period); - } - } - return ma; - }; - - const ma7 = calculateMA(prices, 7); - const latestMA7: number = ma7[ma7.length - 1]; - - const periodHigh = Math.max(...highs); - const periodLow = Math.min(...lows); - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const formatPercentage = useCallback((value: number) => { - const sign = value >= 0 ? '+' : ''; - return `${sign}${value.toFixed(2)}%`; - }, []); - - return ( - - - {/* LineChart fills to take up available width and height */} - - - - - BTC - {formatPrice(latestPrice)} - - - - - - - - - - ); -} -``` - -#### Compact - -You can also have charts in a compact form. - -```jsx live -function Compact() { - const dimensions = { width: 62, height: 18 }; - - const sparklineData = prices - .map((price) => parseFloat(price)) - .filter((price, index) => index % 10 === 0); - const positiveFloor = Math.min(...sparklineData) - 10; - - const negativeData = sparklineData.map((price) => -1 * price).reverse(); - const negativeCeiling = Math.max(...negativeData) + 10; - - const formatPrice = useCallback((price: number) => { - return `$${price.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - type CompactChartProps = { - data: number[]; - showArea?: boolean; - color?: string; - referenceY: number; - }; - - const CompactChart = memo(({ data, showArea, color, referenceY }: CompactChartProps) => ( - - - - - - )); - - const ChartCell = memo( - ({ - data, - showArea, - color, - referenceY, - subdetail, - }: CompactChartProps & { subdetail: string }) => { - const { isPhone } = useBreakpoints(); - - return ( - - } - media={} - onClick={() => console.log('clicked')} - spacingVariant="condensed" - style={{ padding: 0 }} - subdetail={subdetail} - title={isPhone ? undefined : assets.btc.name} - /> - ); - }, - ); - - return ( - - - - - - - ); -} -``` - -## Composed Examples - -### Asset Price with Dotted Area - -You can use [PeriodSelector](/components/graphs/PeriodSelector) to have a chart where the user can select a time period and the chart automatically animates. - -```jsx live -function AssetPriceWithDottedArea() { - const BTCTab: TabComponent = memo( - forwardRef( - ({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { - const { activeTab } = useTabsContext(); - const isActive = activeTab?.id === props.id; - - return ( - - {label} - - } - {...props} - /> - ); - }, - ), - ); - - const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( - - )); - - const AssetPriceDotted = memo(() => { - const currentPrice = - sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value; - const tabs = useMemo( - () => [ - { id: 'hour', label: '1H' }, - { id: 'day', label: '1D' }, - { id: 'week', label: '1W' }, - { id: 'month', label: '1M' }, - { id: 'year', label: '1Y' }, - { id: 'all', label: 'All' }, - ], - [], - ); - const [timePeriod, setTimePeriod] = useState(tabs[0]); - - const sparklineTimePeriodData = useMemo(() => { - return sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData]; - }, [timePeriod]); - - const sparklineTimePeriodDataValues = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.value); - }, [sparklineTimePeriodData]); - - const sparklineTimePeriodDataTimestamps = useMemo(() => { - return sparklineTimePeriodData.map((d) => d.date); - }, [sparklineTimePeriodData]); - - const onPeriodChange = useCallback( - (period: TabValue | null) => { - setTimePeriod(period || tabs[0]); - }, - [tabs, setTimePeriod], - ); - - const priceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }), - [], - ); - - const scrubberPriceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), - [], - ); - - const formatPrice = useCallback( - (price: number) => { - return priceFormatter.format(price); - }, - [priceFormatter], - ); - - const formatDate = useCallback((date: Date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - const scrubberLabel = useCallback( - (index: number) => { - const price = scrubberPriceFormatter.format(sparklineTimePeriodDataValues[index]); - const date = formatDate(sparklineTimePeriodDataTimestamps[index]); - return ( - <> - {price} USD {date} - - ); - }, - [ - scrubberPriceFormatter, - sparklineTimePeriodDataValues, - sparklineTimePeriodDataTimestamps, - formatDate, - ], - ); - - const chartAccessibilityLabel = `Bitcoin price chart for ${timePeriod.label} period. Current price: ${formatPrice(currentPrice)}`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - const price = scrubberPriceFormatter.format(sparklineTimePeriodDataValues[index]); - const date = formatDate(sparklineTimePeriodDataTimestamps[index]); - return `${price} USD ${date}`; - }, - [ - scrubberPriceFormatter, - sparklineTimePeriodDataValues, - sparklineTimePeriodDataTimestamps, - formatDate, - ], - ); - - return ( - - {formatPrice(currentPrice)}} - end={ - - - - } - style={{ padding: 0 }} - title={Bitcoin} - /> - - - - - - ); - }); - - return ; -} -``` - -### Monotone Asset Price - -You can adjust [YAxis](/components/graphs/YAxis) and [Scrubber](/components/graphs/Scrubber) to have a chart where the y-axis is overlaid and the beacon is inverted in style. - -```jsx live -function MonotoneAssetPrice() { - const prices = sparklineInteractiveData.hour; - - const priceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }), - [], - ); - - const scrubberPriceFormatter = useMemo( - () => - new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }), - [], - ); - - const formatPrice = useCallback( - (price: number) => { - return priceFormatter.format(price); - }, - [priceFormatter], - ); - - const CustomYAxisTickLabel = useCallback( - (props) => ( - - ), - [], - ); - - const formatDate = useCallback((date: Date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - const scrubberLabel = useCallback( - (index: number) => { - const price = scrubberPriceFormatter.format(prices[index].value); - const date = formatDate(prices[index].date); - return ( - <> - {price} USD {date} - - ); - }, - [scrubberPriceFormatter, prices, formatDate], - ); - - const CustomScrubberBeacon = memo( - forwardRef(({ dataX, dataY, seriesId, isIdle }: ScrubberBeaconProps, ref) => { - const { getSeries, getXScale, getYScale } = useCartesianChartContext(); - const targetSeries = getSeries(seriesId); - const xScale = getXScale(); - const yScale = getYScale(targetSeries?.yAxisId); - - const pixelCoordinate = useMemo(() => { - if (!xScale || !yScale) return; - return projectPoint({ x: dataX, y: dataY, xScale, yScale }); - }, [dataX, dataY, xScale, yScale]); - - // Provide a no-op pulse implementation for simple beacons - useImperativeHandle(ref, () => ({ pulse: () => {} }), []); - - if (!pixelCoordinate) return; - - if (isIdle) { - return ( - - ); - } - - return ( - - ); - }), - ); - - return ( - price.value), - color: 'var(--color-fg)', - gradient: { - axis: 'x', - stops: ({ min, max }) => [ - { offset: min, color: 'var(--color-fg)', opacity: 0 }, - { offset: 32, color: 'var(--color-fg)', opacity: 1 }, - ], - }, - }, - ]} - style={{ outlineColor: 'var(--color-fg)' }} - xAxis={{ - range: ({ min, max }) => ({ min: 96, max: max }), - }} - yAxis={{ - position: 'left', - width: 0, - showGrid: true, - tickLabelFormatter: formatPrice, - TickLabelComponent: CustomYAxisTickLabel, - }} - > - - - ); -} -``` - -### Asset Price Widget - -```jsx live -function AssetPriceWidget() { - const { isPhone } = useBreakpoints(); - const prices = [...btcCandles].reverse().map((candle) => parseFloat(candle.close)); - const latestPrice = prices[prices.length - 1]; - - const formatPrice = (price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(price); - }; - - const formatPercentChange = (price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'percent', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(price); - }; - - const percentChange = (latestPrice - prices[0]) / prices[0]; - - const chartAccessibilityLabel = `Bitcoin price chart. Current price: ${formatPrice(latestPrice)}. Change: ${formatPercentChange(percentChange)}`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Bitcoin price at position ${index + 1}: ${formatPrice(prices[index])}`; - }, - [prices], - ); - - return ( - - - - {!isPhone && ( - - - BTC - - - Bitcoin - - - )} - - - {formatPrice(latestPrice)} - - - +{formatPercentChange(percentChange)} - - - - - - - - - - ); -} -``` - -### Service Availability - -You can have irregular data points by passing in `data` to `xAxis`. - -```jsx live -function ServiceAvailability() { - const availabilityEvents = useMemo( - () => [ - { date: new Date('2022-01-01'), availability: 79 }, - { date: new Date('2022-01-03'), availability: 81 }, - { date: new Date('2022-01-04'), availability: 82 }, - { date: new Date('2022-01-06'), availability: 91 }, - { date: new Date('2022-01-07'), availability: 92 }, - { date: new Date('2022-01-10'), availability: 86 }, - ], - [], - ); - - const chartAccessibilityLabel = `Availability chart showing ${availabilityEvents.length} data points over time`; - - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - const event = availabilityEvents[index]; - const formattedDate = event.date.toLocaleDateString('en-US', { - weekday: 'short', - month: 'short', - day: 'numeric', - year: 'numeric', - }); - const status = - event.availability >= 90 ? 'Good' : event.availability >= 85 ? 'Warning' : 'Critical'; - return `${formattedDate}: Availability ${event.availability}% - Status: ${status}`; - }, - [availabilityEvents], - ); - - return ( - event.availability), - gradient: { - stops: ({ min, max }) => [ - { offset: min, color: 'var(--color-fgNegative)' }, - { offset: 85, color: 'var(--color-fgNegative)' }, - { offset: 85, color: 'var(--color-fgWarning)' }, - { offset: 90, color: 'var(--color-fgWarning)' }, - { offset: 90, color: 'var(--color-fgPositive)' }, - { offset: max, color: 'var(--color-fgPositive)' }, - ], - }, - }, - ]} - xAxis={{ - data: availabilityEvents.map((event) => event.date.getTime()), - }} - yAxis={{ - domain: ({ min, max }) => ({ min: Math.max(min - 2, 0), max: Math.min(max + 2, 100) }), - }} - > - new Date(value).toLocaleDateString()} - /> - `${value}%`} - /> - ({ - ...props, - fill: 'var(--color-bg)', - stroke: props.fill, - })} - seriesId="availability" - /> - - - ); -} -``` - -### Forecast Asset Price - -You can combine multiple lines within a series to change styles dynamically. - -```jsx live -function ForecastAssetPrice() { - const startYear = 2020; - const data = [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70]; - const currentIndex = 6; - - const strokeWidth = 3; - // To prevent cutting off the edge of our lines - const clipOffset = strokeWidth; - - const axisFormatter = useCallback( - (dataIndex: number) => { - return startYear + dataIndex; - }, - [startYear], - ); - - const HistoricalLineComponent = memo((props: SolidLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - if (!xScale || !drawingArea) return; - - const currentX = xScale(currentIndex); - - if (currentX === undefined) return; - - return ( - <> - - - - - - - - - - ); - }); - - // Since the solid and dotted line have different curves, - // we need two separate line components. Otherwise we could - // have one line component with SolidLine and DottedLine inside - // of it and two clipPaths. - const ForecastLineComponent = memo((props: DottedLineProps) => { - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - if (!xScale || !drawingArea) return; - - const currentX = xScale(currentIndex); - - if (currentX === undefined) return; - - return ( - <> - - - - - - - - - - ); - }); - - const CustomScrubber = memo(() => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - // We need a fade in animation for the Scrubber - return ( - - - - - - - - - ); - }); - - return ( - - - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json b/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json deleted file mode 100644 index c551bd9554..0000000000 --- a/apps/docs/docs/components/graphs/LineChart/mobileMetadata.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "import": "import { LineChart } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/LineChart.tsx", - "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Point", - "url": "/components/graphs/Point/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/LineChart/webMetadata.json b/apps/docs/docs/components/graphs/LineChart/webMetadata.json deleted file mode 100644 index d540edc70a..0000000000 --- a/apps/docs/docs/components/graphs/LineChart/webMetadata.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "import": "import { LineChart } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/LineChart.tsx", - "description": "A flexible line chart component for displaying data trends over time. Supports multiple series, custom curves, areas, scrubbing, and interactive data exploration.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Point", - "url": "/components/graphs/Point/" - }, - { - "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/PeriodSelector/_mobileExamples.mdx b/apps/docs/docs/components/graphs/PeriodSelector/_mobileExamples.mdx deleted file mode 100644 index 5e91583fbe..0000000000 --- a/apps/docs/docs/components/graphs/PeriodSelector/_mobileExamples.mdx +++ /dev/null @@ -1,296 +0,0 @@ -## Basic Example - -```jsx -function BasicExample() { - const tabs = [ - { id: '1H', label: '1H' }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ]; - - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ; -} -``` - -## Minimum Width - -You can set the `width` prop to `fit-content` to make the period selector as small as possible. - -```jsx -function MinimumWidthExample() { - const tabs = [ - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: 'YTD', label: 'YTD' }, - ]; - - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ( - - ); -} -``` - -## Many Periods with Overflow - -```jsx -function ManyPeriodsExample() { - const tabs = useMemo( - () => [ - { - id: '1H', - label: , - }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: 'YTD', label: 'YTD' }, - { id: '1Y', label: '1Y' }, - { id: '5Y', label: '5Y' }, - { id: 'All', label: 'All' }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - const activeBackground = useMemo(() => (!isLive ? 'bgPrimaryWash' : 'bgNegativeWash'), [isLive]); - - return ( - - - - - - - ); -} -``` - -## Live Indicator - -```jsx -function LiveExample() { - const tabs = useMemo( - () => [ - // LiveTabLabel is exported from PeriodSelector - { id: '1H', label: }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); - - return ( - - ); -} -``` - -## Customization - -### Custom Colors - -```jsx -function ColoredBTCExample() { - const btcColor = assets.btc.color; - - // Custom active indicator with BTC color - const BTCActiveIndicator = memo((props) => { - const theme = useTheme(); - const { activeTab } = useTabsContext(); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - const backgroundColor = useMemo( - () => (isLive ? theme.color.bgNegativeWash : `${btcColor}1A`), - [isLive, theme.color.bgNegativeWash], - ); - - return ; - }); - - // Custom tab component with BTC color - const BTCTab = memo( - forwardRef(({ label, ...props }, ref) => { - const { activeTab } = useTabsContext(); - const isActive = activeTab?.id === props.id; - const theme = useTheme(); - - const wrappedLabel = - typeof label === 'string' ? ( - - {label} - - ) : ( - label - ); - - return ; - }), - ); - - // Custom live label with BTC color - const BTCLiveLabel = memo( - forwardRef(({ label = 'LIVE', font = 'label1', hideDot, style, ...props }, ref) => { - const theme = useTheme(); - - const dotStyle = useMemo( - () => ({ - width: theme.space[1], - height: theme.space[1], - borderRadius: 1000, - marginRight: theme.space[0.75], - backgroundColor: btcColor, - }), - [theme.space], - ); - - return ( - - {!hideDot && } - - {label} - - - ); - }), - ); - - const tabs = [ - { id: '1H', label: }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ]; - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ( - - ); -} -``` - -### Custom Colors Excluding Live - -Customize colors for regular periods while keeping the default red styling for live periods. - -```jsx -function ColoredExcludingLiveExample() { - const btcColor = assets.btc.color; - - // Custom active indicator that only applies BTC color to non-live periods - const BTCActiveExcludingLiveIndicator = memo((props) => { - const theme = useTheme(); - const { activeTab } = useTabsContext(); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - const backgroundColor = useMemo( - () => (isLive ? theme.color.bgNegativeWash : `${btcColor}1A`), - [isLive, theme.color.bgNegativeWash], - ); - - return ; - }); - - // Custom tab component with BTC color - const BTCTab = memo( - forwardRef(({ label, ...props }, ref) => { - const { activeTab } = useTabsContext(); - const isActive = activeTab?.id === props.id; - const theme = useTheme(); - - const wrappedLabel = - typeof label === 'string' ? ( - - {label} - - ) : ( - label - ); - - return ; - }), - ); - - const tabs = [ - { id: '1H', label: }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ]; - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ( - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx b/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx deleted file mode 100644 index 8e8da0d2c0..0000000000 --- a/apps/docs/docs/components/graphs/PeriodSelector/_webExamples.mdx +++ /dev/null @@ -1,532 +0,0 @@ -## Basic Example - -```jsx live -function BasicExample() { - const tabs = [ - { id: '1H', label: '1H' }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'YTD', label: 'YTD' }, - { id: 'All', label: 'All' }, - ]; - - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ( - - - - ); -} -``` - -## Minimum Width - -You can set the `width` prop to `fit-content` to make the period selector as small as possible. - -```jsx live -function MinimumWidthExample() { - const tabs = [ - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: 'YTD', label: 'YTD' }, - ]; - - const [activeTab, setActiveTab] = useState(tabs[0]); - - return ( - - ); -} -``` - -## Many Periods with Overflow - -```jsx live -function ManyPeriodsExample() { - const tabs = useMemo( - () => [ - { id: '1H', label: '1H' }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: 'YTD', label: 'YTD' }, - { id: '1Y', label: '1Y' }, - { id: '5Y', label: '5Y' }, - { id: 'All', label: 'All' }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - return ( - - - - - - - - - - - ); -} -``` - -## Live Indicator - -```jsx live -function LiveExample() { - const tabs = useMemo( - () => [ - // LiveTabLabel is exported from PeriodSelector - { id: '1H', label: }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const isLive = useMemo(() => activeTab?.id === '1H', [activeTab]); - - const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); - - return ( - - - - ); -} -``` - -## Customization - -### Custom Colors - -```jsx live -function LiveExample() { - const tabs = useMemo( - () => [ - { id: '1H', label: '1H' }, - { id: '1D', label: '1D' }, - { id: '1W', label: '1W' }, - { id: '1M', label: '1M' }, - { id: '1Y', label: '1Y' }, - { id: 'All', label: 'All' }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const isLive = useMemo(() => activeTab?.id === 'live', [activeTab]); - - const activeBackground = useMemo(() => (isLive ? 'bgNegativeWash' : 'bgPrimaryWash'), [isLive]); - - return ( - - - - ); -} -``` - -### Color Shifting - -```jsx live -function ColorShiftingExample() { - const TabLabel = memo(({ label }) => ( - - {label} - - )); - - const tabs = useMemo( - () => [ - { - id: '1H', - label: , - }, - { - id: '1D', - label: , - }, - { - id: '1W', - label: , - }, - { - id: '1M', - label: , - }, - { - id: '1Y', - label: , - }, - { - id: 'All', - label: , - }, - ], - [], - ); - - const [activeTab, setActiveTab] = useState(tabs[0]); - const [chartActiveColor, setChartActiveColor] = useState('positive'); - - const toggleColor = useCallback(() => { - setChartActiveColor((activeColor) => (activeColor === 'positive' ? 'negative' : 'positive')); - }, []); - - const activeForegroundColor = useMemo(() => { - return chartActiveColor === 'positive' ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; - }, [chartActiveColor]); - - const activeBackground = useMemo(() => { - return chartActiveColor === 'positive' ? 'bgPositiveWash' : 'bgNegativeWash'; - }, [chartActiveColor]); - - return ( - - - - - - - ); -} -``` - -### Asset Price Chart - -You can use a PeriodSelector to control the time period of a LineChart, with a settings icon to enable extra customization for users. - -```jsx live -function CustomizableAssetPriceExample() { - const tabs = [ - { id: 'hour', label: '1H' }, - { id: 'day', label: '1D' }, - { id: 'week', label: '1W' }, - { id: 'month', label: '1M' }, - { id: 'year', label: '1Y' }, - { id: 'all', label: 'All' }, - ]; - - const PeriodSelectorWrapper = memo(({ activeTab, setActiveTab, tabs, onClickSettings }) => ( - - - - - - - - - - - )); - - const AssetPriceChart = memo(() => { - const [activeTab, setActiveTab] = useState(tabs[0]); - const [showSettings, setShowSettings] = useState(false); - const [showYAxis, setShowYAxis] = useState(true); - const [showXAxis, setShowXAxis] = useState(true); - const [scrubIndex, setScrubIndex] = useState(); - const breakpoints = useBreakpoints(); - - const formatPrice = useCallback((price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(price); - }, []); - - const formatYAxisPrice = useCallback((price: number) => { - if (breakpoints.isPhone) { - // Compact format for mobile: $45k, $1.2M, etc. - if (price >= 1000000) { - return `$${(price / 1000000).toFixed(1)}M`; - } else if (price >= 1000) { - return `$${(price / 1000).toFixed(0)}k`; - } - return `$${price.toFixed(0)}`; - } - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price); - }, [breakpoints.isPhone]); - const toggleShowYAxis = useCallback(() => setShowYAxis((show) => !show), []); - const toggleShowXAxis = useCallback(() => setShowXAxis((show) => !show), []); - - const data = useMemo(() => sparklineInteractiveData[activeTab.id], [activeTab.id]); - const currentPrice = useMemo(() => sparklineInteractiveData.hour[sparklineInteractiveData.hour.length - 1].value, []); - const currentTimePrice = useMemo(() => { - if (scrubIndex !== undefined) { - return data[scrubIndex].value; - } - return currentPrice; - }, [data, scrubIndex, currentPrice]); - - const formatDate = useCallback((date) => { - const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); - const monthDay = date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - const time = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - return `${dayOfWeek}, ${monthDay}, ${time}`; - }, []); - - const scrubberLabel = useMemo(() => { - if (scrubIndex === undefined) return; - return formatDate(data[scrubIndex].date); - }, [scrubIndex, data, formatDate]); - - const accessibilityLabel = useMemo(() => { - if (scrubIndex === undefined) return; - const price = new Intl.NumberFormat('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(data[scrubIndex].value); - const date = formatDate(data[scrubIndex].date); - return `Asset price: ${price} USD on ${date}`; - }, [scrubIndex, data, formatDate]); - - const onClickSettings = useCallback(() => setShowSettings(!showSettings), [showSettings]); - - const seriesData = useMemo(() => [{ id: 'price', data: data.map((d) => d.value) }], [data]); - - const getFormattingConfigForPeriod = useCallback((period) => { - switch (period) { - case 'hour': - case 'day': - return { - hour: 'numeric', - minute: 'numeric', - }; - - case 'week': - case 'month': - return { - month: 'numeric', - day: 'numeric', - }; - - case 'year': - case 'all': - return { - month: 'numeric', - year: 'numeric', - }; - } - }, []); - - const formatXAxisDate = useCallback((index) => { - if (!data[index]) return ''; - const date = data[index].date; - const formatConfig = getFormattingConfigForPeriod(activeTab.id); - - if (activeTab.id === 'hour' || activeTab.id === 'day') { - return date.toLocaleTimeString('en-US', formatConfig); - } else { - return date.toLocaleDateString('en-US', formatConfig); - } - }, [data, activeTab.id, getFormattingConfigForPeriod]); - - const isMobile = breakpoints.isPhone || breakpoints.isTabletPortrait; - - return ( - - Asset Price} - balance={} - end={isMobile ? undefined : ( - - - ) - } - /> - - - - {isMobile && ( - - - - )} - {showSettings && ( - setShowSettings(false)}> - {({ handleClose }) => ( - - - Show Y-Axis - - - - - Show X-Axis - - - - )} - - )} - - ); - }, []); - - return ; -} -``` diff --git a/apps/docs/docs/components/graphs/PeriodSelector/index.mdx b/apps/docs/docs/components/graphs/PeriodSelector/index.mdx deleted file mode 100644 index 75a8105647..0000000000 --- a/apps/docs/docs/components/graphs/PeriodSelector/index.mdx +++ /dev/null @@ -1,39 +0,0 @@ ---- -id: periodSelector -title: PeriodSelector -platform_switcher_options: { web: true, mobile: true } -hide_title: true ---- - -import { VStack } from '@coinbase/cds-web/layout'; - -import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; -import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; - -import webPropsToc from ':docgen/web-visualization/chart/PeriodSelector/toc-props'; -import mobilePropsToc from ':docgen/mobile-visualization/chart/PeriodSelector/toc-props'; - -import WebPropsTable from './_webPropsTable.mdx'; -import MobilePropsTable from './_mobilePropsTable.mdx'; -import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; -import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; -import webMetadata from './webMetadata.json'; -import mobileMetadata from './mobileMetadata.json'; - - - - } - webExamples={} - mobilePropsTable={} - mobileExamples={} - webExamplesToc={webExamplesToc} - mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} - mobilePropsToc={mobilePropsToc} - /> - diff --git a/apps/docs/docs/components/graphs/PeriodSelector/mobileMetadata.json b/apps/docs/docs/components/graphs/PeriodSelector/mobileMetadata.json deleted file mode 100644 index 6e6aa66342..0000000000 --- a/apps/docs/docs/components/graphs/PeriodSelector/mobileMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { PeriodSelector } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/PeriodSelector.tsx", - "description": "A selector component for choosing time periods in charts.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "SegmentedTabs", - "url": "/components/navigation/SegmentedTabs/" - } - ], - "dependencies": [ - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/PeriodSelector/webMetadata.json b/apps/docs/docs/components/graphs/PeriodSelector/webMetadata.json deleted file mode 100644 index 65ff25fe5f..0000000000 --- a/apps/docs/docs/components/graphs/PeriodSelector/webMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { PeriodSelector } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/PeriodSelector.tsx", - "description": "A selector component for choosing time periods in charts.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "SegmentedTabs", - "url": "/components/navigation/SegmentedTabs/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Point/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Point/_mobileExamples.mdx deleted file mode 100644 index 3b0865b065..0000000000 --- a/apps/docs/docs/components/graphs/Point/_mobileExamples.mdx +++ /dev/null @@ -1,275 +0,0 @@ -## Basic Example - -Points are visual markers that highlight specific data values on a chart. They can be used to emphasize important data points, show discrete values, or provide interactive elements. - -You can add points using `points` on Line or [LineChart](/components/graphs/LineChart). - -```jsx - ({ min, max: max - 8 }), - }} - yAxis={{ - showGrid: true, - }} -> - - -``` - -You can also add Points directly to a chart. - -```jsx -function MyChart() { - const prices = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - return ( - - `$${value}`} /> - {prices.map((price, index) => ( - - ))} - - ); -} -``` - -### Conditional - -You can conditionally render points to highlight specific values in your data, such as maximum/minimum values or outliers. - -```jsx -function AssetPriceWithMinMax() { - const data = sparklineInteractiveData.hour.map((d) => d.value); - - const minPrice = Math.min(...data); - const maxPrice = Math.max(...data); - - const formatPrice = useCallback((price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(price); - }, []); - - return ( - { - const isMin = dataY === minPrice; - const isMax = dataY === maxPrice; - - if (isMin) { - return { label: formatPrice(dataY), labelPosition: 'bottom' }; - } - - if (isMax) { - return { label: formatPrice(dataY), labelPosition: 'top' }; - } - }} - series={[ - { - id: 'btc', - data: data, - color: assets.btc.color, - }, - ]} - /> - ); -}; -``` - -## Styling - -Points support customization through various properties including colors, sizes, and labels. - -```jsx -function CustomizedPoints() { - const theme = useTheme(); - return ( - { - const isHighPerformance = dataY >= 90; - const isLowPerformance = dataY < 75; - - return { - fill: isHighPerformance - ? theme.color.bgPositive - : isLowPerformance - ? theme.color.bgNegative - : theme.color.fgPrimary, - radius: isHighPerformance ? 6 : 4, - strokeWidth: 2, - stroke: theme.color.bg, - label: isHighPerformance || isLowPerformance ? `${dataY}%` : undefined, - labelPosition: isHighPerformance ? 'top' : 'bottom', - }; - }} - series={[ - { - id: 'performance', - data: [65, 70, 72, 85, 88, 92, 78, 82, 90, 95, 91, 94], - }, - ]} - yAxis={{ - showGrid: true, - label: 'Performance Score', - }} - /> - ); -} -``` - -### Labels - -You can use `labelPosition`, `labelOffset`, and `labelFont` to adjust Point's label. - -```jsx -function Scatterplot() { - const dataPoints = [ - { x: 20, y: 30, label: 'A' }, - { x: 40, y: 65, label: 'B' }, - { x: 60, y: 45, label: 'C' }, - { x: 75, y: 80, label: 'D' }, - ]; - - return ( - - - - {dataPoints.map((point, index) => ( - - ))} - - ); -} -``` - -### Custom Label Position - -You can also use `LabelComponent` to create custom label components. - -```jsx -function ScatterplotWithCustomLabels() { - const theme = useTheme(); - const dataPoints = [ - { x: 12, y: 34, label: 'A', color: theme.color.fgAccent }, - { x: 28, y: 67, label: 'B', color: theme.color.fgAccent }, - { x: 45, y: 23, label: 'C', color: theme.color.fgAccent }, - { x: 67, y: 89, label: 'D', color: theme.color.bgPositive }, - { x: 82, y: 76, label: 'E', color: theme.color.bgPositive }, - { x: 34, y: 91, label: 'F', color: theme.color.bgPositive }, - { x: 56, y: 45, label: 'G', color: theme.color.bgPositive }, - { x: 19, y: 12, label: 'H', color: theme.color.fgWarning }, - { x: 73, y: 28, label: 'I', color: theme.color.fgWarning }, - { x: 91, y: 54, label: 'J', color: theme.color.fgWarning }, - { x: 15, y: 58, label: 'K', color: theme.color.fgPrimary }, - { x: 39, y: 72, label: 'L', color: theme.color.fgPrimary }, - { x: 88, y: 15, label: 'M', color: theme.color.fgPrimary }, - { x: 52, y: 82, label: 'N', color: theme.color.fgPrimary }, - ]; - - // Calculate domain based on data - const xValues = dataPoints.map((p) => p.x); - const yValues = dataPoints.map((p) => p.y); - const xMin = Math.min(...xValues); - const xMax = Math.max(...xValues); - const yMin = Math.min(...yValues); - const yMax = Math.max(...yValues); - - // Custom label component that places labels to the top-right - const TopRightPointLabel = ({ x, y, offset = 0, children }) => { - return ( - - {children} - - ); - }; - - return ( - - - - {dataPoints.map((point, index) => ( - - ))} - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/Point/_webExamples.mdx b/apps/docs/docs/components/graphs/Point/_webExamples.mdx deleted file mode 100644 index c3d0ffdabe..0000000000 --- a/apps/docs/docs/components/graphs/Point/_webExamples.mdx +++ /dev/null @@ -1,310 +0,0 @@ -## Basics - -Points are visual markers that highlight specific data values on a chart. They can be used to emphasize important data points, show discrete values, or provide interactive elements. - -You can add points using `points` on Line or [LineChart](/components/graphs/LineChart). - -```jsx live - ({ min, max: max - 8 }), - }} - yAxis={{ - showGrid: true, - }} -> - - -``` - -You can also add Points directly to a chart. - -```jsx live -function MyChart() { - const prices = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - - return ( - - `$${value}`} /> - {prices.map((price, index) => ( - - ))} - - ); -} -``` - -### Conditional - -You can conditionally render points to highlight specific values in your data, such as maximum/minimum values or outliers. - -```jsx live -function AssetPriceWithMinMax() { - const data = sparklineInteractiveData.hour.map((d) => d.value); - - const minPrice = Math.min(...data); - const maxPrice = Math.max(...data); - - const formatPrice = useCallback((price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(price); - }, []); - - return ( - { - const isMin = dataY === minPrice; - const isMax = dataY === maxPrice; - - if (isMin) { - return { label: formatPrice(dataY), labelPosition: 'bottom' }; - } - - if (isMax) { - return { label: formatPrice(dataY), labelPosition: 'top' }; - } - }} - series={[ - { - id: 'btc', - data: data, - color: assets.btc.color, - }, - ]} - style={{ outlineColor: assets.btc.color }} - /> - ); -}; -``` - -## Interaction - -Points can be made interactive by adding click handlers, allowing users to explore data in more detail. - -```jsx live - { - const months = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ]; - return { - radius: 4, - onClick: () => alert(`${months[dataX]}: ${dataY} units sold`), - accessibilityLabel: `${months[dataX]} sales: ${dataY} units`, - }; - }} - series={[ - { - id: 'sales', - data: [120, 132, 101, 134, 90, 230, 210, 120, 180, 190, 210, 176], - }, - ]} - yAxis={{ - showGrid: true, - label: 'Sales (units)', - }} -/> -``` - -## Styling - -Points support customization through various properties including colors, sizes, and labels. - -```jsx live - { - const isHighPerformance = dataY >= 90; - const isLowPerformance = dataY < 75; - - return { - fill: isHighPerformance - ? 'var(--color-bgPositive)' - : isLowPerformance - ? 'var(--color-bgNegative)' - : 'var(--color-fgPrimary)', - radius: isHighPerformance ? 6 : 4, - strokeWidth: 2, - stroke: 'var(--color-bg)', - label: isHighPerformance || isLowPerformance ? `${dataY}%` : undefined, - labelPosition: isHighPerformance ? 'top' : 'bottom', - }; - }} - series={[ - { - id: 'performance', - data: [65, 70, 72, 85, 88, 92, 78, 82, 90, 95, 91, 94], - }, - ]} - xAxis={{ - range: ({ min, max }) => ({ min, max: max - 8 }), - }} - yAxis={{ - showGrid: true, - label: 'Performance Score', - }} -/> -``` - -### Labels - -You can use `labelPosition`, `labelOffset`, and `labelFont` to adjust Point's label. - -```jsx live -function Scatterplot() { - const dataPoints = [ - { x: 20, y: 30, label: 'A' }, - { x: 40, y: 65, label: 'B' }, - { x: 60, y: 45, label: 'C' }, - { x: 75, y: 80, label: 'D' }, - ]; - - return ( - - - - {dataPoints.map((point, index) => ( - - ))} - - ); -} -``` - -### Custom Label Position - -You can also use `LabelComponent` to create custom label components. - -```jsx live -function ScatterplotWithCustomLabels() { - const dataPoints = [ - { x: 12, y: 34, label: 'A', color: 'var(--color-fgAccent)' }, - { x: 28, y: 67, label: 'B', color: 'var(--color-fgAccent)' }, - { x: 45, y: 23, label: 'C', color: 'var(--color-fgAccent)' }, - { x: 67, y: 89, label: 'D', color: 'var(--color-bgPositive)' }, - { x: 82, y: 76, label: 'E', color: 'var(--color-bgPositive)' }, - { x: 34, y: 91, label: 'F', color: 'var(--color-bgPositive)' }, - { x: 56, y: 45, label: 'G', color: 'var(--color-bgPositive)' }, - { x: 19, y: 12, label: 'H', color: 'var(--color-fgWarning)' }, - { x: 73, y: 28, label: 'I', color: 'var(--color-fgWarning)' }, - { x: 91, y: 54, label: 'J', color: 'var(--color-fgWarning)' }, - { x: 15, y: 58, label: 'K', color: 'var(--color-fgPrimary)' }, - { x: 39, y: 72, label: 'L', color: 'var(--color-fgPrimary)' }, - { x: 88, y: 15, label: 'M', color: 'var(--color-fgPrimary)' }, - { x: 52, y: 82, label: 'N', color: 'var(--color-fgPrimary)' }, - ]; - - // Calculate domain based on data - const xValues = dataPoints.map((p) => p.x); - const yValues = dataPoints.map((p) => p.y); - const xMin = Math.min(...xValues); - const xMax = Math.max(...xValues); - const yMin = Math.min(...yValues); - const yMax = Math.max(...yValues); - - // Custom label component that places labels to the top-right - const TopRightPointLabel = ({ x, y, offset = 0, children }) => { - return ( - - {children} - - ); - }; - - return ( - - - - {dataPoints.map((point, index) => ( - - ))} - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/Point/mobileMetadata.json b/apps/docs/docs/components/graphs/Point/mobileMetadata.json deleted file mode 100644 index 660a5e89a6..0000000000 --- a/apps/docs/docs/components/graphs/Point/mobileMetadata.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "import": "import { Point } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/Point.tsx", - "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and labels.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Point/webMetadata.json b/apps/docs/docs/components/graphs/Point/webMetadata.json deleted file mode 100644 index 0f3a11077f..0000000000 --- a/apps/docs/docs/components/graphs/Point/webMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { Point } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/Point.tsx", - "description": "Visual markers that highlight specific data values on a chart. Points can be customized with different colors, sizes, and interactivity.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Scrubber", - "url": "/components/graphs/Scrubber/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/ReferenceLine/_mobileExamples.mdx b/apps/docs/docs/components/graphs/ReferenceLine/_mobileExamples.mdx deleted file mode 100644 index 4fe6ec9e9c..0000000000 --- a/apps/docs/docs/components/graphs/ReferenceLine/_mobileExamples.mdx +++ /dev/null @@ -1,261 +0,0 @@ -## Basics - -ReferenceLine can be used to add important details to a chart, such as a reference price or date. You can create horizontal lines using `dataY` or vertical lines using `dataX`. - -### Simple Reference Line - -A minimal reference line without labels, useful for marking key thresholds: - -```jsx -function SimpleReferenceLineExample() { - const theme = useTheme(); - - return ( - - } - dataY={10} - stroke={theme.color.fg} - /> - - ); -} -``` - -### With Labels - -You can add text labels to reference lines and position them using alignment and offset props: - -```jsx -function WithLabelsExample() { - return ( - - - - - ); -} -``` - -## Data Values - -ReferenceLine relies on `dataX` or `dataY` to position the line. Passing in `dataY` will create a horizontal line across the y axis at that value, and passing in `dataX` will do the same along the x axis. - -```jsx -function DataValuesExample() { - const theme = useTheme(); - - return ( - - - - - ); -} -``` - -## Labels - -### Customization - -You can customize label appearance using `labelFont`, `labelDx`, `labelDy`, `labelHorizontalAlignment`, and `labelVerticalAlignment` props. - -```jsx -function LabelCustomizationExample() { - return ( - - - - - ); -} -``` - -### Bounds - -Use `labelBoundsInset` to prevent labels from getting too close to chart edges. - -```jsx -function BoundsExample() { - return ( - - - - - ); -} -``` - -### Custom Component - -You can adjust the style of the label using a custom `LabelComponent`. - -```jsx -function LabelStyleExample() { - const theme = useTheme(); - - const LiquidationLabel = useMemo( - () => - memo((props) => ( - - )), - [theme.color.accentSubtleYellow], - ); - - const PriceLabel = useMemo( - () => - memo((props) => ( - - )), - [theme.color.bg, theme.color.yellow70], - ); - - return ( - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/ReferenceLine/_webExamples.mdx b/apps/docs/docs/components/graphs/ReferenceLine/_webExamples.mdx deleted file mode 100644 index a90612a06a..0000000000 --- a/apps/docs/docs/components/graphs/ReferenceLine/_webExamples.mdx +++ /dev/null @@ -1,556 +0,0 @@ -## Basics - -ReferenceLine can be used to add important details to a chart, such as a reference price or date. You can create horizontal lines using `dataY` or vertical lines using `dataX`. - -### Simple Reference Line - -A minimal reference line without labels, useful for marking key thresholds: - -```jsx live - - } - dataY={10} - stroke="var(--color-fg)" - /> - -``` - -### With Labels - -You can add text labels to reference lines and position them using alignment and offset props: - -```jsx live - - - - -``` - -## Data Values - -ReferenceLine relies on `dataX` or `dataY` to position the line. Passing in `dataY` will create a horizontal line across the y axis at that value, and passing in `dataX` will do the same along the x axis. - -```jsx live - - - - -``` - -## Labels - -### Customization - -You can customize label appearance using `labelFont`, `labelDx`, `labelDy`, `labelHorizontalAlignment`, and `labelVerticalAlignment` props. - -```jsx live - - - - -``` - -### Bounds - -Use `labelBoundsInset` to prevent labels from getting too close to chart edges. - -```jsx live - - - - - - -``` - -### Custom Component - -You can adjust the style of the label using a custom `LabelComponent`. - -```jsx live -function LabelStyleExample() { - const LiquidationLabel = useMemo( - () => - memo((props) => ( - - )), - [], - ); - - const PriceLabel = useMemo( - () => - memo((props) => ( - - )), - [], - ); - - return ( - - - - - ); -} -``` - -## Draggable Price Target - -You can pair a ReferenceLine with a custom drag component to create a draggable price target. - -```jsx live -function DraggablePriceTarget() { - const DragIcon = ({ x, y }: { x: number; y: number }) => { - const DragCircle = (props: React.SVGProps) => ( - - ); - - return ( - - - - - - - - - - - ); - }; - - const TrendArrowIcon = ({ - x, - y, - isPositive, - color, - }: { - x: number; - y: number; - isPositive: boolean; - color: string; - }) => { - return ( - - - - - - ); - }; - - const DynamicPriceLabel = memo( - ({ color, ...props }: React.ComponentProps & { color: string }) => ( - - ), - ); - - const DraggableReferenceLine = memo( - ({ - baselineAmount, - startAmount, - chartRef, - }: { - baselineAmount: number; - startAmount: number; - chartRef: RefObject; - }) => { - const theme = useTheme(); - const { isPhone } = useBreakpoints(); - - const formatPrice = useCallback((value: number) => { - return `$${value.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - const { getYScale, drawingArea } = useCartesianChartContext(); - const [amount, setAmount] = useState(startAmount); - const [isDragging, setIsDragging] = useState(false); - const [textDimensions, setTextDimensions] = useState({ width: 0, height: 0 }); - const color = amount >= baselineAmount ? 'var(--color-bgPositive)' : 'var(--color-bgNegative)'; - - const yScale = getYScale(); - - const labelComponent = useCallback( - (props: React.ComponentProps) => ( - - ), - [color], - ); - - // Set up persistent event listeners on the chart SVG element - useEffect(() => { - const element = chartRef.current; - - if (!element || !yScale || !('invert' in yScale && typeof yScale.invert === 'function')) { - return; - } - - const updatePosition = (clientX: number, clientY: number) => { - const point = element.createSVGPoint(); - point.x = clientX; - point.y = clientY; - - const svgPoint = point.matrixTransform(element.getScreenCTM()?.inverse()); - - // Clamp the Y position to the chart area - const clampedY = Math.max( - drawingArea.y, - Math.min(drawingArea.y + drawingArea.height, svgPoint.y), - ); - - const rawAmount = yScale.invert(clampedY); - - const rawPercentage = ((rawAmount - baselineAmount) / baselineAmount) * 100; - - let targetPercentage = Math.round(rawPercentage); - - if (targetPercentage === 0) { - targetPercentage = rawPercentage >= 0 ? 1 : -1; - } - - const newAmount = baselineAmount * (1 + targetPercentage / 100); - setAmount(newAmount); - }; - - const handleMouseMove = (event: MouseEvent) => { - if (!isDragging) { - return; - } - updatePosition(event.clientX, event.clientY); - }; - - const handleTouchMove = (event: TouchEvent) => { - if (!isDragging || event.touches.length === 0) { - return; - } - const touch = event.touches[0]; - updatePosition(touch.clientX, touch.clientY); - }; - - const handleMouseUp = () => { - setIsDragging(false); - }; - - const handleTouchEnd = () => { - setIsDragging(false); - }; - - const handleMouseLeave = () => { - setIsDragging(false); - }; - - element.addEventListener('mousemove', handleMouseMove); - element.addEventListener('mouseup', handleMouseUp); - element.addEventListener('mouseleave', handleMouseLeave); - element.addEventListener('touchmove', handleTouchMove); - element.addEventListener('touchend', handleTouchEnd); - element.addEventListener('touchcancel', handleTouchEnd); - - return () => { - element.removeEventListener('mousemove', handleMouseMove); - element.removeEventListener('mouseup', handleMouseUp); - element.removeEventListener('mouseleave', handleMouseLeave); - element.removeEventListener('touchmove', handleTouchMove); - element.removeEventListener('touchend', handleTouchEnd); - element.removeEventListener('touchcancel', handleTouchEnd); - }; - }, [isDragging, yScale, chartRef, baselineAmount, drawingArea.y, drawingArea.height]); - - if (!yScale) return null; - - const yPixel = yScale(amount); - - if (yPixel === undefined || yPixel === null) return null; - - const difference = amount - baselineAmount; - const percentageChange = Math.round((difference / baselineAmount) * 100); - const isPositive = difference > 0; - - const percentageLabel = isPhone - ? `${Math.abs(percentageChange)}%` - : `${Math.abs(percentageChange)}% (${formatPrice(Math.abs(difference))})`; - const dollarLabel = formatPrice(amount); - - const handleMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const handleTouchStart = (e: React.TouchEvent) => { - e.preventDefault(); - setIsDragging(true); - }; - - const padding = 16; - const dragIconSize = 16; - const trendArrowIconSize = 16; - const iconGap = 8; - const totalPadding = padding * 2 + iconGap; - - const rectWidth = textDimensions.width + totalPadding + dragIconSize + trendArrowIconSize; - - return ( - <> - - - - - - setTextDimensions(dimensions)} - verticalAlignment="middle" - x={drawingArea.x + padding + dragIconSize + iconGap + trendArrowIconSize} - y={yPixel + 1} - > - {percentageLabel} - - - - ); - }, - ); - - const BaselinePriceLabel = useMemo(() => memo((props) => ( - - )), []); - - const PriceTargetChart = () => { - const priceData = useMemo(() => sparklineInteractiveData.year.map((d) => d.value), []); - const { isPhone } = useBreakpoints(); - - const chartRef = useRef(null); - - const formatPrice = useCallback((value: number) => { - return `$${value.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`; - }, []); - - return ( - ({ min: min * 0.7, max: max * 1.3 }) }} - > - {!isPhone && ( - - )} - - - ); - }; - return -} -``` diff --git a/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json b/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json deleted file mode 100644 index f30371a6e5..0000000000 --- a/apps/docs/docs/components/graphs/ReferenceLine/mobileMetadata.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "import": "import { ReferenceLine } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx", - "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json b/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json deleted file mode 100644 index 270290eef0..0000000000 --- a/apps/docs/docs/components/graphs/ReferenceLine/webMetadata.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "import": "import { ReferenceLine } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/line/ReferenceLine.tsx", - "description": "A horizontal or vertical reference line to mark important values on a chart, such as targets, thresholds, or baseline values.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx deleted file mode 100644 index 8344275fc6..0000000000 --- a/apps/docs/docs/components/graphs/Scrubber/_mobileExamples.mdx +++ /dev/null @@ -1,689 +0,0 @@ -## Basics - -Scrubber can be used to provide horizontal interaction with a chart. As you drag over the chart, you will see a line and scrubber beacon following. - -```jsx - - - -``` - -All series will be scrubbed by default. You can set `seriesIds` to show only specific series. - -```jsx - - - -``` - -## Labels - -Setting `label` on a series will display a label to the side of the scrubber beacon, and -setting `label` on Scrubber displays a label above the scrubber line. - -```jsx - - `Day ${dataIndex + 1}`} /> - -``` - -## Pulsing - -Setting `idlePulse` to `true` will cause the scrubber beacons to pulse when the user is not actively scrubbing. - -```jsx - - } - dataY={10} - stroke="var(--color-fg)" - /> - - -``` - -You can also use the imperative handle to pulse the scrubber beacons programmatically. - -```jsx -function ImperativeHandle() { - const scrubberRef = useRef(null); - return ( - - ({ min, max: max - 8 }), - }} - yAxis={{ - showGrid: true, - }} - > - - - - - ); -} -``` - -## Styling - -### Beacons - -You can use `BeaconComponent` to customize the visual appearance of scrubber beacons. - -```jsx -function OutlineBeacon() { - // Simple outline beacon with no pulse animation - const OutlineBeaconComponent = memo( - forwardRef(({ dataX, dataY, seriesId, isIdle, animate = true }: ScrubberBeaconProps, ref) => { - const theme = useTheme(); - const { getSeries, getXSerializableScale, getYSerializableScale } = useCartesianChartContext(); - - const targetSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); - const yScale = useMemo( - () => getYSerializableScale(targetSeries?.yAxisId), - [getYSerializableScale, targetSeries?.yAxisId], - ); - - const color = useMemo( - () => targetSeries?.color ?? theme.color.fgPrimary, - [targetSeries?.color, theme.color.fgPrimary], - ); - - const animatedX = useSharedValue(0); - const animatedY = useSharedValue(0); - - // Provide a no-op pulse implementation for simple beacons - useImperativeHandle(ref, () => ({ pulse: () => {} }), []); - - // Calculate the target point position - project data to pixels - const targetPoint = useDerivedValue(() => { - if (!xScale || !yScale) return { x: 0, y: 0 }; - return projectPointWithSerializableScale({ - x: unwrapAnimatedValue(dataX), - y: unwrapAnimatedValue(dataY), - xScale, - yScale, - }); - }, [dataX, dataY, xScale, yScale]); - - useAnimatedReaction( - () => { - return { point: targetPoint.value, isIdle: unwrapAnimatedValue(isIdle) }; - }, - (current, previous) => { - // When animation is disabled, on initial render, or when we are starting, - // continuing, or finishing scrubbing we should immediately transition - if (!animate || previous === null || !previous.isIdle || !current.isIdle) { - animatedX.value = current.point.x; - animatedY.value = current.point.y; - return; - } - - animatedX.value = buildTransition(current.point.x, defaultTransition); - animatedY.value = buildTransition(current.point.y, defaultTransition); - }, - [animate], - ); - - // Create animated point using the animated values - const animatedPoint = useDerivedValue(() => { - return { x: animatedX.value, y: animatedY.value }; - }, [animatedX, animatedY]); - - return ( - <> - - - - ); - }), - ); - - const dataCount = 14; - const minDataValue = 0; - const maxDataValue = 100; - const minStepOffset = 5; - const maxStepOffset = 20; - const updateInterval = 2000; - - function generateNextValue(previousValue: number) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataValue) { - direction = -1; - } else if (previousValue <= minDataValue) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - const newValue = previousValue + offset * direction; - return Math.max(minDataValue, Math.min(maxDataValue, newValue)); - } - - function generateInitialData() { - const data = []; - let previousValue = Math.random() * (maxDataValue - minDataValue) + minDataValue; - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - return data; - } - - - const OutlineBeaconChart = memo(() => { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 50; - const newValue = generateNextValue(lastValue); - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - return ( - ({ min, max: max - 16 }), - }} - yAxis={{ - showGrid: true, - domain: { min: 0, max: 100 } - }} - > - - - ); - }); - - return ; -} -``` - -### Labels - -You can use `BeaconLabelComponent` to customize the labels for each scrubber beacon. - -```jsx -function CustomBeaconLabel() { - const theme = useTheme(); - // This custom component label shows the percentage value of the data at the scrubber position. - const MyScrubberBeaconLabel = memo( - ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { - const { getSeriesData, dataLength } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - - const seriesData = useMemo( - () => getLineData(getSeriesData(seriesId)), - [getSeriesData, seriesId], - ); - - const dataIndex = useDerivedValue(() => { - return scrubberPosition.value ?? Math.max(0, dataLength - 1); - }, [scrubberPosition, dataLength]); - - const percentageLabel = useDerivedValue(() => { - if (seriesData !== undefined) { - const dataAtPosition = seriesData[dataIndex.value]; - return `${unwrapAnimatedValue(label)} · ${dataAtPosition}%`; - } - return unwrapAnimatedValue(label); - }, [label, seriesData, dataIndex]); - - return ( - - ); - }, - ); - - return ( - - - - ); -} -``` - -Using `labelElevated` will elevate the Scrubber's reference line label with a shadow. - -```jsx - - `Day ${dataIndex + 1}`} labelElevated /> - -``` - -You can use `LabelComponent` to customize this label even further. - -```jsx -function CustomLabelComponent() { - const CustomLabelComponent = memo((props: ScrubberLabelProps) => { - const theme = useTheme(); - const { drawingArea } = useCartesianChartContext(); - - if (!drawingArea) return; - - return ( - - ); - }); - return ( - - `Day ${dataIndex + 1}`} - /> - - ); -} -``` - -#### Multi-line Centered Text - -You can create custom multi-line centered labels using Skia's `ParagraphBuilder` with `TextAlign.Center`. Set `paragraphAlignment={TextAlign.Center}` on your custom label component to ensure proper positioning. - -```jsx -function TwoLineCenteredLabel() { - const theme = useTheme(); - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - - const fontMgr = useMemo(() => Skia.TypefaceFontProvider.Make(), []); - - const formatPrice = useCallback((price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(price); - }, []); - - const scrubberLabel = useCallback( - (index: number) => { - const price = formatPrice(data[index]); - const day = `Day ${index + 1}`; - - const priceStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 16, - fontStyle: { weight: FontWeight.Bold }, - color: Skia.Color(theme.color.fg), - }; - - const dayStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 14, - fontStyle: { weight: FontWeight.Normal }, - color: Skia.Color(theme.color.fgMuted), - }; - - const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Center }, fontMgr); - - builder.pushStyle(priceStyle); - builder.addText(price); - builder.addText('\n'); - - builder.pushStyle(dayStyle); - builder.addText(day); - - const para = builder.build(); - para.layout(384); - return para; - }, - [data, formatPrice, theme.color.fg, theme.color.fgMuted, fontMgr], - ); - - // Custom label component that sets paragraphAlignment to center - const CenteredScrubberLabel = memo((props: ScrubberLabelProps) => ( - - )); - - return ( - - - - ); -} -``` - -#### Fonts - -You can use `labelFont` to customize the font of the scrubber line label and `beaconLabelFont` to customize the font of the beacon labels. - -```jsx -function CustomLabelFonts() { - const theme = useTheme(); - - return ( - - `Day ${dataIndex + 1}`} - labelFont="legal" - beaconLabelFont="legal" - /> - - ); -} -``` - -#### Bounds - -Use `labelBoundsInset` to prevent the scrubber line label from getting too close to chart edges. - -```jsx -function WithoutBoundsExample() { - return ( - - - - ); -} -``` - -```jsx -function WithBoundsExample() { - return ( - - - - ); -} -``` - -### Line - -You can use `LineComponent` to customize Scrubber's line. In this case, as a user scrubs, they will see a solid line instead of dotted. - -```jsx - - - -``` - -### Opacity - -You can use `BeaconComponent` and `BeaconLabelComponent` with the `opacity` prop to hide scrubber beacons and labels when idle. - -```jsx -function HiddenScrubberWhenIdle() { - const MyScrubberBeacon = memo( - forwardRef((props: ScrubberBeaconProps, ref) => { - const { scrubberPosition } = useScrubberContext(); - const beaconOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); - - return ; - }), - ); - - const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { - const { scrubberPosition } = useScrubberContext(); - const labelOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); - - return ; - }); - - return ( - - - - ); -} -``` - -### Overlay - -By default, Scrubber will show an overlay to de-emphasize future data. You can hide this by setting `hideOverlay` to `true`. - -```jsx - - - -``` diff --git a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx b/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx deleted file mode 100644 index 6de30ab0b6..0000000000 --- a/apps/docs/docs/components/graphs/Scrubber/_webExamples.mdx +++ /dev/null @@ -1,590 +0,0 @@ -## Basics - -Scrubber can be used to provide horizontal interaction with a chart. As your mouse hovers over the chart, you will see a line and scrubber beacon following. - -```jsx live - ({ min, max: max - 8 }), - }} - yAxis={{ - showGrid: true, - }} -> - - -``` - -All series will be scrubbed by default. You can set `seriesIds` to show only specific series. - -```jsx live - , - }, - { - id: 'bottom', - data: [4, 8, 11, 15, 16, 14, 16, 10, 12, 14], - color: '#800080', - curve: 'step', - AreaComponent: DottedArea, - showArea: true, - }, - ]} -> - - -``` - -## Labels - -Setting `label` on a series will display a label to the side of the scrubber beacon, and -setting `label` on Scrubber displays a label above the scrubber line. - -```jsx live - - `Day ${dataIndex + 1}`} /> - -``` - -## Pulsing - -Setting `idlePulse` to `true` will cause the scrubber beacons to pulse when the user is not actively scrubbing. - -```jsx live - - } - dataY={10} - stroke="var(--color-fg)" - /> - - -``` - -You can also use the imperative handle to pulse the scrubber beacons programmatically. - -```jsx live -function ImperativeHandle() { - const scrubberRef = useRef(null); - return ( - - - - - - - ); -} -``` - -## Styling - -### Beacons - -You can use `BeaconComponent` to customize the visual appearance of scrubber beacons. - -```jsx live -function OutlineBeacon() { - // Simple outline beacon with no pulse animation - const OutlineBeaconComponent = memo( - forwardRef(({ dataX, dataY, seriesId, color, isIdle }: ScrubberBeaconProps, ref) => { - const { getSeries, getXScale, getYScale } = useCartesianChartContext(); - const targetSeries = getSeries(seriesId); - const xScale = getXScale(); - const yScale = getYScale(targetSeries?.yAxisId); - - const pixelCoordinate = useMemo(() => { - if (!xScale || !yScale) return; - return projectPoint({ x: dataX, y: dataY, xScale, yScale }); - }, [dataX, dataY, xScale, yScale]); - - // Provide a no-op pulse implementation for simple beacons - useImperativeHandle(ref, () => ({ pulse: () => {} }), []); - - if (!pixelCoordinate) return; - - if (isIdle) { - return ( - <> - - - - ); - } - - return ( - <> - - - - ); - }), - ); - - const dataCount = 14; - const minDataValue = 0; - const maxDataValue = 100; - const minStepOffset = 5; - const maxStepOffset = 20; - const updateInterval = 2000; - - function generateNextValue(previousValue) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataValue) { - direction = -1; - } else if (previousValue <= minDataValue) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - return Math.max(minDataValue, Math.min(maxDataValue, newValue)); - } - - function generateInitialData() { - const data = []; - let previousValue = Math.random() * (maxDataValue - minDataValue) + minDataValue; - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - return data; - } - - - const OutlineBeaconChart = memo(() => { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 50; - const newValue = generateNextValue(lastValue); - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - return ( - ({ min, max: max - 16 }), - }} - yAxis={{ - showGrid: true, - domain: { min: 0, max: 100 } - }} - > - - - ); - }); - - return ; -} -``` - -### Labels - -You can use `BeaconLabelComponent` to customize the labels for each scrubber beacon. - -```jsx live -function CustomBeaconLabel() { - // This custom component label shows the percentage value of the data at the scrubber position. - const MyScrubberBeaconLabel = memo(({ seriesId, color, label, ...props}: ScrubberBeaconLabelProps) => { - const { getSeriesData, dataLength } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - - const seriesData = useMemo(() => getLineData(getSeriesData(seriesId)), [getSeriesData, seriesId]); - - const dataIndex = useMemo(() => { - return scrubberPosition ?? Math.max(0, dataLength - 1); - }, [scrubberPosition, dataLength]); - - const percentageLabel = useMemo(() => { - if (seriesData !== undefined) { - const dataAtPosition = seriesData[dataIndex]; - return `${label} · ${dataAtPosition}%`; - } - return label; - }, [label, seriesData, dataIndex]) - - return ( - - ); - }); - - return ( - - - - ); -} -``` - -Using `labelElevated` will elevate the Scrubber's reference line label with a shadow. - -```jsx live - - `Day ${dataIndex + 1}`} labelElevated /> - -``` - -You can use `LabelComponent` to customize this label even further. - -```jsx live -function CustomLabelComponent() { - const CustomLabelComponent = memo((props: ScrubberLabelProps) => { - const { drawingArea } = useCartesianChartContext(); - - if (!drawingArea) return; - - return ( - - ); - }); - return ( - - `Day ${dataIndex + 1}`} - /> - - ); -} -``` - -#### Fonts - -You can use `labelFont` to customize the font of the scrubber line label and `beaconLabelFont` to customize the font of the beacon labels. - -```jsx live - - `Day ${dataIndex + 1}`} - labelFont="legal" - beaconLabelFont="legal" - /> - -``` - -#### Bounds - -Use `labelBoundsInset` to prevent the scrubber line label from getting too close to chart edges. - -```jsx live - - - - - -``` - -```jsx live - - - - - -``` - -### Line - -You can use `LineComponent` to customize Scrubber's line. In this case, as a user scrubs, they will see a solid line instead of dotted. - -```jsx live - - - -``` - -### Opacity - -You can use `BeaconComponent` and `BeaconLabelComponent` with the `opacity` prop to hide scrubber beacons and labels when idle. - -```jsx live -function HiddenScrubberWhenIdle() { - const MyScrubberBeacon = memo( - forwardRef((props: ScrubberBeaconProps, ref) => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - - return ; - }), - ); - - const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { - const { scrubberPosition } = useScrubberContext(); - const isScrubbing = scrubberPosition !== undefined; - - return ; - }); - - return ( - - - - ); -} -``` - -### Overlay - -By default, Scrubber will show an overlay to de-emphasize future data. You can hide this by setting `hideOverlay` to `true`. - -```jsx live - - - -``` diff --git a/apps/docs/docs/components/graphs/Scrubber/index.mdx b/apps/docs/docs/components/graphs/Scrubber/index.mdx deleted file mode 100644 index ecfaade0cd..0000000000 --- a/apps/docs/docs/components/graphs/Scrubber/index.mdx +++ /dev/null @@ -1,35 +0,0 @@ ---- -id: scrubber -title: Scrubber -platform_switcher_options: { web: true, mobile: true } -hide_title: true ---- - -import { VStack } from '@coinbase/cds-web/layout'; - -import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; -import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; - -import webPropsToc from ':docgen/web-visualization/chart/scrubber/Scrubber/toc-props'; -import mobilePropsToc from ':docgen/mobile-visualization/chart/scrubber/Scrubber/toc-props'; - -import WebPropsTable from './_webPropsTable.mdx'; -import MobilePropsTable from './_mobilePropsTable.mdx'; -import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; -import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; -import webMetadata from './webMetadata.json'; -import mobileMetadata from './mobileMetadata.json'; - - - - } - webExamples={} - mobilePropsTable={} - mobileExamples={} - webExamplesToc={webExamplesToc} - mobileExamplesToc={mobileExamplesToc} - webPropsToc={webPropsToc} - mobilePropsToc={mobilePropsToc} - /> - diff --git a/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json b/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json deleted file mode 100644 index f18cb0336d..0000000000 --- a/apps/docs/docs/components/graphs/Scrubber/mobileMetadata.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "import": "import { Scrubber } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx", - "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Point", - "url": "/components/graphs/Point/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Scrubber/webMetadata.json b/apps/docs/docs/components/graphs/Scrubber/webMetadata.json deleted file mode 100644 index 88326f55b9..0000000000 --- a/apps/docs/docs/components/graphs/Scrubber/webMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { Scrubber } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/scrubber/Scrubber.tsx", - "description": "An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "Point", - "url": "/components/graphs/Point/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Sparkline/mobileMetadata.json b/apps/docs/docs/components/graphs/Sparkline/mobileMetadata.json deleted file mode 100644 index 388220795d..0000000000 --- a/apps/docs/docs/components/graphs/Sparkline/mobileMetadata.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "import": "import { Sparkline } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/Sparkline.tsx", - "description": "A small line chart component for displaying data trends.", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineGradient", - "url": "/components/graphs/SparklineGradient/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "react-native-svg", - "version": "^14.1.0" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/Sparkline/webMetadata.json b/apps/docs/docs/components/graphs/Sparkline/webMetadata.json deleted file mode 100644 index 9dbfed6eb2..0000000000 --- a/apps/docs/docs/components/graphs/Sparkline/webMetadata.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "import": "import { Sparkline } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/Sparkline.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractive--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "A small line chart component for displaying data trends.", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineGradient", - "url": "/components/graphs/SparklineGradient/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineGradient/_mobileExamples.mdx b/apps/docs/docs/components/graphs/SparklineGradient/_mobileExamples.mdx deleted file mode 100644 index 4ff8f46ecf..0000000000 --- a/apps/docs/docs/components/graphs/SparklineGradient/_mobileExamples.mdx +++ /dev/null @@ -1,77 +0,0 @@ -Expands upon the [Sparkline](/components/graphs/Sparkline) component to provide a gradient stroke. However, for dark mode we disable the gradient effect. These are typically used at a larger size for portfolio charts or on detail Asset pages. - -### Dynamic path colors - -```jsx -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - return ( - - {assetColors.map((color) => ( - - ))} - - ); -} -``` - -### Dynamic background colors - -```jsx -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - return ( - - {assetColors.map((background) => ( - - - - ))} - - ); -} -``` - -### y axis scaling - -```jsx -function Example() { - const yAxisScalingFactor = 0.2; - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices, yAxisScalingFactor }); - return ( - - - Scale {yAxisScalingFactor} - - - - ); -} -``` - -### Sparkline fill - -```jsx -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - const area = useSparklineArea({ ...dimensions, data: prices }); - return ( - - {assetColors.map((color) => ( - - - - ))} - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/SparklineGradient/_webExamples.mdx b/apps/docs/docs/components/graphs/SparklineGradient/_webExamples.mdx deleted file mode 100644 index 82757caacb..0000000000 --- a/apps/docs/docs/components/graphs/SparklineGradient/_webExamples.mdx +++ /dev/null @@ -1,77 +0,0 @@ -Expands upon the [Sparkline](/components/graphs/Sparkline) component to provide a gradient stroke. However, for dark mode we disable the gradient effect. These are typically used at a larger size for portfolio charts or on detail Asset pages. - -### Dynamic path colors - -```jsx live -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - return ( - - {assetColors.map((color) => ( - - ))} - - ); -} -``` - -### Dynamic background colors - -```jsx live -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - return ( - - {assetColors.map((background) => ( - - - - ))} - - ); -} -``` - -### y axis scaling - -```jsx live -function Example() { - const yAxisScalingFactor = 0.2; - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices, yAxisScalingFactor }); - return ( - - - Scale {yAxisScalingFactor} - - - - ); -} -``` - -### Sparkline fill - -```jsx live -function Example() { - const dimensions = { width: 400, height: 200 }; - const path = useSparklinePath({ ...dimensions, data: prices }); - const area = useSparklineArea({ ...dimensions, data: prices }); - return ( - - {assetColors.map((color) => ( - - - - ))} - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/SparklineGradient/mobileMetadata.json b/apps/docs/docs/components/graphs/SparklineGradient/mobileMetadata.json deleted file mode 100644 index e1d3c24105..0000000000 --- a/apps/docs/docs/components/graphs/SparklineGradient/mobileMetadata.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "import": "import { SparklineGradient } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx", - "description": "A small line chart component with gradient fill below the line.", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "Sparkline", - "url": "/components/graphs/Sparkline/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "react-native-svg", - "version": "^14.1.0" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineGradient/webMetadata.json b/apps/docs/docs/components/graphs/SparklineGradient/webMetadata.json deleted file mode 100644 index 6df916f523..0000000000 --- a/apps/docs/docs/components/graphs/SparklineGradient/webMetadata.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "import": "import { SparklineGradient } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/SparklineGradient.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparkline--sparkline-gradient", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "A small line chart component with gradient fill below the line.", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "Sparkline", - "url": "/components/graphs/Sparkline/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/_mobileExamples.mdx b/apps/docs/docs/components/graphs/SparklineInteractive/_mobileExamples.mdx deleted file mode 100644 index a53c71a29d..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractive/_mobileExamples.mdx +++ /dev/null @@ -1,106 +0,0 @@ -### Default usage - -```jsx - - - -``` - -### Fill Type - -The fill will be added by default with a gradient style. You can set `fillType="dotted"` to get a dotted gradient fill. - -```jsx - - - -``` - -### Compact - -```jsx - - - -``` - -### Hide period selector - -```jsx - - - -``` - -### Scaling Factor - -The scaling factor is usually used when you want to show less variance in the chart. An example of this is a stable coin that doesn't change price by more than a few cents. - -```jsx - - - -``` - -### With header - -```jsx - - - -``` - -### Custom hover data - -```jsx - - - -``` - -### Period selector placement - -`periodSelectorPlacement` can be used to place the period selector in different positions (`above` or `below`). - -```jsx - - - -``` - -### Custom styles - -You can also provide custom styles, such as to remove bottom padding from the header. - -```jsx - - - -``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/_webExamples.mdx b/apps/docs/docs/components/graphs/SparklineInteractive/_webExamples.mdx deleted file mode 100644 index 0db569a3bc..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractive/_webExamples.mdx +++ /dev/null @@ -1,106 +0,0 @@ -### Default usage - -```jsx live - - - -``` - -### Fill Type - -The fill will be added by default with a gradient style. You can set `fillType="dotted"` to get a dotted gradient fill. - -```jsx live - - - -``` - -### Compact - -```jsx live - - - -``` - -### Hide period selector - -```jsx live - - - -``` - -### Scaling Factor - -The scaling factor is usually used when you want to show less variance in the chart. An example of this is a stable coin that doesn't change price by more than a few cents. - -```jsx live - - - -``` - -### With header - -```jsx live - - - -``` - -### Custom hover data - -```jsx live - - - -``` - -### Period selector placement - -`periodSelectorPlacement` can be used to place the period selector in different positions (`above` or `below`). - -```jsx live - - - -``` - -### Custom styles - -You can also provide custom styles, such as to remove any horizontal padding from the header. - -```jsx live - - - -``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/mobileMetadata.json b/apps/docs/docs/components/graphs/SparklineInteractive/mobileMetadata.json deleted file mode 100644 index 69d429c4bb..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractive/mobileMetadata.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "import": "import { SparklineInteractive } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "The SparklineInteractive is used to display a Sparkline that has multiple time periods", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineInteractiveHeader", - "url": "/components/graphs/SparklineInteractiveHeader/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "react-native-svg", - "version": "^14.1.0" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineInteractive/webMetadata.json b/apps/docs/docs/components/graphs/SparklineInteractive/webMetadata.json deleted file mode 100644 index fce01ec014..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractive/webMetadata.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "import": "import { SparklineInteractive } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractive--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "The SparklineInteractive is used to display a Sparkline that has multiple time periods", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineInteractiveHeader", - "url": "/components/graphs/SparklineInteractiveHeader/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_mobileExamples.mdx b/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_mobileExamples.mdx deleted file mode 100644 index 5e4d6bc492..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_mobileExamples.mdx +++ /dev/null @@ -1,49 +0,0 @@ -### Default usage - -```jsx - - - -``` - -### Fill - -The fill will be added by default - -```jsx - - - -``` - -### Compact - -```jsx - - - -``` - -### Custom Label - -```jsx - - - - - CustomHeader - - - } - /> - -``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_webExamples.mdx b/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_webExamples.mdx deleted file mode 100644 index 1107e78033..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/_webExamples.mdx +++ /dev/null @@ -1,53 +0,0 @@ -:::tip Accessibility tip -When possible combining content that is contextually related benefits screen reader users. The interactive header within Sparkline is one of these moments. Use an accessibilityLabel prop or aria-label to set the entire context of the interactive header. This way screen reader users will hear the asset name, price, and direction all in one sentence. -::: - -### Default usage - -```jsx live - - - -``` - -### Fill - -The fill will be added by default - -```jsx live - - - -``` - -### Compact - -```jsx live - - - -``` - -### Custom Label - -```jsx live - - - - - CustomHeader - - - } - /> - -``` diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/mobileMetadata.json b/apps/docs/docs/components/graphs/SparklineInteractiveHeader/mobileMetadata.json deleted file mode 100644 index 7daf31bd8a..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/mobileMetadata.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "import": "import { SparklineInteractiveHeader } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "The SparklineInteractiveHeader is used to display chart information that changes over time", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineInteractive", - "url": "/components/graphs/SparklineInteractive/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "react-native-svg", - "version": "^14.1.0" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/webMetadata.json b/apps/docs/docs/components/graphs/SparklineInteractiveHeader/webMetadata.json deleted file mode 100644 index a7c88863b3..0000000000 --- a/apps/docs/docs/components/graphs/SparklineInteractiveHeader/webMetadata.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "import": "import { SparklineInteractiveHeader } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/sparkline/sparkline-interactive-header/SparklineInteractiveHeader.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/visualization-sparklineinteractiveheader--default", - "figma": "https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=155-12194", - "description": "The SparklineInteractiveHeader is used to display chart information that changes over time", - "warning": "Sparkline components are deprecated. Please use LineChart instead.", - "relatedComponents": [ - { - "label": "SparklineInteractive", - "url": "/components/graphs/SparklineInteractive/" - }, - { - "label": "LineChart", - "url": "/components/graphs/LineChart/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx b/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx deleted file mode 100644 index 5f6e0380b2..0000000000 --- a/apps/docs/docs/components/graphs/XAxis/_mobileExamples.mdx +++ /dev/null @@ -1,715 +0,0 @@ -## Basic Example - -The XAxis component provides a horizontal axis for charts with automatic tick generation and labeling. - -```jsx - - - - - -``` - -## Axis Config - -Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. - -### Scale Type - -XAxis supports `linear` (default), `log`, and `band` scale types. -`linear` and `log` are numeric scales while `band` is a categorical scale. -`band` scale is required for bar charts. - -```jsx - - - - -``` - -### Domain - -An axis's domain is the range of values that the axis will display. -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx - ({ min: min - 5, max: max + 5 }), - }} -> - - - -``` - -#### Domain Limit - -For numeric scales, you can set the domain limit to `nice` or `strict` (default for XAxis). `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. - -### Range - -An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. - -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx - ({ min, max: max - 64 }), - }} -> - - -``` - -### Data - -Data sets x values for the axis. - -#### String Data - -Using string data will allow you to set string x values for each data point. - -```jsx - - - - -``` - -#### Number Data - -Using number data with a numeric scale will allow you to adjust the x values for each data point. - -```jsx - - - -``` - -### Category Padding - -For band scales, you can set the category padding to adjust the spacing between categories. The default is 0.1. This is a value between 0 and 1, where 0.1 = 10% spacing. - -```jsx - -``` - -## Axis Props - -Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. - -### Position - -You can set the position of an axis to `top` or `bottom` (default). - -```jsx -function XAxisPositionExample() { - const theme = useTheme(); - const lineA = [5, 5, 10, 90, 85, 70, 30, 25, 25]; - const lineB = [90, 85, 70, 25, 23, 40, 45, 40, 50]; - - const timeData = useMemo( - () => - [ - new Date(2023, 7, 31), - new Date(2023, 7, 31, 12), - new Date(2023, 8, 1), - new Date(2023, 8, 1, 12), - new Date(2023, 8, 2), - new Date(2023, 8, 2, 12), - new Date(2023, 8, 3), - new Date(2023, 8, 3, 12), - new Date(2023, 8, 4), - ].map((d) => d.getTime()), - [], - ); - - const dateFormatter = useCallback( - (index: number) => { - return new Date(timeData[index]).toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - }); - }, - [timeData], - ); - - const timeOfDayFormatter = useCallback( - (index: number) => { - return new Date(timeData[index]).toLocaleTimeString('en-US', { - hour: '2-digit', - }); - }, - [timeData], - ); - - const timeOfDayTicks = useMemo(() => { - return timeData.map((d, index) => index); - }, [timeData]); - - const dateTicks = useMemo(() => { - return timeData.map((d, index) => index).filter((d) => d % 2 === 0); - }, [timeData]); - - return ( - - - - - - ); -}; -``` - -### Grid - -You can show grid lines at each tick position using the `showGrid` prop. - -```jsx -function XAxisGridExample() { - const [showGrid, setShowGrid] = useState(true); - return ( - - - setShowGrid(!showGrid)}> - Show Grid - - - - - - - - - ); -} -``` - -You can also customize the grid lines using the `GridLineComponent` prop. - -```jsx -function CustomGridLineExample() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - return ( - - - - - - ); -} -``` - -On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. - -Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. - -```jsx -function BandGridPlacement() { - const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState('edges'); - - return ( - - - - - ); -} -``` - -### Line - -You can show the axis line using the `showLine` prop. - -```jsx -function XAxisLineExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -You can also customize the axis line using the `styles` props. - -```jsx -function XAxisLineStylesExample() { - const theme = useTheme(); - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -### Size - -The `size` prop sets the size of the axis in pixels. The default is 32 for XAxis, but can be adjusted to fit the size of your data. - -```jsx - - - - - -``` - -### Ticks - -You can use the `ticks`, `requestedTickCount`, and `tickInterval` (default for XAxis) props to control the number and placement of ticks on the XAxis. - -`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. - -```jsx - - - - - -``` - -Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. - -```jsx - - - - - -``` - -`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. - -```jsx - - - - - -``` - -### Tick Marks - -You can show tick marks on the axis using the `showTickMarks` prop. You can also customize the tick mark size using the `tickMarkSize` prop. - -```jsx -function XAxisTickMarksExample() { - const [showTickMarks, setShowTickMarks] = useState(true); - return ( - - - setShowTickMarks(!showTickMarks)}> - Show Tick Marks - - - - - - - - - ); -} -``` - -On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. - -Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. - -```jsx -function BandTickMarkPlacement() { - const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState('middle'); - - return ( - - - - - ); -} -``` - -### Tick Labels - -You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. - -```jsx - - `Day of ${value}`} /> - - - -``` - -If no data is set for the axis, it will receive the regular number value of the tick, which is normally the index corresponding to each value in the series. - -```jsx - - value * 2} /> - - - -``` - -### Label - -You can add a label to the axis using the `label` prop. - -```jsx - - - - - - -``` - -#### Custom Tick Labels - -You can create custom tick label components using the `TickLabelComponent` prop for advanced styling that works cross-platform. - -```jsx -function CustomTickLabelExample() { - const theme = useTheme(); - - const CustomXAxisTickLabel = useCallback( - (props) => , - [theme], - ); - - return ( - - - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx b/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx deleted file mode 100644 index 9618219610..0000000000 --- a/apps/docs/docs/components/graphs/XAxis/_webExamples.mdx +++ /dev/null @@ -1,751 +0,0 @@ -## Basic Example - -The XAxis component provides a horizontal axis for charts with automatic tick generation and labeling. - -```jsx live - - - - - -``` - -## Axis Config - -Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. - -### Scale Type - -XAxis supports `linear` (default), `log`, and `band` scale types. -`linear` and `log` are numeric scales while `band` is a categorical scale. -`band` scale is required for bar charts. - -```jsx live - - - - -``` - -### Domain - -An axis's domain is the range of values that the axis will display. -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx live - ({ min: min - 5, max: max + 5 }), - }} -> - - - -``` - -#### Domain Limit - -For numeric scales, you can set the domain limit to `nice` or `strict` (default for XAxis). `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. - -### Range - -An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. - -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx live - ({ min, max: max - 64 }), - }} -> - - -``` - -### Data - -Data sets x values for the axis. - -#### String Data - -Using string data will allow you to set string x values for each data point. - -```jsx live - - - - -``` - -#### Number Data - -Using number data with a numeric scale will allow you to adjust the x values for each data point. - -```jsx live - - - -``` - -### Category Padding - -For band scales, you can set the category padding to adjust the spacing between categories. The default is 0.1. This is a value between 0 and 1, where 0.1 = 10% spacing. - -```jsx live - -``` - -## Axis Props - -Properties related to the visual appearance of the XAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. - -### Position - -You can set the position of an axis to `top` or `bottom` (default). - -```jsx live -function XAxisPositionExample() { - const lineA = [5, 5, 10, 90, 85, 70, 30, 25, 25]; - const lineB = [90, 85, 70, 25, 23, 40, 45, 40, 50]; - - const timeData = useMemo( - () => - [ - new Date(2023, 7, 31), - new Date(2023, 7, 31, 12), - new Date(2023, 8, 1), - new Date(2023, 8, 1, 12), - new Date(2023, 8, 2), - new Date(2023, 8, 2, 12), - new Date(2023, 8, 3), - new Date(2023, 8, 3, 12), - new Date(2023, 8, 4), - ].map((d) => d.getTime()), - [], - ); - - const dateFormatter = useCallback( - (index: number) => { - return new Date(timeData[index]).toLocaleDateString('en-US', { - month: '2-digit', - day: '2-digit', - }); - }, - [timeData], - ); - - const timeOfDayFormatter = useCallback( - (index: number) => { - return new Date(timeData[index]).toLocaleTimeString('en-US', { - hour: '2-digit', - }); - }, - [timeData], - ); - - const timeOfDayTicks = useMemo(() => { - return timeData.map((d, index) => index); - }, [timeData]); - - const dateTicks = useMemo(() => { - return timeData.map((d, index) => index).filter((d) => d % 2 === 0); - }, [timeData]); - - return ( - - - - - - ); -}; -``` - -### Grid - -You can show grid lines at each tick position using the `showGrid` prop. - -```jsx live -function XAxisGridExample() { - const [showGrid, setShowGrid] = useState(true); - return ( - - - setShowGrid(!showGrid)}> - Show Grid - - - - - - - - - ); -} -``` - -You can also customize the grid lines using the `GridLineComponent` prop. - -```jsx live -function CustomGridLineExample() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - return ( - - - - - - ); -} -``` - -On band scales, you can also use `bandGridLinePlacement` to control where grid lines appear relative to each band. - -Using edges will place a grid line at the start of each band, plus a grid line at the end of the last band. - -```jsx live -function BandGridPlacement() { - const bandGridLinePlacements = [ - { id: 'edges', label: 'Edges' }, - { id: 'start', label: 'Start' }, - { id: 'middle', label: 'Middle' }, - { id: 'end', label: 'End' }, - ]; - const [selectedBandGridPlacement, setSelectedBandGridPlacement] = useState( - bandGridLinePlacements[0], - ); - - return ( - - - - Band Grid Placement - - - - - - - - - ); -} -``` - -### Line - -You can show the axis line using the `showLine` prop. - -```jsx live -function XAxisLineExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -You can also customize the axis line using the `classNames` and `styles` props. - -```jsx live -function XAxisLineStylesExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -### Size - -The `size` prop sets the size of the axis in pixels. The default is 32 for XAxis, but can be adjusted to fit the size of your data. - -```jsx live - - - - - -``` - -### Ticks - -You can use the `ticks`, `requestedTickCount`, and `tickInterval` (default for XAxis) props to control the number and placement of ticks on the XAxis. - -`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. - -```jsx live - - - - - -``` - -Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. - -```jsx live - - - - - -``` - -`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. - -```jsx live - - - - - -``` - -### Tick Marks - -You can show tick marks on the axis using the `showTickMarks` prop. You can also customize the tick mark size using the `tickMarkSize` prop. - -```jsx live -function XAxisTickMarksExample() { - const [showTickMarks, setShowTickMarks] = useState(true); - return ( - - - setShowTickMarks(!showTickMarks)}> - Show Tick Marks - - - - - - - - - ); -} -``` - -On band scales, you can also use `bandTickMarkPlacement` to control where tick marks appear relative to each band. - -Using edges will place a tick mark at the start of each band, plus a tick mark at the end of the last band. - -```jsx live -function BandTickMarkPlacement() { - const bandTickMarkPlacements = [ - { id: 'middle', label: 'Middle' }, - { id: 'edges', label: 'Edges' }, - { id: 'start', label: 'Start' }, - { id: 'end', label: 'End' }, - ]; - const [selectedBandTickMarkPlacement, setSelectedBandTickMarkPlacement] = useState( - bandTickMarkPlacements[0], - ); - - return ( - - - - Band Tick Mark Placement - - - - - - - - - ); -} -``` - -### Tick Labels - -You can customize the tick labels using the `tickLabelFormatter` prop. It will receive the x data value of the tick. Meaning, if data is provided for the axis, it will receive the string label for the tick. - -```jsx live - - `Day of ${value}`} /> - - - -``` - -If no data is set for the axis, it will receive the regular number value of the tick, which is normally the index corresponding to each value in the series. - -```jsx live - - value * 2} /> - - - -``` - -### Label - -You can add a label to the axis using the `label` prop. - -```jsx live - - - - - - -``` - -#### Custom Tick Labels - -You can create custom tick label components using the `TickLabelComponent` prop for advanced styling that works cross-platform. - -```jsx live -function CustomTickLabelExample() { - const CustomXAxisTickLabel = useCallback( - (props) => , - [], - ); - - return ( - - - - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json b/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json deleted file mode 100644 index a3ee7c9bca..0000000000 --- a/apps/docs/docs/components/graphs/XAxis/mobileMetadata.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "import": "import { XAxis } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/XAxis.tsx", - "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/XAxis/webMetadata.json b/apps/docs/docs/components/graphs/XAxis/webMetadata.json deleted file mode 100644 index 8a7e9e83df..0000000000 --- a/apps/docs/docs/components/graphs/XAxis/webMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { XAxis } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/XAxis.tsx", - "description": "A horizontal axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting and data domains.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "YAxis", - "url": "/components/graphs/YAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/YAxis/_mobileExamples.mdx b/apps/docs/docs/components/graphs/YAxis/_mobileExamples.mdx deleted file mode 100644 index d264412e0a..0000000000 --- a/apps/docs/docs/components/graphs/YAxis/_mobileExamples.mdx +++ /dev/null @@ -1,585 +0,0 @@ -## Basic Example - -The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. - -```jsx - - - - - -``` - -## Axis Config - -Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. - -### Scale Type - -YAxis supports `linear` (default) and `log` scale types. Both `linear` and `log` are numeric scales. - -```jsx -function ScaleTypeExample() { - const theme = useTheme(); - return ( - - - value.toLocaleString()} - /> - - ); -} -``` - -### Domain - -An axis's domain is the range of values that the axis will display. -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx - ({ min: min - 50, max: max + 50 }), - }} -> - - - -``` - -#### Domain Limit - -You can set the domain limit to `nice` (default for YAxis) or `strict`. `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. - -### Range - -An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. - -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx - ({ min: min + 96, max: max - 96 }), - }} -> - - -``` - -## Axis Props - -Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. - -### Position - -You can set the position of an axis to `left` or `right` (default). - -```jsx - - - - - - - -``` - -### Grid - -You can show grid lines at each tick position using the `showGrid` prop. - -```jsx -function YAxisGridExample() { - const [showGrid, setShowGrid] = useState(true); - return ( - - - setShowGrid(!showGrid)}> - Show Grid - - - - - - - - - ); -} -``` - -You can also customize the grid lines using the `GridLineComponent` prop. - -```jsx -function CustomGridLineExample() { - const theme = useTheme(); - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); - const gains = [ - 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, - 0, 0, 0, - ]; - const losses = [ - -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, - 0, 0, 0, -12, -10, - ]; - const series = [ - { id: 'gains', data: gains, color: theme.color.fgPositive, stackId: 'bars' }, - { id: 'losses', data: losses, color: theme.color.fgNegative, stackId: 'bars' }, - ]; - - return ( - - - `$${value}M`} - /> - - - - ); -}; -``` - -### Line - -You can show the axis line using the `showLine` prop. - -```jsx -function YAxisLineExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -You can also customize the axis line using the `classNames` and `styles` props. - -```jsx -function YAxisLineStylesExample() { - const theme = useTheme(); - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -### Size - -The `size` prop sets the size of the axis in pixels. The default is 44 for YAxis, but can be adjusted to fit the size of your data. - -```jsx -function YAxisSizeExample() { - const theme = useTheme(); - return ( - - - value.toLocaleString()} - /> - - ); -} -``` - -### Ticks - -You can use the `ticks`, `requestedTickCount` (default for YAxis), and `tickInterval` props to control the number and placement of ticks on the YAxis. - -`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. - -```jsx - - - - - -``` - -Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. - -This is the default behavior for YAxis, and defaults to `5`. - -```jsx - - - - - -``` - -`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. - -```jsx - - - - - -``` - -### Tick Marks - -You can show tick marks on the axis using the `showTickMarks` prop. -You can also customize the tick mark size using the `tickMarkSize` prop. - -```jsx -function YAxisTickMarksExample() { - const [showTickMarks, setShowTickMarks] = useState(true); - return ( - - - setShowTickMarks(!showTickMarks)}> - Show Tick Marks - - - - - - - - - ); -} -``` - -### Tick Labels - -You can customize the tick labels using the `tickLabelFormatter` prop. - -```jsx - - `$${value}`} /> - - - -``` - -### Label - -You can add a label to the axis using the `label` prop. - -```jsx - - `$${value}`} /> - - - -``` - -#### Custom Tick Labels - -You can create custom tick label components using the `TickLabelComponent` prop for advanced styling and positioning that works cross-platform. - -```jsx -function CustomTickLabelExample() { - const CustomYAxisTickLabel = useCallback( - (props) => , - [], - ); - - return ( - - `$${value}`} - TickLabelComponent={CustomYAxisTickLabel} - /> - - - - ); -} -``` - -## Customization - -### Multiple Y Axes - -```jsx -function MultipleYAxesExample() { - const theme = useTheme(); - return ( - - - - `$${value}k`} - /> - `${value}%`} - /> - - - - - - Revenue ($) - - - - Profit Margin (%) - - - - ); -} -``` diff --git a/apps/docs/docs/components/graphs/YAxis/_webExamples.mdx b/apps/docs/docs/components/graphs/YAxis/_webExamples.mdx deleted file mode 100644 index 6da576473b..0000000000 --- a/apps/docs/docs/components/graphs/YAxis/_webExamples.mdx +++ /dev/null @@ -1,548 +0,0 @@ -## Basic Example - -The YAxis component provides a vertical axis for charts with automatic tick generation and labeling. - -```jsx live - - - - - -``` - -## Axis Config - -Properties related to the scale of an axis are set on the Chart component. This includes `scaleType`, `domain`, `domainLimit`, `range`, `data`, and `categoryPadding`. - -### Scale Type - -YAxis supports `linear` (default) and `log` scale types. Both `linear` and `log` are numeric scales. - -```jsx live - - - value.toLocaleString()} - /> - -``` - -### Domain - -An axis's domain is the range of values that the axis will display. -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx live - ({ min: min - 50, max: max + 50 }), - }} -> - - - -``` - -#### Domain Limit - -You can set the domain limit to `nice` (default for YAxis) or `strict`. `nice` will round the domain to human-friendly values, while `strict` will use the exact min/max values from the data. See [d3-scale](https://d3js.org/d3-scale/linear#linear_nice) for more details. - -### Range - -An axis's range is the range of values that the axis will display in pixels. This is most useful for adjusting the sizing of the data inside of the chart's drawing area. - -You can pass in either an object (AxisBounds) with `min` and `max` properties (both optional), or a function that receives initial `AxisBounds` and returns an adjusted `AxisBounds`. - -```jsx live - ({ min: min + 96, max: max - 96 }), - }} -> - - -``` - -## Axis Props - -Properties related to the visual appearance of the YAxis are set on the component itself. This includes `position`, `showGrid`, `showLine`, `showTickMarks`, `size`, `tickInterval`, `ticks`, `tickLabelFormatter`, and `tickMarkSize`. - -### Position - -You can set the position of an axis to `left` or `right` (default). - -```jsx live - - - - - - - -``` - -### Grid - -You can show grid lines at each tick position using the `showGrid` prop. - -```jsx live -function YAxisGridExample() { - const [showGrid, setShowGrid] = useState(true); - return ( - - - setShowGrid(!showGrid)}> - Show Grid - - - - - - - - - ); -} -``` - -You can also customize the grid lines using the `GridLineComponent` prop. - -```jsx live -function CustomGridLineExample() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); - const gains = [ - 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, - 0, 0, 0, - ]; - const losses = [ - -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, - 0, 0, 0, -12, -10, - ]; - const series = [ - { id: 'gains', data: gains, color: 'var(--color-fgPositive)', stackId: 'bars' }, - { id: 'losses', data: losses, color: 'var(--color-fgNegative)', stackId: 'bars' }, - ]; - - return ( - - - `$${value}M`} - /> - - - - ); -}; -``` - -### Line - -You can show the axis line using the `showLine` prop. - -```jsx live -function YAxisLineExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -You can also customize the axis line using the `classNames` and `styles` props. - -```jsx live -function YAxisLineStylesExample() { - const [showLine, setShowLine] = useState(true); - return ( - - - setShowLine(!showLine)}> - Show Line - - - - - - - - - ); -} -``` - -### Size - -The `size` prop sets the size of the axis in pixels. The default is 44 for YAxis, but can be adjusted to fit the size of your data. - -```jsx live - - - value.toLocaleString()} - /> - -``` - -### Ticks - -You can use the `ticks`, `requestedTickCount` (default for YAxis), and `tickInterval` props to control the number and placement of ticks on the YAxis. - -`ticks` accepts an array of numbers, which corresponds to the values of that axis that you would like to display ticks for. - -```jsx live - - - - - -``` - -Using `requestedTickCount` will use [D3's ticks function](https://d3js.org/d3-array/ticks#ticks) to determine the number and placement of ticks. Note that this count is not guaranteed to be respected. - -This is the default behavior for YAxis, and defaults to `5`. - -```jsx live - - - - - -``` - -`tickInterval`, which accepts a number for the gap between ticks in pixels, will measure the available space and try to create evenly spaced ticks. It will always include the first and last values of the domain. - -```jsx live - - - - - -``` - -### Tick Marks - -You can show tick marks on the axis using the `showTickMarks` prop. -You can also customize the tick mark size using the `tickMarkSize` prop. - -```jsx live -function YAxisTickMarksExample() { - const [showTickMarks, setShowTickMarks] = useState(true); - return ( - - - setShowTickMarks(!showTickMarks)}> - Show Tick Marks - - - - - - - - - ); -} -``` - -### Tick Labels - -You can customize the tick labels using the `tickLabelFormatter` prop. - -```jsx live - - `$${value}`} /> - - - -``` - -### Label - -You can add a label to the axis using the `label` prop. - -```jsx live - - `$${value}`} /> - - - -``` - -#### Custom Tick Labels - -You can create custom tick label components using the `TickLabelComponent` prop for advanced styling and positioning that works cross-platform. - -```jsx live -function CustomTickLabelExample() { - const CustomYAxisTickLabel = useCallback( - (props) => , - [], - ); - - return ( - - `$${value}`} - TickLabelComponent={CustomYAxisTickLabel} - /> - - - - ); -} -``` - -## Customization - -### Multiple Y Axes - -```jsx live - - - `$${value}k`} - /> - `${value}%`} - /> - - -``` diff --git a/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json b/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json deleted file mode 100644 index e9882e3a3d..0000000000 --- a/apps/docs/docs/components/graphs/YAxis/mobileMetadata.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "import": "import { YAxis } from '@coinbase/cds-mobile-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/axis/YAxis.tsx", - "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - } - ], - "dependencies": [ - { - "name": "@shopify/react-native-skia", - "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" - } - ] -} diff --git a/apps/docs/docs/components/graphs/YAxis/webMetadata.json b/apps/docs/docs/components/graphs/YAxis/webMetadata.json deleted file mode 100644 index 19bca7ff2a..0000000000 --- a/apps/docs/docs/components/graphs/YAxis/webMetadata.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "import": "import { YAxis } from '@coinbase/cds-web-visualization'", - "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/axis/YAxis.tsx", - "description": "A vertical axis component for CartesianChart. Displays tick marks, labels, gridlines, and supports custom formatting, positioning, and data domains.", - "relatedComponents": [ - { - "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" - }, - { - "label": "XAxis", - "url": "/components/graphs/XAxis/" - } - ], - "dependencies": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] -} diff --git a/apps/docs/docs/components/inputs/Button/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Button/_mobileExamples.mdx index 45725131f0..024ed14fa3 100644 --- a/apps/docs/docs/components/inputs/Button/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Button/_mobileExamples.mdx @@ -56,7 +56,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 @@ -117,6 +144,25 @@ Use `block` to make the button expand to fill its container width. ``` +## Typography + +Button forwards text-related font props to the internal `Text` label, so you can customize typography without rendering a custom child. + +```jsx + + + + + + +``` + ## Icons ### End Icon diff --git a/apps/docs/docs/components/inputs/Button/_webExamples.mdx b/apps/docs/docs/components/inputs/Button/_webExamples.mdx index f77d75831d..4100dc2206 100644 --- a/apps/docs/docs/components/inputs/Button/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Button/_webExamples.mdx @@ -59,7 +59,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() { @@ -131,6 +160,25 @@ Use `block` to make the button expand to fill its container width. ``` +## Typography + +Button forwards text-related font props to the internal `Text` label, so you can customize typography without rendering a custom child. + +```jsx live + + + + + + +``` + ## Icons ### End Icon diff --git a/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json b/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json index bef7136e94..07fa93254d 100644 --- a/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json @@ -24,5 +24,6 @@ "label": "Switch", "url": "/components/inputs/Switch/?platform=mobile" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxCell/_mobileStyles.mdx b/apps/docs/docs/components/inputs/CheckboxCell/_mobileStyles.mdx new file mode 100644 index 0000000000..127a75d6d2 --- /dev/null +++ b/apps/docs/docs/components/inputs/CheckboxCell/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/controls/CheckboxCell/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/CheckboxCell/_webStyles.mdx b/apps/docs/docs/components/inputs/CheckboxCell/_webStyles.mdx new file mode 100644 index 0000000000..fb3ae13937 --- /dev/null +++ b/apps/docs/docs/components/inputs/CheckboxCell/_webStyles.mdx @@ -0,0 +1,22 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { CheckboxCell } from '@coinbase/cds-web/controls'; + +import webStylesData from ':docgen/web/controls/CheckboxCell/styles-data'; + +## Explorer + + + {(classNames) => ( + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/CheckboxCell/index.mdx b/apps/docs/docs/components/inputs/CheckboxCell/index.mdx index 36f33a28ad..82e898f038 100644 --- a/apps/docs/docs/components/inputs/CheckboxCell/index.mdx +++ b/apps/docs/docs/components/inputs/CheckboxCell/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/controls/CheckboxCell/toc-props'; import mobilePropsToc from ':docgen/mobile/controls/CheckboxCell/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -22,12 +24,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/inputs/CheckboxCell/mobileMetadata.json b/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json index 3a118b0d5e..8bb9c0ff71 100644 --- a/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json @@ -16,5 +16,6 @@ "label": "RadioCell", "url": "/components/inputs/RadioCell/?platform=mobile" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json b/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json index 2a66b7bfdf..cb8ba03f5d 100644 --- a/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json @@ -17,5 +17,11 @@ "label": "RadioCell", "url": "/components/inputs/RadioCell/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json index f8f38cff37..e4e1e13c63 100644 --- a/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json @@ -20,5 +20,6 @@ "label": "RadioGroup", "url": "/components/inputs/RadioGroup" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json b/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json index bf0b47a771..4300e5ebb7 100644 --- a/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json @@ -21,5 +21,11 @@ "label": "RadioGroup", "url": "/components/inputs/RadioGroup" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx index 56c6a1baf7..b0a019db1b 100644 --- a/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Chip/_mobileExamples.mdx @@ -1,57 +1,43 @@ -### Basic usage +## Basics + +Render a Chip with text. Without `onPress`, it displays as a static pill. With `onPress`, it becomes a pressable element. Use `disabled` to prevent interaction on an otherwise interactive chip. ```tsx function Example() { return ( - + Basic Chip - Disabled Chip alert('Pressed!')}>Interactive Chip + alert('Pressed!')}> + Disabled Chip + ); } ``` -### Variants - -```tsx -function Example() { - return ( - - - Compact - Inverted - - Long text that should truncate nicely - - - - ); -} -``` - -### With Icons and Images +## Icons and Images ```tsx function Example() { return ( - + }>With Start Icon - }>With End Icon - } end={}> + }>With End Icon + } end={}> Both Icons - + } + start={} onPress={() => alert('BTC selected')} > BTC } + start={} onPress={() => alert('ETH selected')} > ETH @@ -61,3 +47,62 @@ function Example() { ); } ``` + +## Styling + +### Color + +Use `invertColorScheme` to invert foreground and background for emphasis. + +```tsx +function Example() { + return ( + + Default + Inverted + + ); +} +``` + +### Compact + +Use `compact` for smaller chips with reduced padding. + +```tsx +function Example() { + return ( + + Default + Compact + + ); +} +``` + +## Accessibility + +When using `onPress`, provide an `accessibilityLabel` for screen readers, especially when the label text alone is ambiguous or when the chip has non-text content. + +```tsx +function Example() { + return ( + + alert('BTC')} + start={} + > + BTC + + } + onPress={() => alert('Filter')} + > + Category + + + ); +} +``` diff --git a/apps/docs/docs/components/inputs/Chip/_mobileStyles.mdx b/apps/docs/docs/components/inputs/Chip/_mobileStyles.mdx new file mode 100644 index 0000000000..9b009bcea9 --- /dev/null +++ b/apps/docs/docs/components/inputs/Chip/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/chips/Chip/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Chip/_webExamples.mdx b/apps/docs/docs/components/inputs/Chip/_webExamples.mdx index 43fde813da..b3e1e6a89d 100644 --- a/apps/docs/docs/components/inputs/Chip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Chip/_webExamples.mdx @@ -1,57 +1,43 @@ -### Basic usage +## Basics + +Render a Chip with text. Without `onClick`, it displays as a static pill. With `onClick`, it becomes a button. Use `disabled` to prevent interaction on an otherwise interactive chip. ```tsx live function Example() { return ( - + Basic Chip - Disabled Chip alert('Clicked!')}>Interactive Chip + alert('Clicked!')}> + Disabled Chip + ); } ``` -### Variants - -```tsx live -function Example() { - return ( - - - Compact - Inverted - - Long text that should truncate nicely - - - - ); -} -``` - -### With Icons and Images +## Icons and Images ```tsx live function Example() { return ( - + }>With Start Icon - }>With End Icon - } end={}> + }>With End Icon + } end={}> Both Icons - + } + start={} onClick={() => alert('BTC selected')} > BTC } + start={} onClick={() => alert('ETH selected')} > ETH @@ -61,3 +47,62 @@ function Example() { ); } ``` + +## Styling + +### Color + +Use `invertColorScheme` to invert foreground and background for emphasis. + +```tsx live +function Example() { + return ( + + Default + Inverted + + ); +} +``` + +### Compact + +Use `compact` for smaller chips with reduced padding. + +```tsx live +function Example() { + return ( + + Default + Compact + + ); +} +``` + +## Accessibility + +When using `onClick`, provide an `accessibilityLabel` for screen readers, especially when the label text alone is ambiguous or when the chip has non-text content. + +```tsx live +function Example() { + return ( + + alert('BTC')} + start={} + > + BTC + + } + onClick={() => alert('Filter')} + > + Category + + + ); +} +``` diff --git a/apps/docs/docs/components/inputs/Chip/_webStyles.mdx b/apps/docs/docs/components/inputs/Chip/_webStyles.mdx new file mode 100644 index 0000000000..58b8216765 --- /dev/null +++ b/apps/docs/docs/components/inputs/Chip/_webStyles.mdx @@ -0,0 +1,15 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Chip } from '@coinbase/cds-web/chips'; + +import webStylesData from ':docgen/web/chips/Chip/styles-data'; + +## Explorer + + + {(classNames) => Chip} + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Chip/index.mdx b/apps/docs/docs/components/inputs/Chip/index.mdx index f9dc6644d4..bdcf8e44e4 100644 --- a/apps/docs/docs/components/inputs/Chip/index.mdx +++ b/apps/docs/docs/components/inputs/Chip/index.mdx @@ -15,6 +15,8 @@ import mobilePropsToc from ':docgen/mobile/chips/Chip/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -24,12 +26,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/inputs/Chip/mobileMetadata.json b/apps/docs/docs/components/inputs/Chip/mobileMetadata.json index bfe9675345..2540020c4f 100644 --- a/apps/docs/docs/components/inputs/Chip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Chip/mobileMetadata.json @@ -1,15 +1,23 @@ { "import": "import { Chip } from '@coinbase/cds-mobile/chips/Chip'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/chips/Chip.tsx", - "description": "A compact, interactive content element.", + "description": "A compact content element for tags, filters, and selections.", "relatedComponents": [ { "label": "InputChip", "url": "/components/inputs/InputChip/" }, + { + "label": "MediaChip", + "url": "/components/inputs/MediaChip/" + }, { "label": "SelectChip", "url": "/components/inputs/SelectChip/" + }, + { + "label": "TabbedChips", + "url": "/components/navigation/TabbedChipsAlpha/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/inputs/Chip/webMetadata.json b/apps/docs/docs/components/inputs/Chip/webMetadata.json index 80c7c72a41..5f6645061f 100644 --- a/apps/docs/docs/components/inputs/Chip/webMetadata.json +++ b/apps/docs/docs/components/inputs/Chip/webMetadata.json @@ -2,15 +2,23 @@ "import": "import { Chip } from '@coinbase/cds-web/chips/Chip'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/chips/Chip.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-chips-chip--default", - "description": "A compact, interactive content element.", + "description": "A compact content element for tags, filters, and selections.", "relatedComponents": [ { "label": "InputChip", "url": "/components/inputs/InputChip/" }, + { + "label": "MediaChip", + "url": "/components/inputs/MediaChip/" + }, { "label": "SelectChip", "url": "/components/inputs/SelectChip/" + }, + { + "label": "TabbedChips", + "url": "/components/navigation/TabbedChipsAlpha/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx index 27064257f6..837d4bb3c8 100644 --- a/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Combobox/_mobileExamples.mdx @@ -1,10 +1,32 @@ -## A note on search logic +## Basics -We use [fuse.js](https://www.fusejs.io/) to power the fuzzy search logic for Combobox. You can override this search logic with your own using the `filterFunction` prop. +To start, you can provide a label, an array of options, control state. -## Multi-Select +```jsx +function SingleSelect() { + const singleSelectOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + ]; + const [value, setValue] = useState(null); -Basic multi-selection combobox with search. + return ( + + ); +} +``` + +### Multiple Selections + +You can also allow users to select multiple options with `type="multi"`. ```jsx function MultiSelect() { @@ -14,11 +36,6 @@ function MultiSelect() { { value: '3', label: 'Option 3' }, { value: '4', label: 'Option 4' }, { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, - { value: '10', label: 'Option 10' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'] }); @@ -35,85 +52,236 @@ function MultiSelect() { } ``` -## Single Select +## Search -Single selection combobox with an option to clear the current choice. +We use [fuse.js](https://www.fusejs.io/) for fuzzy search by default. You can override with `filterFunction`. ```jsx -function SingleSelect() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, +function CustomFilter() { + const cryptoOptions = [ + { value: 'btc', label: 'Bitcoin', description: 'BTC • Digital Gold' }, + { value: 'eth', label: 'Ethereum', description: 'ETH • Smart Contracts' }, + { value: 'usdc', label: 'USD Coin', description: 'USDC • Stablecoin' }, + { value: 'sol', label: 'Solana', description: 'SOL • High Performance' }, ]; - const [value, setValue] = useState(null); + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + const filterFunction = (options, searchText) => { + const search = searchText.toLowerCase().trim(); + if (!search) return options; + return options.filter((option) => { + const label = typeof option.label === 'string' ? option.label.toLowerCase() : ''; + const description = + typeof option.description === 'string' ? option.description.toLowerCase() : ''; + return label.startsWith(search) || description.startsWith(search); + }); + }; return ( ); } ``` -## Controlled Search +## Grouped -Manage the search text externally while letting the combobox stay in sync. +Display options under headers using `label` and `options`. Sort options by the same dimension you group by. ```jsx -function ControlledSearch() { +function GroupedOptions() { + const groupedOptions = [ + { + label: 'Fruits', + options: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ], + }, + { + label: 'Vegetables', + options: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli' }, + { value: 'spinach', label: 'Spinach' }, + ], + }, + ]; const { value, onChange } = useMultiSelect({ initialValue: [] }); - const [searchText, setSearchText] = useState(''); + return ( + + ); +} +``` + +## Accessibility + +Use `accessibilityLabel` and `accessibilityHint` to describe purpose and additional context. For multi-select, add hidden-selection labels so screen readers can describe +X summaries. + +```jsx +function AccessibilityProps() { + const priorityOptions = [ + { value: 'high', label: 'High Priority' }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: ['medium'] }); + + return ( + + ); +} +``` + +## Styling + +### Selection Display Limit + +Cap visible chips with `maxSelectedOptionsToShow`; the rest show as +X more. Pair with `hiddenSelectedOptionsLabel` for screen readers. + +```jsx +function LimitDisplayedSelections() { + const countryOptions = [ + { value: 'us', label: 'United States', description: 'North America' }, + { value: 'ca', label: 'Canada', description: 'North America' }, + { value: 'mx', label: 'Mexico', description: 'North America' }, + { value: 'uk', label: 'United Kingdom', description: 'Europe' }, + { value: 'fr', label: 'France', description: 'Europe' }, + { value: 'de', label: 'Germany', description: 'Europe' }, + ]; + const { value, onChange } = useMultiSelect({ + initialValue: ['us', 'ca', 'mx', 'uk'], + }); + + return ( + + ); +} +``` + +### Alignment + +Align selected values with the `align` prop. + +```jsx +function AlignmentExample() { const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ]; + const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); + + return ( + + + + + ); +} +``` + +### Borderless + +Remove the border with `bordered={false}`. + +```jsx +function BorderlessExample() { + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const [value, setValue] = useState('apple'); return ( ); } ``` -## Helper Text +### Compact -Use helper text to guide how many selections a user should make. +Use smaller sizing with `compact`. ```jsx -function HelperTextExample() { - const { value, onChange } = useMultiSelect({ initialValue: [] }); - - const teamOptions = [ - { value: 'john', label: 'John Smith', description: 'Engineering' }, - { value: 'jane', label: 'Jane Doe', description: 'Design' }, - { value: 'bob', label: 'Bob Johnson', description: 'Product' }, - { value: 'alice', label: 'Alice Williams', description: 'Engineering' }, +function CompactExample() { + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, ]; + const { value, onChange } = useMultiSelect({ initialValue: ['apple'] }); return ( @@ -121,51 +289,224 @@ function HelperTextExample() { } ``` -## Borderless +### Helper Text -You can remove the border from the combobox control by setting `bordered` to `false`. +Add guidance with `helperText`. ```jsx -function BorderlessExample() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, +function HelperTextExample() { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ]; + return ( + + ); +} +``` + +## Composed Examples + +### Country Selection + +You can include flag emoji in labels to create a country selector. + +```jsx +function CountrySelectionExample() { + const getFlagEmoji = (cc) => + cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); + + const countryOptions = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Free Solo + +You can add a dynamic option to Combobox to enable free solo where users can provide their own value. + +```jsx +function FreeSoloComboboxExample() { + const CREATE_OPTION_PREFIX = '__create__'; + + function FreeSoloCombobox({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps + }) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((o) => o.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prev) => { + const addedStillSelected = prev.filter( + (o) => !initialSet.has(o.value) && valueSet.has(o.value), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [value, freeSolo, initialOptions]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmed = searchText.trim(); + if (!trimmed) return options; + const alreadyExists = options.some( + (o) => typeof o.label === 'string' && o.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (alreadyExists) return options; + return [ + ...options, + { value: `${CREATE_OPTION_PREFIX}${trimmed}`, label: `Add "${trimmed}"` }, + ]; + }, [options, searchText, freeSolo]); + + const handleChange = useCallback( + (newValue) => { + if (!freeSolo) { + onChange(newValue); + return; + } + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((v) => String(v).startsWith(CREATE_OPTION_PREFIX)); + if (createValue) { + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const newOption = { value: newLabel.toLowerCase(), label: newLabel }; + setOptions((prev) => [...prev, newOption]); + const updatedValues = values + .filter((v) => !String(v).startsWith(CREATE_OPTION_PREFIX)) + .concat(newOption.value); + onChange(comboboxProps.type === 'multi' ? updatedValues : newOption.value); + setSearchText(''); + } else { + onChange(newValue); + } + }, + [onChange, freeSolo, comboboxProps.type], + ); + + return ( + + ); + } + + const [standardSingleValue, setStandardSingle] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingle] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, ]; - const [singleValue, setSingleValue] = useState('apple'); - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['apple'], - }); - return ( - - + + ); diff --git a/apps/docs/docs/components/inputs/Combobox/_mobileStyles.mdx b/apps/docs/docs/components/inputs/Combobox/_mobileStyles.mdx new file mode 100644 index 0000000000..bb55d68fef --- /dev/null +++ b/apps/docs/docs/components/inputs/Combobox/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/alpha/combobox/Combobox/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx b/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx index 12b61f4394..87ba5ea6fa 100644 --- a/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/Combobox/_webExamples.mdx @@ -1,159 +1,544 @@ -## A note on search logic +## Basics -We use [fuse.js](https://www.fusejs.io/) to power the fuzzy search logic for Combobox. You can override this search logic with your own using the `filterFunction` prop. +To start, you can provide a label, an array of options, control state. -## Multi-Select +```tsx live +function SingleSelect() { + const singleSelectOptions = [ + { value: null, label: 'Remove selection' }, + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ]; + + const [value, setValue] = useState('apple'); + + return ( + + ); +} +``` + +### Multiple Selections -Basic multi-selection combobox with search. +You can also allow users to select multiple options with `type="multi"`. -```jsx live +```tsx live function MultiSelect() { -const fruitOptions: SelectOption[] = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, - { value: 'date', label: 'Date' }, - { value: 'elderberry', label: 'Elderberry' }, - { value: 'fig', label: 'Fig' }, - { value: 'grape', label: 'Grape' }, - { value: 'honeydew', label: 'Honeydew' }, - { value: 'kiwi', label: 'Kiwi' }, - { value: 'lemon', label: 'Lemon' }, - { value: 'mango', label: 'Mango' }, - { value: 'orange', label: 'Orange' }, - { value: 'papaya', label: 'Papaya' }, - { value: 'raspberry', label: 'Raspberry' }, - { value: 'strawberry', label: 'Strawberry' }, -]; - - const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); + const fruitOptions: SelectOption[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, + { value: 'grape', label: 'Grape' }, + { value: 'honeydew', label: 'Honeydew' }, + { value: 'kiwi', label: 'Kiwi' }, + { value: 'lemon', label: 'Lemon' }, + { value: 'mango', label: 'Mango' }, + { value: 'orange', label: 'Orange' }, + { value: 'papaya', label: 'Papaya' }, + { value: 'raspberry', label: 'Raspberry' }, + { value: 'strawberry', label: 'Strawberry' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +## Search + +We use [fuse.js](https://www.fusejs.io/) for fuzzy search by default. You can override with `filterFunction`. + +```tsx live +function CustomFilter() { + const cryptoOptions: SelectOption[] = [ + { value: 'btc', label: 'Bitcoin', description: 'BTC • Digital Gold' }, + { value: 'eth', label: 'Ethereum', description: 'ETH • Smart Contracts' }, + { value: 'usdc', label: 'USD Coin', description: 'USDC • Stablecoin' }, + { value: 'sol', label: 'Solana', description: 'SOL • High Performance' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + const filterFunction = useCallback((options: SelectOption[], searchText: string) => { + const search = searchText.toLowerCase().trim(); + if (!search) return options; + return options.filter((option) => { + const label = typeof option.label === 'string' ? option.label.toLowerCase() : ''; + const description = + typeof option.description === 'string' ? option.description.toLowerCase() : ''; + return label.startsWith(search) || description.startsWith(search); + }); + }, []); + + return ( + + ); +} +``` + +## Grouped + +Display options under headers using `label` and `options`. Sort options by the same dimension you group by. + +```tsx live +function GroupedOptions() { + const groupedOptions = [ + { + label: 'Fruits', + options: [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ], + }, + { + label: 'Vegetables', + options: [ + { value: 'carrot', label: 'Carrot' }, + { value: 'broccoli', label: 'Broccoli' }, + { value: 'spinach', label: 'Spinach' }, + ], + }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +## Accessibility + +Use accessibility labels to provide clear control and dropdown context. For multi-select, add remove and hidden-selection labels so screen readers can describe chip actions and +X summaries. + +```tsx live +function AccessibilityProps() { + const priorityOptions: SelectOption[] = [ + { value: 'high', label: 'High Priority' }, + { value: 'medium', label: 'Medium Priority' }, + { value: 'low', label: 'Low Priority' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +## Styling + +### Selection Display Limit + +Cap visible chips with `maxSelectedOptionsToShow`; the rest show as +X more. Pair with `hiddenSelectedOptionsLabel` for screen readers. + +```tsx live +function LimitDisplayedSelections() { + const countryOptions: SelectOption[] = [ + { value: 'us', label: 'United States', description: 'North America' }, + { value: 'ca', label: 'Canada', description: 'North America' }, + { value: 'mx', label: 'Mexico', description: 'North America' }, + { value: 'uk', label: 'United Kingdom', description: 'Europe' }, + { value: 'fr', label: 'France', description: 'Europe' }, + { value: 'de', label: 'Germany', description: 'Europe' }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Alignment + +Align selected values with the `align` prop. + +```tsx live +function AlignmentExample() { + const fruitOptions: SelectOption[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ]; + const { value, onChange } = useMultiSelect({ initialValue: [] }); return ( + + + ); } ``` -## Single Select +### Borderless -Standard single-selection combobox with an option to clear the current value. +Remove the border with `bordered={false}`. -```jsx live -function SingleSelect() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, +```tsx live +function BorderlessExample() { + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, - { value: 'date', label: 'Date' }, ]; - const [value, setValue] = useState('apple'); return ( ); } ``` -## Helper Text - -Communicate limits or guidance by pairing helper text with multi-select usage. - -```jsx live -function HelperText() { -const fruitOptions: SelectOption[] = [ - { value: 'apple', label: 'Apple' }, - { value: 'banana', label: 'Banana' }, - { value: 'cherry', label: 'Cherry' }, - { value: 'date', label: 'Date' }, - { value: 'elderberry', label: 'Elderberry' }, - { value: 'fig', label: 'Fig' }, - { value: 'grape', label: 'Grape' }, - { value: 'honeydew', label: 'Honeydew' }, - { value: 'kiwi', label: 'Kiwi' }, - { value: 'lemon', label: 'Lemon' }, - { value: 'mango', label: 'Mango' }, - { value: 'orange', label: 'Orange' }, - { value: 'papaya', label: 'Papaya' }, - { value: 'raspberry', label: 'Raspberry' }, - { value: 'strawberry', label: 'Strawberry' }, -]; - - const { value, onChange } = useMultiSelect({ initialValue: ['apple', 'banana'] }); +### Compact + +Use smaller sizing with `compact`. + +```tsx live +function CompactExample() { + const fruitOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + const { value, onChange } = useMultiSelect({ initialValue: [] }); return ( - + ); } ``` -## Borderless +### Helper Text -You can remove the border from the combobox control by setting `bordered` to `false`. +Add guidance with `helperText`. -```jsx live -function BorderlessExample() { - const singleSelectOptions = [ - { value: null, label: 'Remove selection' }, +```tsx live +function HelperTextExample() { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + const fruitOptions: SelectOption[] = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, ]; + return ( + + ); +} +``` + +## Composed Examples + +### Country Selection + +You can include flag emoji in labels to create a country selector. + +```tsx live +function CountrySelectionExample() { + const getFlagEmoji = (cc) => + cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); + + const countryOptions = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, + ]; + + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +} +``` + +### Free Solo + +You can add a dynamic option to Combobox to enable free solo where users can provide their own value. + +```tsx live +function FreeSoloExample() { + const CREATE_OPTION_PREFIX = '__create__'; + + const FreeSoloCombobox = useMemo(() => { + function StableFreeSoloCombobox({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps + }) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((option) => option.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prevOptions) => { + const addedStillSelected = prevOptions.filter( + (option) => !initialSet.has(option.value) && valueSet.has(option.value), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [freeSolo, initialOptions, value]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmedSearch = searchText.trim(); + if (!trimmedSearch) return options; + + const alreadyExists = options.some( + (option) => + typeof option.label === 'string' && + option.label.toLowerCase() === trimmedSearch.toLowerCase(), + ); + if (alreadyExists) return options; + + return [ + ...options, + { value: `${CREATE_OPTION_PREFIX}${trimmedSearch}`, label: `Add "${trimmedSearch}"` }, + ]; + }, [freeSolo, options, searchText]); + + const handleChange = useCallback( + (newValue) => { + if (!freeSolo) { + onChange(newValue); + return; + } + + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((optionValue) => + String(optionValue).startsWith(CREATE_OPTION_PREFIX), + ); + + if (!createValue) { + onChange(newValue); + return; + } + + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const normalizedValue = newLabel.toLowerCase(); + const newOption = { value: normalizedValue, label: newLabel }; + + setOptions((prevOptions) => [...prevOptions, newOption]); + + const updatedValues = values + .filter((optionValue) => !String(optionValue).startsWith(CREATE_OPTION_PREFIX)) + .concat(normalizedValue); + + onChange(comboboxProps.type === 'multi' ? updatedValues : normalizedValue); + setSearchText(''); + }, + [comboboxProps.type, freeSolo, onChange], + ); + + return ( + + ); + } + + return StableFreeSoloCombobox; + }, [CREATE_OPTION_PREFIX]); + const fruitOptions = [ { value: 'apple', label: 'Apple' }, { value: 'banana', label: 'Banana' }, { value: 'cherry', label: 'Cherry' }, { value: 'date', label: 'Date' }, { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, ]; - const [singleValue, setSingleValue] = useState('apple'); - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['apple'], - }); + const [standardSingleValue, setStandardSingleValue] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingleValue] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); return ( - - + + ); diff --git a/apps/docs/docs/components/inputs/Combobox/_webStyles.mdx b/apps/docs/docs/components/inputs/Combobox/_webStyles.mdx new file mode 100644 index 0000000000..df8d0163ea --- /dev/null +++ b/apps/docs/docs/components/inputs/Combobox/_webStyles.mdx @@ -0,0 +1,36 @@ +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Combobox } from '@coinbase/cds-web/alpha/combobox'; + +import webStylesData from ':docgen/web/alpha/combobox/Combobox/styles-data'; + +export const ComboboxExample = ({ classNames }) => { + const [value, setValue] = useState(); + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + { value: 'option3', label: 'Option 3' }, + ]; + return ( + + ); +}; + +## Explorer + + + {(classNames) => } + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/Combobox/index.mdx b/apps/docs/docs/components/inputs/Combobox/index.mdx index d0b8ff86f2..4bb9acad9f 100644 --- a/apps/docs/docs/components/inputs/Combobox/index.mdx +++ b/apps/docs/docs/components/inputs/Combobox/index.mdx @@ -14,6 +14,8 @@ import MobilePropsTable from './_mobilePropsTable.mdx'; import mobilePropsToc from ':docgen/mobile/alpha/combobox/Combobox/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import webPropsToc from ':docgen/web/alpha/combobox/Combobox/toc-props'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; @@ -31,12 +33,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/inputs/Combobox/mobileMetadata.json b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json index a506ad520f..77ea5e689d 100644 --- a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json @@ -12,8 +12,8 @@ ], "dependencies": [ { - "name": "fuse.js", - "version": "^6.6.2" + "name": "react-native-safe-area-context", + "version": "^4.10.5" } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/webMetadata.json b/apps/docs/docs/components/inputs/Combobox/webMetadata.json index b28685b3a0..4db33d49b9 100644 --- a/apps/docs/docs/components/inputs/Combobox/webMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/webMetadata.json @@ -1,7 +1,7 @@ { "import": "import { Combobox } from '@coinbase/cds-web/alpha/combobox'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/alpha/combobox/Combobox.tsx", - "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-alpha-combobox-combobox--default", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-alpha-combobox--basic-usage", "description": "A flexible combobox component for both single and multi-selection, built for web applications with comprehensive accessibility support.", "alpha": true, "relatedComponents": [ @@ -12,8 +12,12 @@ ], "dependencies": [ { - "name": "fuse.js", - "version": "^6.6.2" + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json index 9e020f094f..57b7d81acf 100644 --- a/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json @@ -31,5 +31,6 @@ "label": "Switch", "url": "/components/inputs/Switch/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json b/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json index 16eec2d6a2..609d2c9e24 100644 --- a/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json @@ -32,5 +32,6 @@ "label": "Switch", "url": "/components/inputs/Switch/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx index 7e1d731115..de2eb7bdee 100644 --- a/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_mobileExamples.mdx @@ -180,6 +180,8 @@ Since icon buttons have no visible text, an `accessibilityLabel` is required to When composing a button with a visible label, use `accessibilityLabelledBy` to reference the label's `id` instead. See the [Claim Drop example](#claim-drop) below. +For most use cases, keep the IconButton target area at `40 x 40` or larger. Reserve `iconSize="xs"` for specific constrained layouts, and avoid shrinking the interactive area below `24 x 24`, which is the absolute minimum target size recommended by [WCAG 2.2 target size guidance](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html). + ## Composed Examples ### Claim Drop diff --git a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx index f383ff2138..4228b112f4 100644 --- a/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/IconButton/_webExamples.mdx @@ -90,7 +90,64 @@ Use the `transparent` prop to remove the background until the user interacts wit ### 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. +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 + + + + + + + + + +``` + +#### Interactive loading + +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() { @@ -189,6 +246,8 @@ Since icon buttons have no visible text, an `accessibilityLabel` is required to When composing a button with a visible label, use `accessibilityLabelledBy` to reference the label's `id` instead. See the [Claim Drop example](#claim-drop) below. +For most use cases, keep the IconButton target area at `40 x 40` or larger. Reserve `iconSize="xs"` for specific constrained layouts, and avoid shrinking the interactive area below `24 x 24`, which is the absolute minimum target size recommended by [WCAG 2.2 target size guidance](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html). + ## Composed Examples ### Claim Drop diff --git a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx index 7d8252d12f..fefb28f08d 100644 --- a/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_mobileExamples.mdx @@ -1,9 +1,35 @@ -### Basic usage +InputChip is built for remove actions. For other uses, see [Chip](/components/inputs/Chip/) which supports interaction. + +## Basics + +Use `onPress` for remove behavior. + +```tsx +function Example() { + const [selectedValues, setSelectedValues] = React.useState(['BTC', 'ETH', 'SOL']); + + return ( + + {selectedValues.map((value) => ( + setSelectedValues((current) => current.filter((item) => item !== value))} + value={value} + /> + ))} + + ); +} +``` + +### Disabled + +Use `disabled` when the value should stay visible but not removable. ```tsx function Example() { return ( - + console.log('Remove Basic')} value="Basic Chip" /> {}} value="Disabled Chip" /> @@ -11,20 +37,22 @@ function Example() { } ``` -### With Custom Start Element +## Styling + +### With start content ```tsx function Example() { return ( - + console.log('Remove Star')} value="With Icon" start={} /> - + console.log('Remove BTC')} value="BTC" @@ -41,16 +69,54 @@ function Example() { } ``` -### With Custom Accessibility Label +### Compact + +Use `compact` to reduce chip height and spacing in dense layouts. + +```tsx +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Compact')} value="Compact" /> + + ); +} +``` + +### Invert color scheme + +Use `invertColorScheme` to emphasize removable values. + +```tsx +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Inverted')} + value="Inverted" + /> + + ); +} +``` + +## Accessibility + +InputChip defaults to a remove label (`Remove ${children}` for string content, otherwise `Remove option`). +Override `accessibilityLabel` when you need more specific wording. ```tsx function Example() { return ( - + + console.log('Remove BTC')} value="BTC" /> console.log('Remove Custom')} value="Custom Label" - accessibilityLabel="Custom remove action" + accessibilityLabel="Remove custom selection" /> ); diff --git a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx index 99c84e6612..31e9ba7b16 100644 --- a/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/InputChip/_webExamples.mdx @@ -1,9 +1,35 @@ -### Basic usage +InputChip is built for remove actions. For other uses, see [Chip](/components/inputs/Chip/) which supports interaction. + +## Basics + +Use `onClick` for remove behavior. + +```tsx live +function Example() { + const [selectedValues, setSelectedValues] = React.useState(['BTC', 'ETH', 'SOL']); + + return ( + + {selectedValues.map((value) => ( + setSelectedValues((current) => current.filter((item) => item !== value))} + value={value} + /> + ))} + + ); +} +``` + +### Disabled + +Use `disabled` when the value should stay visible but not removable. ```tsx live function Example() { return ( - + console.log('Remove Basic')} value="Basic Chip" /> {}} value="Disabled Chip" /> @@ -11,20 +37,22 @@ function Example() { } ``` -### With Custom Start Element +## Styling + +### With start content ```tsx live function Example() { return ( - + console.log('Remove Star')} value="With Icon" start={} /> - + console.log('Remove BTC')} value="BTC" @@ -41,16 +69,54 @@ function Example() { } ``` -### With Custom Accessibility Label +### Compact + +Use `compact` to reduce chip height and spacing in dense layouts. + +```tsx live +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Compact')} value="Compact" /> + + ); +} +``` + +### Invert color scheme + +Use `invertColorScheme` to emphasize removable values. + +```tsx live +function Example() { + return ( + + console.log('Remove Default')} value="Default" /> + console.log('Remove Inverted')} + value="Inverted" + /> + + ); +} +``` + +## Accessibility + +InputChip defaults to a remove label (`Remove ${children}` for string content, otherwise `Remove option`). +Override `accessibilityLabel` when you need more specific wording. ```tsx live function Example() { return ( - + + console.log('Remove BTC')} value="BTC" /> console.log('Remove Custom')} value="Custom Label" - accessibilityLabel="Custom remove action" + accessibilityLabel="Remove custom selection" /> ); diff --git a/apps/docs/docs/components/inputs/InputChip/index.mdx b/apps/docs/docs/components/inputs/InputChip/index.mdx index 62fb10616b..e0ef1ae47f 100644 --- a/apps/docs/docs/components/inputs/InputChip/index.mdx +++ b/apps/docs/docs/components/inputs/InputChip/index.mdx @@ -28,7 +28,7 @@ import mobileMetadata from './mobileMetadata.json'; title="InputChip" webMetadata={webMetadata} mobileMetadata={mobileMetadata} - description="InputChip is a compact, interactive element that represents a value or action. It's commonly used for filtering, selection, or data entry." + description="InputChip is a compact remove-action element for removable values. Use it when pressing the chip should remove or clear the represented selection." /> + Label only } @@ -22,9 +22,9 @@ MediaChip automatically calculates spacing based on the content you provide (sta ``` -### Configurations +## Layout Configurations -MediaChip supports all 6 spacing configurations automatically. +MediaChip supports all six content combinations automatically. ```tsx @@ -55,11 +55,13 @@ MediaChip supports all 6 spacing configurations automatically. ``` -### Compact Variant +## Styling + +### Compact The compact variant reduces spacing for denser layouts. -:::tip Recommended component sizes for compact chip +:::tip Recommended component sizes for compact chips - Start: **16×16** circular media - End: **xs** size icons @@ -82,15 +84,15 @@ The compact variant reduces spacing for denser layouts. ``` -### Inverted State +### Invert color scheme -Use the inverted prop to emphasize the chip with inverted colors. +Use `invertColorScheme` to emphasize the chip with inverted colors. ```tsx - - Selected + + Selected } start={} > @@ -99,12 +101,32 @@ Use the inverted prop to emphasize the chip with inverted colors. ``` -### Interactive +### Custom spacing + +Override automatic spacing with custom values when needed. + +```tsx + + + Custom spacing + + } + > + Asymmetric padding + + +``` + +## Interactivity -MediaChip can be made interactive by providing an onPress handler. +Provide `onPress` to make MediaChip interactive. Use `disabled` to prevent interaction. ```tsx - + console.log('Pressed!')}>Pressable console.log('Pressed!')} @@ -118,22 +140,23 @@ MediaChip can be made interactive by providing an onPress handler. ``` -### Custom Spacing +## Accessibility -You can override the automatic spacing with custom values if needed. +When `onPress` is provided and visible text is unclear (or absent), provide an `accessibilityLabel`. ```tsx - - - Custom spacing - + console.log('Open ETH')} start={} + /> + } + onPress={() => console.log('Open filter')} > - Asymmetric padding + Filter ``` diff --git a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx index d40ac65348..bcf1e24a1b 100644 --- a/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/MediaChip/_webExamples.mdx @@ -1,15 +1,15 @@ -### Basic Usage +MediaChip automatically adjusts spacing based on the combination of `start`, `children`, and `end` content. -MediaChip automatically calculates spacing based on the content you provide (start, children, end). +## Basics -:::tip Recommended component sizes for regular sized chip +:::tip Recommended component sizes for regular-sized chips - Start: **24×24** circular media - End: **xs** size icons ::: ```tsx live - + Label only } @@ -22,9 +22,9 @@ MediaChip automatically calculates spacing based on the content you provide (sta ``` -### Configurations +## Layout Configurations -MediaChip supports all 6 spacing configurations automatically. +MediaChip supports all six content combinations automatically. ```tsx live @@ -55,11 +55,13 @@ MediaChip supports all 6 spacing configurations automatically. ``` -### Compact Variant +## Styling + +### Compact The compact variant reduces spacing for denser layouts. -:::tip Recommended component sizes for compact chip +:::tip Recommended component sizes for compact chips - Start: **16×16** circular media - End: **xs** size icons @@ -82,15 +84,15 @@ The compact variant reduces spacing for denser layouts. ``` -### Inverted State +### Invert color scheme -Use the inverted prop to emphasize the chip with inverted colors. +Use `invertColorScheme` to emphasize the chip with inverted colors. ```tsx live - - Selected + + Selected } start={} > @@ -99,12 +101,32 @@ Use the inverted prop to emphasize the chip with inverted colors. ``` -### Interactive +### Custom spacing + +Override automatic spacing with custom values when needed. + +```tsx live + + + Custom spacing + + } + > + Asymmetric padding + + +``` + +## Interactivity -MediaChip can be made interactive by providing an onClick handler. +Provide `onClick` to make MediaChip interactive. Use `disabled` to prevent interaction. ```tsx live - + alert('Clicked!')}>Clickable alert('Clicked!')} @@ -118,22 +140,23 @@ MediaChip can be made interactive by providing an onClick handler. ``` -### Custom Spacing +## Accessibility -You can override the automatic spacing with custom values if needed. +When `onClick` is provided and visible text is unclear (or absent), provide an `accessibilityLabel`. ```tsx live - - - Custom spacing - + alert('Open ETH')} start={} + /> + } + onClick={() => alert('Open filter')} > - Asymmetric padding + Filter ``` diff --git a/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json b/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json index 16ae058952..d6c59ebc09 100644 --- a/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json @@ -19,5 +19,6 @@ "label": "TabbedChips", "url": "/components/navigation/TabbedChips/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/MediaChip/webMetadata.json b/apps/docs/docs/components/inputs/MediaChip/webMetadata.json index 1deb01bbdd..a26de154bc 100644 --- a/apps/docs/docs/components/inputs/MediaChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/MediaChip/webMetadata.json @@ -20,5 +20,6 @@ "label": "TabbedChips", "url": "/components/navigation/TabbedChips/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/Numpad/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Numpad/_mobileExamples.mdx index 53ddabd15a..48995bbea3 100644 --- a/apps/docs/docs/components/inputs/Numpad/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Numpad/_mobileExamples.mdx @@ -13,7 +13,7 @@ Primary use case for this is when a user is inputing a PIN code. Notice it does alt="Pin Numpad" /> -```jsx +```tsx const PinNumpadExample = () => { // localState const [visible, { toggleOn, toggleOff }] = useToggler(false); @@ -88,7 +88,7 @@ Best when used in the context of a transactional scenario. This could range from alt="Transactional Numpad" /> -```jsx +```tsx const VALUE_MAX = 1000000; const TransactionalNumpadExample = () => { const [visible, { toggleOn, toggleOff }] = useToggler(false); diff --git a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json index 155dc8010b..6ef25ea309 100644 --- a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json @@ -27,8 +27,8 @@ ], "dependencies": [ { - "name": "framer-motion", - "version": "^10.18.0" + "name": "react-native-svg", + "version": "^14.1.0" } ] } diff --git a/apps/docs/docs/components/inputs/Radio/webMetadata.json b/apps/docs/docs/components/inputs/Radio/webMetadata.json index 5dd80ea86a..d7c43966c4 100644 --- a/apps/docs/docs/components/inputs/Radio/webMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/webMetadata.json @@ -25,5 +25,11 @@ "label": "Switch", "url": "/components/inputs/Switch" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/_mobileStyles.mdx b/apps/docs/docs/components/inputs/RadioCell/_mobileStyles.mdx new file mode 100644 index 0000000000..3bf146e733 --- /dev/null +++ b/apps/docs/docs/components/inputs/RadioCell/_mobileStyles.mdx @@ -0,0 +1,7 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; + +import mobileStylesData from ':docgen/mobile/controls/RadioCell/styles-data'; + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/RadioCell/_webStyles.mdx b/apps/docs/docs/components/inputs/RadioCell/_webStyles.mdx new file mode 100644 index 0000000000..08313413ab --- /dev/null +++ b/apps/docs/docs/components/inputs/RadioCell/_webStyles.mdx @@ -0,0 +1,22 @@ +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { RadioCell } from '@coinbase/cds-web/controls'; + +import webStylesData from ':docgen/web/controls/RadioCell/styles-data'; + +## Explorer + + + {(classNames) => ( + + )} + + +## Selectors + + diff --git a/apps/docs/docs/components/inputs/RadioCell/index.mdx b/apps/docs/docs/components/inputs/RadioCell/index.mdx index d2adc1f06a..8cb090bf59 100644 --- a/apps/docs/docs/components/inputs/RadioCell/index.mdx +++ b/apps/docs/docs/components/inputs/RadioCell/index.mdx @@ -13,6 +13,8 @@ import webPropsToc from ':docgen/web/controls/RadioCell/toc-props'; import mobilePropsToc from ':docgen/mobile/controls/RadioCell/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; import MobilePropsTable from './_mobilePropsTable.mdx'; +import WebStyles, { toc as webStylesToc } from './_webStyles.mdx'; +import MobileStyles, { toc as mobileStylesToc } from './_mobileStyles.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; @@ -22,12 +24,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/inputs/RadioCell/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json index b1caa8484f..685965c943 100644 --- a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json @@ -16,5 +16,11 @@ "label": "Radio", "url": "/components/inputs/Radio/?platform=mobile" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/webMetadata.json b/apps/docs/docs/components/inputs/RadioCell/webMetadata.json index 4fe91b8b58..1fd5a4a886 100644 --- a/apps/docs/docs/components/inputs/RadioCell/webMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/webMetadata.json @@ -17,5 +17,11 @@ "label": "Radio", "url": "/components/inputs/Radio/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json index 66941645bc..c594a0a22a 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json @@ -14,5 +14,11 @@ "url": "/components/inputs/ControlGroup/", "description": "ControlGroup is a component that allows users to group related controls together." } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json index 9bf256ba21..ad8a650600 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json @@ -13,5 +13,11 @@ "label": "ControlGroup", "url": "/components/inputs/ControlGroup/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/SearchInput/_mobileExamples.mdx b/apps/docs/docs/components/inputs/SearchInput/_mobileExamples.mdx index 7d63fb166d..8117a09742 100644 --- a/apps/docs/docs/components/inputs/SearchInput/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/SearchInput/_mobileExamples.mdx @@ -45,10 +45,15 @@ function SearchInputWithBack() { ### Variants +When `bordered={false}`, SearchInput keeps focused border styling disabled by default. Set +`focusedBorderWidth` to opt into a focus border style. + ```tsx function SearchInputVariants() { const [value1, setValue1] = useState(''); const [value2, setValue2] = useState(''); + const [value3, setValue3] = useState(''); + const [value4, setValue4] = useState(''); return ( @@ -63,6 +68,21 @@ function SearchInputVariants() { value={value2} onChangeText={setValue2} onClear={() => setValue2('')} + placeholder="Borderless search (default focus behavior)..." + bordered={false} + /> + setValue3('')} + placeholder="Borderless search (with focus border)..." + bordered={false} + focusedBorderWidth={200} + /> + setValue4('')} placeholder="No icons..." hideStartIcon hideEndIcon diff --git a/apps/docs/docs/components/inputs/SearchInput/_webExamples.mdx b/apps/docs/docs/components/inputs/SearchInput/_webExamples.mdx index 97cff84bbb..e78c9e3de6 100644 --- a/apps/docs/docs/components/inputs/SearchInput/_webExamples.mdx +++ b/apps/docs/docs/components/inputs/SearchInput/_webExamples.mdx @@ -22,11 +22,15 @@ function BasicSearchInput() { ### Variants +When `bordered={false}`, SearchInput keeps focused border styling disabled by default. Set +`focusedBorderWidth` to opt into a focus border style. + ```tsx live function SearchInputVariants() { const [value1, setValue1] = useState(''); const [value2, setValue2] = useState(''); const [value3, setValue3] = useState(''); + const [value4, setValue4] = useState(''); return ( @@ -41,13 +45,21 @@ function SearchInputVariants() { value={value2} onChangeText={setValue2} onClear={() => setValue2('')} - placeholder="Borderless search..." + placeholder="Borderless search (default focus behavior)..." bordered={false} /> setValue3('')} + placeholder="Borderless search (with focus border)..." + bordered={false} + focusedBorderWidth={200} + /> + setValue4('')} placeholder="No icons..." hideStartIcon hideEndIcon diff --git a/apps/docs/docs/components/inputs/SegmentedControl/_webExamples.mdx b/apps/docs/docs/components/inputs/SegmentedControl/_webExamples.mdx new file mode 100644 index 0000000000..12987d86f8 --- /dev/null +++ b/apps/docs/docs/components/inputs/SegmentedControl/_webExamples.mdx @@ -0,0 +1,145 @@ +SegmentedControl uses native radio inputs with labels to provide an accessible, compact switch between options. It supports both text labels and icon options. + +## Basics + +Pass an array of `options` with `value` and `label` properties. Use `value` and `onChange` for controlled usage, or omit them for uncontrolled behavior. The component manages its own state when uncontrolled. + +```jsx live +function SegmentedControlBasic() { + const options = [ + { value: 'eth', label: 'ETH' }, + { value: 'usd', label: 'USD' }, + { value: 'btc', label: 'BTC' }, + ]; + + const [selected, setSelected] = useState('eth'); + + return ( + + + + Selected: {selected} + + + ); +} +``` + +### Uncontrolled + +When you omit `value` and `onChange`, SegmentedControl manages selection internally. Use `onChange` only when you need to react to changes. + +```jsx live +function SegmentedControlUncontrolled() { + const options = [ + { value: 'list', label: 'List' }, + { value: 'grid', label: 'Grid' }, + ]; + + return ; +} +``` + +## Icons + +For icon-only segments, set `type="icon"` and provide `iconSize` and options with `label` as an icon name. Use `accessibilityLabel` on each option for screen readers. + +```jsx live +function SegmentedControlIcons() { + const options = [ + { value: 'eth', label: 'ethereum', accessibilityLabel: 'Ethereum' }, + { value: 'usd', label: 'cashUSD', accessibilityLabel: 'US Dollar' }, + ]; + + const [value, setValue] = useState('eth'); + + return ( + + + + + + ); +} +``` + +You can also set `active` to `true` to apply an active icon style. + +```jsx live +function SegmentedControlActiveIcons() { + const options = [ + { value: 'eth', label: 'ethereum', accessibilityLabel: 'Ethereum', active: true }, + { value: 'usd', label: 'cashUSD', accessibilityLabel: 'US Dollar' }, + ]; + + const [value, setValue] = useState('eth'); + + return ( + + ); +} +``` + +## Disabled + +Disable the entire control with the `disabled` prop. + +```jsx live +function SegmentedControlDisabled() { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + return ; +} +``` + +## Accessibility + +Provide `accessibilityLabel` on each option when using icons so screen readers can announce the segment. For text options, the label text is used automatically. + +```jsx live +function SegmentedControlAccessible() { + const options = [ + { value: 'eth', label: 'ethereum', accessibilityLabel: 'View in Ethereum' }, + { value: 'usd', label: 'cashUSD', accessibilityLabel: 'View in US Dollars' }, + ]; + + const [value, setValue] = useState('eth'); + + return ( + + ); +} +``` diff --git a/apps/docs/docs/components/inputs/SegmentedControl/_webPropsTable.mdx b/apps/docs/docs/components/inputs/SegmentedControl/_webPropsTable.mdx new file mode 100644 index 0000000000..3064cd7c55 --- /dev/null +++ b/apps/docs/docs/components/inputs/SegmentedControl/_webPropsTable.mdx @@ -0,0 +1,11 @@ +import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable'; + +import webPropsData from ':docgen/web/controls/SegmentedControl/data'; +import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; +import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; + + diff --git a/apps/docs/docs/components/inputs/SegmentedControl/index.mdx b/apps/docs/docs/components/inputs/SegmentedControl/index.mdx new file mode 100644 index 0000000000..92837e9b53 --- /dev/null +++ b/apps/docs/docs/components/inputs/SegmentedControl/index.mdx @@ -0,0 +1,25 @@ +--- +id: segmentedControl +title: SegmentedControl +platform_switcher_options: { web: true, mobile: false } +hide_title: true +--- + +import { VStack } from '@coinbase/cds-web/layout'; +import { ComponentHeader } from '@site/src/components/page/ComponentHeader'; +import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; + +import webPropsToc from ':docgen/web/controls/SegmentedControl/toc-props'; +import WebPropsTable from './_webPropsTable.mdx'; +import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; +import webMetadata from './webMetadata.json'; + + + + } + webExamplesToc={webExamplesToc} + webPropsTable={} + webPropsToc={webPropsToc} + /> + diff --git a/apps/docs/docs/components/inputs/SegmentedControl/webMetadata.json b/apps/docs/docs/components/inputs/SegmentedControl/webMetadata.json new file mode 100644 index 0000000000..b4a3530446 --- /dev/null +++ b/apps/docs/docs/components/inputs/SegmentedControl/webMetadata.json @@ -0,0 +1,21 @@ +{ + "import": "import { SegmentedControl } from '@coinbase/cds-web/controls/SegmentedControl'", + "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/controls/SegmentedControl.tsx", + "description": "A horizontal control composed of mutually exclusive segments, used to switch between related options.", + "warning": "SegmentedControl is deprecated and will be removed in a future version. Please use Tabs or SegmentedTabs instead.", + "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-segmented-control--normal", + "relatedComponents": [ + { + "label": "SegmentedTabs", + "url": "/components/navigation/SegmentedTabs/" + }, + { + "label": "RadioGroup", + "url": "/components/inputs/RadioGroup/" + }, + { + "label": "ControlGroup", + "url": "/components/inputs/ControlGroup/" + } + ] +} diff --git a/apps/docs/docs/components/inputs/Select/_mobileExamples.mdx b/apps/docs/docs/components/inputs/Select/_mobileExamples.mdx index 2264b54648..d2d95a854a 100644 --- a/apps/docs/docs/components/inputs/Select/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/Select/_mobileExamples.mdx @@ -4,7 +4,7 @@ The mobile version of `Select` is quite different from web; where on mobile, `Se On mobile, all `SelectOption`s must be wrapped in a `Menu`. Think of it as a controlled `Select` on web, where you pass it the `value` and `onChange` handler. -```jsx +```tsx const SelectMobile = () => { const [isTrayVisible, { toggleOff: handleClose, toggleOn: handleOpenTray }] = useToggler(false); const [value, setValue] = useState(); diff --git a/apps/docs/docs/components/inputs/Select/webMetadata.json b/apps/docs/docs/components/inputs/Select/webMetadata.json index a6bac0619e..135c57e756 100644 --- a/apps/docs/docs/components/inputs/Select/webMetadata.json +++ b/apps/docs/docs/components/inputs/Select/webMetadata.json @@ -23,5 +23,14 @@ "url": "/components/inputs/SelectChip/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/_mobileExamples.mdx b/apps/docs/docs/components/inputs/SelectAlpha/_mobileExamples.mdx index 7a8ca1af33..d553aa608e 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/_mobileExamples.mdx +++ b/apps/docs/docs/components/inputs/SelectAlpha/_mobileExamples.mdx @@ -155,6 +155,72 @@ function MultiSelectWithGroupsExample() { } ``` +## Alignment + +The mobile Select component supports aligning the selected value(s) using the `align` prop. + +::::note +Left / right alignment is preferred for styling. +:::: + +```jsx +function AlignmentExample() { + const exampleOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + { value: '4', label: 'Option 4' }, + { value: '5', label: 'Option 5' }, + { value: '6', label: 'Option 6' }, + { value: '7', label: 'Option 7' }, + { value: '8', label: 'Option 8' }, + ]; + + const [singleValue, setSingleValue] = useState('1'); + const { value: multiValue, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + + + + + ); +} +``` + ## Accessibility Props The mobile Select component supports comprehensive accessibility features including custom labels, hints, and roles. @@ -1308,7 +1374,7 @@ function CustomClassNamesExamples() { ## Custom Label -If you need to render a custom label (e.g. a label with a tooltip), you can pass a React Node to the `label` prop. +You can pass a ReactNode to `label` to render a custom label. If you want to include a tooltip, ensure the touch target is at least 24x24 for accessibility compliance. ```jsx function CustomLabelExample() { @@ -1323,10 +1389,11 @@ function CustomLabelExample() { return ( + + - Custom Label + + Custom Label + {/* Add padding to ensure 24x24 tooltip tap target for a11y compliance */} - + } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/_webStyles.mdx b/apps/docs/docs/components/inputs/SelectAlpha/_webStyles.mdx new file mode 100644 index 0000000000..d69c7f5879 --- /dev/null +++ b/apps/docs/docs/components/inputs/SelectAlpha/_webStyles.mdx @@ -0,0 +1,37 @@ +import { useState } from 'react'; +import { ComponentStylesTable } from '@site/src/components/page/ComponentStylesTable'; +import { StylesExplorer } from '@site/src/components/page/StylesExplorer'; +import { Select } from '@coinbase/cds-web/alpha/select'; + +import webStylesData from ':docgen/web/alpha/select/Select/styles-data'; + +export const SelectExample = ({ classNames }) => { + const [value, setValue] = useState('1'); + const options = [ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + { value: '4', label: 'Option 4' }, + ]; + return ( + } + accept={accept} + multiple={multiple} + onChange={onFileInputChange} + style={{ + position: 'absolute', + inset: 0, + opacity: 0, + cursor: 'pointer', + width: '100%', + height: '100%', + }} + type="file" + /> + {children} + + ); +}); + +export { useFileUpload } from './useFileUpload'; diff --git a/apps/docs/src/components/page/FileDropZone/useFileUpload.ts b/apps/docs/src/components/page/FileDropZone/useFileUpload.ts new file mode 100644 index 0000000000..24222e4993 --- /dev/null +++ b/apps/docs/src/components/page/FileDropZone/useFileUpload.ts @@ -0,0 +1,63 @@ +import { type ChangeEvent, type DragEvent, useCallback, useRef } from 'react'; + +type UseFileUploadOptions = { + onFiles: (files: File[]) => void; + onDragEnter?: (itemCount: number) => void; + onDragLeave?: () => void; +}; + +export function useFileUpload({ onFiles, onDragEnter, onDragLeave }: UseFileUploadOptions) { + const fileInputRef = useRef(null); + + const handleChange = useCallback( + (e: ChangeEvent) => { + if (!e.target.files?.length) return; + const files = Array.from(e.target.files); + e.target.value = ''; + setTimeout(() => onFiles(files), 0); + }, + [onFiles], + ); + + const handleDrop = useCallback( + (e: DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!e.dataTransfer.files.length) return; + const files = Array.from(e.dataTransfer.files); + setTimeout(() => onFiles(files), 0); + }, + [onFiles], + ); + + const handleDragOver = useCallback( + (e: DragEvent) => { + e.preventDefault(); + const items = Array.from(e.dataTransfer.items).filter((i) => i.kind === 'file'); + onDragEnter?.(items.length); + }, + [onDragEnter], + ); + + const handleDragLeave = useCallback( + (e: DragEvent) => { + const zone = e.currentTarget; + if (!zone.contains(e.relatedTarget as Node)) { + onDragLeave?.(); + } + }, + [onDragLeave], + ); + + const dropZoneProps = { + onDrop: handleDrop, + onDragOver: handleDragOver, + onDragLeave: handleDragLeave, + }; + + return { + dropZoneProps, + fileInputRef, + handleFileInputChange: handleChange, + }; +} diff --git a/apps/docs/src/components/page/IconSheet/index.tsx b/apps/docs/src/components/page/IconSheet/index.tsx index 68649d6d3f..4527f53546 100644 --- a/apps/docs/src/components/page/IconSheet/index.tsx +++ b/apps/docs/src/components/page/IconSheet/index.tsx @@ -74,8 +74,10 @@ export const IconSheet = ({ title }: { title?: React.ReactNode }) => { @@ -85,7 +87,11 @@ export const IconSheet = ({ title }: { title?: React.ReactNode }) => { active prop: - +
diff --git a/apps/docs/src/components/page/IllustrationSheet/index.tsx b/apps/docs/src/components/page/IllustrationSheet/index.tsx index 2d3c084d2d..246d07bc51 100644 --- a/apps/docs/src/components/page/IllustrationSheet/index.tsx +++ b/apps/docs/src/components/page/IllustrationSheet/index.tsx @@ -198,8 +198,10 @@ export const IllustrationSheet = ({ variant }: { variant: IllustrationVariant }) diff --git a/apps/docs/src/components/page/LLMDocButton/index.tsx b/apps/docs/src/components/page/LLMDocButton/index.tsx deleted file mode 100644 index bb9a890782..0000000000 --- a/apps/docs/src/components/page/LLMDocButton/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { memo, useCallback, useMemo } from 'react'; -import { Icon } from '@coinbase/cds-web/icons'; -import { Box } from '@coinbase/cds-web/layout'; -import { useToast } from '@coinbase/cds-web/overlays/useToast'; -import { Link } from '@coinbase/cds-web/typography/Link'; -import { useLocation } from '@docusaurus/router'; -import { usePlatformContext } from '@site/src/utils/PlatformContext'; - -/** - * A button group that provides access to LLM-friendly documentation. - */ -export const LLMDocButtons = memo(() => { - const { platform } = usePlatformContext(); - const toast = useToast(); - const location = useLocation(); - - // Parse the current URL to determine doc type and title - const { docType, title } = useMemo(() => { - const pathname = location.pathname; - const parts = pathname.split('/').filter(Boolean); - - // Extract doc type (first segment) and title (last segment) from URL - // e.g., /components/Button -> { docType: 'components', title: 'Button' } - // e.g., /components/layout/AccordionItem -> { docType: 'components', title: 'AccordionItem' } - // e.g., /hooks/useTheme -> { docType: 'hooks', title: 'useTheme' } - // e.g., /getting-started/installation -> { docType: 'getting-started', title: 'installation' } - - if (parts.length >= 2) { - const docType = parts[0]; - const title = parts[parts.length - 1]; // Get the last segment - return { - docType, - title, - }; - } - - // Fallback - return { docType: 'components', title: 'unknown' }; - }, [location.pathname]); - - // Construct the URL path to the LLM text file - const llmDocUrl = `/llms/${platform}/${docType}/${title}.txt`; - - const handleCopy = useCallback(async () => { - try { - // Fetch the text file content - const response = await fetch(llmDocUrl); - if (!response.ok) { - throw new Error('Failed to fetch LLM doc'); - } - const text = await response.text(); - - // Copy to clipboard - await navigator.clipboard.writeText(text); - toast.show('Copied to clipboard'); - } catch (error) { - console.error('Failed to copy LLM doc:', error); - toast.show('Failed to copy to clipboard'); - } - }, [llmDocUrl, toast]); - - return ( - - - {' '} - Copy for LLM - - - {' '} - View as Markdown - - - ); -}); diff --git a/apps/docs/src/components/page/LinkChip/index.tsx b/apps/docs/src/components/page/LinkChip/index.tsx new file mode 100644 index 0000000000..cc80068a6c --- /dev/null +++ b/apps/docs/src/components/page/LinkChip/index.tsx @@ -0,0 +1,42 @@ +import React, { memo } from 'react'; +import type { IconName } from '@coinbase/cds-common'; +import { Icon } from '@coinbase/cds-web/icons'; +import { HStack } from '@coinbase/cds-web/layout/HStack'; +import { Pressable, type PressableProps } from '@coinbase/cds-web/system'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import DocusaurusLink from '@docusaurus/Link'; + +type LinkChipProps = Omit, 'as' | 'children'> & { + children: React.ReactNode; + startIcon?: IconName; + endIcon?: IconName; +}; + +/** + * A Chip-styled link that uses Pressable for hover/active states with DocusaurusLink for routing. + */ +export const LinkChip = memo( + ({ + children, + startIcon = 'externalLink', + endIcon, + background = 'bgSecondary', + borderRadius = 700, + target = '_blank', + ...props + }: LinkChipProps) => ( + + + {startIcon && } + {children} + {endIcon && } + + + ), +); diff --git a/apps/docs/src/components/page/LottieSheet/index.tsx b/apps/docs/src/components/page/LottieSheet/index.tsx index 86e45fe314..378986ce2d 100644 --- a/apps/docs/src/components/page/LottieSheet/index.tsx +++ b/apps/docs/src/components/page/LottieSheet/index.tsx @@ -66,8 +66,10 @@ export const LottieSheet = () => { diff --git a/apps/docs/src/components/page/Metadata/MetadataDependencies.tsx b/apps/docs/src/components/page/Metadata/MetadataDependencies.tsx new file mode 100644 index 0000000000..7281dfe7b3 --- /dev/null +++ b/apps/docs/src/components/page/Metadata/MetadataDependencies.tsx @@ -0,0 +1,53 @@ +import React, { memo } from 'react'; +import { HStack } from '@coinbase/cds-web/layout/HStack'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Link } from '@coinbase/cds-web/typography/Link'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import DocusaurusLink from '@docusaurus/Link'; + +import type { Dependency } from '.'; + +type MetadataDependenciesProps = { + /** List of dependencies to display */ + dependencies: Dependency[]; +}; + +/** + * Displays a list of peer dependencies with optional version info and links. + */ +export const MetadataDependencies = memo(({ dependencies }: MetadataDependenciesProps) => { + if (dependencies.length === 0) { + return null; + } + + return ( + + Peer dependencies + + {dependencies.map((dependency, index) => ( +
  • + + {dependency.url ? ( + + {dependency.name} + + ) : ( + dependency.name + )} + {dependency.version && {`: ${dependency.version}`}} + {index < dependencies.length - 1 && ', '} + +
  • + ))} +
    +
    + ); +}); diff --git a/apps/docs/src/components/page/Metadata/MetadataLinks.tsx b/apps/docs/src/components/page/Metadata/MetadataLinks.tsx new file mode 100644 index 0000000000..451d7bd23d --- /dev/null +++ b/apps/docs/src/components/page/Metadata/MetadataLinks.tsx @@ -0,0 +1,88 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { Chip } from '@coinbase/cds-web/chips/Chip'; +import { Icon } from '@coinbase/cds-web/icons'; +import { HStack } from '@coinbase/cds-web/layout/HStack'; +import { Tooltip } from '@coinbase/cds-web/overlays'; +import { useToast } from '@coinbase/cds-web/overlays/useToast'; +import { useLocation } from '@docusaurus/router'; +import { LinkChip } from '@site/src/components/page/LinkChip'; +import { usePlatformContext } from '@site/src/utils/PlatformContext'; + +type MetadataLinksProps = { + /** URL to source code */ + source?: string; + /** URL to Storybook */ + storybook?: string; + /** URL to changelog */ + changelog?: string; + /** URL to Figma */ + figma?: string; + /** Hide the "View as Markdown" and "Copy for LLM" links */ + hideLlmLinks?: boolean; +}; + +/** + * Displays metadata links (Source, Storybook, Changelog, Figma) and View as Markdown. + */ +export const MetadataLinks = memo( + ({ source, storybook, changelog, figma, hideLlmLinks }: MetadataLinksProps) => { + const { platform } = usePlatformContext(); + const toast = useToast(); + const location = useLocation(); + + const llmDocUrl = useMemo(() => { + const pathname = location.pathname; + const parts = pathname.split('/').filter(Boolean); + + const docType = parts.length >= 2 ? parts[0] : 'components'; + const title = parts.length >= 2 ? parts[parts.length - 1] : 'unknown'; + + return `/llms/${platform}/${docType}/${title}.txt`; + }, [location.pathname, platform]); + + const handleCopyLLMDoc = useCallback(async () => { + try { + const response = await fetch(llmDocUrl); + if (!response.ok) { + throw new Error('Failed to fetch LLM doc'); + } + const text = await response.text(); + await navigator.clipboard.writeText(text); + toast.show('Copied to clipboard'); + } catch (error) { + console.error('Failed to copy LLM doc:', error); + toast.show('Failed to copy to clipboard'); + } + }, [llmDocUrl, toast]); + + return ( + + {source && ( + + Source + + )} + {storybook && Storybook} + {changelog && Changelog} + {figma && ( + + + Figma + + + )} + {!hideLlmLinks && ( + } + > + Copy for LLM + + )} + {!hideLlmLinks && View as Markdown} + + ); + }, +); diff --git a/apps/docs/src/components/page/Metadata/MetadataRelatedComponents.tsx b/apps/docs/src/components/page/Metadata/MetadataRelatedComponents.tsx new file mode 100644 index 0000000000..4112669fc0 --- /dev/null +++ b/apps/docs/src/components/page/Metadata/MetadataRelatedComponents.tsx @@ -0,0 +1,50 @@ +import React, { memo } from 'react'; +import { HStack } from '@coinbase/cds-web/layout/HStack'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Link } from '@coinbase/cds-web/typography/Link'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import DocusaurusLink from '@docusaurus/Link'; + +import type { RelatedComponent } from '.'; + +type MetadataRelatedComponentsProps = { + /** List of related components to display */ + relatedComponents: RelatedComponent[]; +}; + +/** + * Displays a list of related components as links. + */ +export const MetadataRelatedComponents = memo( + ({ relatedComponents }: MetadataRelatedComponentsProps) => { + if (relatedComponents.length === 0) { + return null; + } + + return ( + + Related components + + {relatedComponents.map((component, index) => ( +
  • + + + {component.label} + + {index < relatedComponents.length - 1 && ', '} + +
  • + ))} +
    +
    + ); + }, +); diff --git a/apps/docs/src/components/page/Metadata/index.ts b/apps/docs/src/components/page/Metadata/index.ts new file mode 100644 index 0000000000..9d9e238b31 --- /dev/null +++ b/apps/docs/src/components/page/Metadata/index.ts @@ -0,0 +1,31 @@ +export type Dependency = { + /** The name of the dependency package */ + name: string; + /** Optional version requirement */ + version?: string; + /** Optional URL to the package */ + url?: string; +}; + +export type RelatedComponent = { + /** The URL that the related component links to */ + url: string; + /** The display label for the related component */ + label: string; +}; + +export type Metadata = { + import: string; + source: string; + changelog?: string; + storybook?: string; + figma?: string; + description?: string; + relatedComponents?: RelatedComponent[]; + /** Dependencies required by this component */ + dependencies?: Dependency[]; +}; + +export * from './MetadataDependencies'; +export * from './MetadataLinks'; +export * from './MetadataRelatedComponents'; diff --git a/apps/docs/src/components/page/PlatformSwitcher/index.tsx b/apps/docs/src/components/page/PlatformSwitcher/index.tsx index f807427b7c..8f1cb562e8 100644 --- a/apps/docs/src/components/page/PlatformSwitcher/index.tsx +++ b/apps/docs/src/components/page/PlatformSwitcher/index.tsx @@ -1,14 +1,8 @@ import { useCallback, useMemo, useRef } from 'react'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; -import { TabsActiveIndicator } from '@coinbase/cds-web/tabs'; import { SegmentedTabs } from '@coinbase/cds-web/tabs/SegmentedTabs'; -import type { SegmentedTabsActiveIndicatorProps } from '@coinbase/cds-web/tabs/SegmentedTabsActiveIndicator'; import { type Platform, usePlatformContext } from '@site/src/utils/PlatformContext'; -const SegmentedTabsActiveIndicator = ({ ...props }: SegmentedTabsActiveIndicatorProps) => { - return ; -}; - export const PlatformSwitcher = () => { const { supportsWeb, supportsMobile, platform, setPlatform } = usePlatformContext(); const segmentedTabsRef = useRef(null); @@ -46,7 +40,7 @@ export const PlatformSwitcher = () => { return ( { const defaultCodeExample = `// Create your own example components and hooks, then call render() to render them -type CounterProps = { - label: string; -}; - -const Counter = ({ label }: CounterProps) => { - const [count, setCount] = useState(0); +const Example = () => { return ( - - - {label}: {count} - - - + Place your example code here ); }; // You must call render() to render your code -render(); +render(); `; const prettierOptions = { diff --git a/apps/docs/src/components/page/StylesExplorer/index.tsx b/apps/docs/src/components/page/StylesExplorer/index.tsx new file mode 100644 index 0000000000..8ed258282f --- /dev/null +++ b/apps/docs/src/components/page/StylesExplorer/index.tsx @@ -0,0 +1,99 @@ +import { memo, type ReactNode, useCallback, useMemo, useState } from 'react'; +import { ListCell } from '@coinbase/cds-web/cells/ListCell'; +import { Box } from '@coinbase/cds-web/layout/Box'; +import { Divider } from '@coinbase/cds-web/layout/Divider'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Text } from '@coinbase/cds-web/typography/Text'; +import type { StyleSelector } from '@coinbase/docusaurus-plugin-docgen/types'; + +import styles from './styles.module.css'; + +export type StylesExplorerProps = { + selectors: StyleSelector[]; + children: (classNames: Record) => ReactNode; +}; + +export const StylesExplorer = memo(({ selectors, children }: StylesExplorerProps) => { + const [activeSelector, setActiveSelector] = useState(null); + const [hoveredSelector, setHoveredSelector] = useState(null); + + const handleSelectorClick = useCallback((selector: string) => { + setActiveSelector((prev) => (prev === selector ? null : selector)); + }, []); + + const handleSelectorHover = useCallback((selector: string | null) => { + setHoveredSelector(selector); + }, []); + + const displayedSelector = hoveredSelector ?? activeSelector; + + const appliedClassNames = useMemo(() => { + if (!displayedSelector) return {}; + return { [displayedSelector]: styles.highlight }; + }, [displayedSelector]); + + return ( + + + + {children(appliedClassNames)} + + + + + + + Component Styles + + + Choose a selector to highlight the corresponding element + + + + {selectors.map((selector) => ( + handleSelectorClick(selector.selector)} + onMouseEnter={() => handleSelectorHover(selector.selector)} + onMouseLeave={() => handleSelectorHover(null)} + selected={activeSelector === selector.selector} + spacingVariant="condensed" + title={selector.selector} + /> + ))} + + + + + ); +}); diff --git a/apps/docs/src/components/page/StylesExplorer/styles.module.css b/apps/docs/src/components/page/StylesExplorer/styles.module.css new file mode 100644 index 0000000000..484cc4711d --- /dev/null +++ b/apps/docs/src/components/page/StylesExplorer/styles.module.css @@ -0,0 +1,10 @@ +.highlight { + box-shadow: inset 0 0 0 2px rgb(var(--red40)); + background-color: rgba(var(--red40), 0.1); +} + +/* SVG elements: use stroke since box-shadow doesn't work */ +:global(svg) .highlight { + stroke: rgb(var(--red40)) !important; + stroke-width: 2; +} diff --git a/apps/docs/src/theme/AnnouncementBar/CloseButton/index.tsx b/apps/docs/src/theme/AnnouncementBar/CloseButton/index.tsx index bb3991ac2a..90368ccf25 100644 --- a/apps/docs/src/theme/AnnouncementBar/CloseButton/index.tsx +++ b/apps/docs/src/theme/AnnouncementBar/CloseButton/index.tsx @@ -8,7 +8,7 @@ export default function AnnouncementBarCloseButton({ onClick }: Props): ReactNod diff --git a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx index 5f8f4bf533..ac0a3d8956 100644 --- a/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx +++ b/apps/docs/src/theme/Navbar/MobileSidebar/index.tsx @@ -20,7 +20,9 @@ export default function NavbarMobileSidebar(): JSX.Element | null { }, []); const handleEscPress = useCallback(() => { - mobileSidebar.toggle(); + if (mobileSidebar.shown) { + mobileSidebar.toggle(); + } }, [mobileSidebar]); // Set aria-hidden on main content when sidebar is open diff --git a/apps/docs/src/theme/Playground/index.tsx b/apps/docs/src/theme/Playground/index.tsx index 26a03a680d..a14d4d04f1 100644 --- a/apps/docs/src/theme/Playground/index.tsx +++ b/apps/docs/src/theme/Playground/index.tsx @@ -12,6 +12,7 @@ import { Text } from '@coinbase/cds-web/typography/Text'; import BrowserOnly from '@docusaurus/BrowserOnly'; import ErrorBoundary from '@docusaurus/ErrorBoundary'; import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common'; +import { parseLanguage } from '@docusaurus/theme-common/internal'; import * as estreePlugin from 'prettier/plugins/estree.js'; import * as typescriptPlugin from 'prettier/plugins/typescript.js'; import { format } from 'prettier/standalone'; @@ -100,13 +101,22 @@ type PlaygroundControlsProps = { collapsed: boolean; headingText: string; onClickCopy: () => void; + onClickOpenInStackBlitz: () => void; + onClickResetPreview: () => void; onToggleCollapsed: () => void; }; const PlaygroundControls = memo( - ({ collapsed, headingText, onClickCopy, onToggleCollapsed }: PlaygroundControlsProps) => { + ({ + collapsed, + headingText, + onClickCopy, + onClickOpenInStackBlitz, + onClickResetPreview, + onToggleCollapsed, + }: PlaygroundControlsProps) => { return ( - + + + + + + Reset preview + + + + + + + + Open in StackBlitz + + + ); }, @@ -146,20 +182,24 @@ type PlaygroundProps = Omit & { hidePreview?: boolean; editorStartsExpanded?: boolean; metastring?: string; + className?: string; }; const Playground = memo(function Playground({ children, + className, code: codeProp, hideControls, hidePreview, editorStartsExpanded, + language, metastring, ...props }: PlaygroundProps): JSX.Element { const [code, setCode] = useState(() => (codeProp ?? children ?? '').replace(/\n$/, '')); const codeRef = useRef(code); const [collapsed, setIsCollapsed] = useState(!editorStartsExpanded); + const [previewKey, setPreviewKey] = useState(0); const toggleCollapsed = useCallback(() => setIsCollapsed((collapsed) => !collapsed), []); const toast = useToast(); const { colorScheme, theme, prismTheme } = usePlaygroundTheme(); @@ -180,6 +220,18 @@ const Playground = memo(function Playground({ .catch(() => toast.show('Failed to copy to clipboard')); }, [toast]); + const detectedLanguage = language ?? parseLanguage(className ?? ''); + const isTypeScript = detectedLanguage !== 'jsx' && detectedLanguage !== 'javascript'; + + const handleResetPreview = useCallback(() => { + setPreviewKey((k) => k + 1); + }, []); + + const handleOpenInStackBlitz = useCallback(async () => { + const { openInStackBlitz } = await import('./sandbox/openInStackBlitz'); + openInStackBlitz(codeRef.current, isTypeScript); + }, [isTypeScript]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.code === 'KeyS' && (event.ctrlKey || event.metaKey)) { @@ -194,13 +246,22 @@ const Playground = memo(function Playground({ return ( - + {!hidePreview && ( )} diff --git a/apps/docs/src/theme/Playground/sandbox/ensureDefaultExport.ts b/apps/docs/src/theme/Playground/sandbox/ensureDefaultExport.ts new file mode 100644 index 0000000000..c56e55939e --- /dev/null +++ b/apps/docs/src/theme/Playground/sandbox/ensureDefaultExport.ts @@ -0,0 +1,25 @@ +export function ensureDefaultExport(code: string): string { + if (/\bexport\s+default\b/.test(code)) { + return code; + } + + const funcMatch = code.match(/^function\s+([A-Z]\w*)\s*\(/m); + if (funcMatch) { + return code.replace(new RegExp(`^(function\\s+${funcMatch[1]})`, 'm'), `export default $1`); + } + + const constMatch = code.match(/^const\s+([A-Z]\w*)\s*=/m); + if (constMatch) { + return `${code}\n\nexport default ${constMatch[1]};`; + } + + if (/^\([^)]*\)\s*=>/.test(code.trimStart())) { + return `const App = ${code.trimStart()}\n\nexport default App;`; + } + + const indented = code + .split('\n') + .map((line) => ' ' + line) + .join('\n'); + return `export default function App() {\n return (\n${indented}\n );\n}`; +} diff --git a/apps/docs/src/theme/Playground/sandbox/generateImports.ts b/apps/docs/src/theme/Playground/sandbox/generateImports.ts new file mode 100644 index 0000000000..73e0ad0e56 --- /dev/null +++ b/apps/docs/src/theme/Playground/sandbox/generateImports.ts @@ -0,0 +1,99 @@ +import { sandboxImportMap as importMap } from '../../ReactLiveScope'; + +type ImportSpecifier = { local: string; exported: string }; + +/** + * Strips string literals and comments from code + */ +function stripNonCode(code: string): string { + return ( + code + // Remove single-line comments + .replace(/\/\/.*$/gm, '') + // Remove multi-line comments + .replace(/\/\*[\s\S]*?\*\//g, '') + // Remove template literals + .replace(/`(?:[^`\\]|\\.)*`/g, '``') + // Remove double-quoted strings + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + // Remove single-quoted strings + .replace(/'(?:[^'\\]|\\.)*'/g, "''") + ); +} + +/** + * Checks if an identifier is declared locally in the code + */ +function isDeclaredLocally(name: string, code: string): boolean { + const patterns = [ + // const prices = ... / let prices / var prices + new RegExp(`(?:const|let|var)\\s+${name}\\b`), + // function formatPrice(...) + new RegExp(`function\\s+${name}\\b`), + // const { title } = props (destructuring in declarations) + new RegExp(`(?:const|let|var)\\s+\\{[^}]*\\b${name}\\b`), + // ({ title, description }) (destructuring in function params) + new RegExp(`\\(\\s*\\{[^)]*(? p.test(code)); +} + +/** + * Checks whether an identifier is used as a value in the code and is not declared locally + */ +function isUsedIdentifier(name: string, strippedCode: string): boolean { + const appearsInCode = new RegExp(`(? { + return entries.reduce((acc, [name, entry]) => { + const exported = entry.exportedAs ?? name; + const existing = acc.get(entry.source) ?? []; + existing.push({ local: name, exported }); + acc.set(entry.source, existing); + return acc; + }, new Map()); +} + +/** + * Scans code for identifiers present in the import map and generates + * the corresponding import statements + */ +export function generateImports(code: string): string { + const strippedCode = stripNonCode(code); + + const usedBySource = groupBySource( + Object.entries(importMap).filter(([name]) => isUsedIdentifier(name, strippedCode)), + ); + + const lines: string[] = ["import React from 'react';"]; + + // Handle React's named imports alongside the default import + const reactImports = usedBySource.get('react'); + if (reactImports && reactImports.length > 0) { + const names = reactImports.map((i) => i.local).join(', '); + lines[0] = `import React, { ${names} } from 'react';`; + usedBySource.delete('react'); + } + + // Generate sorted import statements for remaining packages + [...usedBySource.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .forEach(([source, imports]) => { + const specifiers = imports + .map((i) => (i.local !== i.exported ? `${i.exported} as ${i.local}` : i.local)) + .sort() + .join(', '); + lines.push(`import { ${specifiers} } from '${source}';`); + }); + + return lines.join('\n'); +} diff --git a/apps/docs/src/theme/Playground/sandbox/openInStackBlitz.ts b/apps/docs/src/theme/Playground/sandbox/openInStackBlitz.ts new file mode 100644 index 0000000000..960df14bdf --- /dev/null +++ b/apps/docs/src/theme/Playground/sandbox/openInStackBlitz.ts @@ -0,0 +1,31 @@ +import sdk from '@stackblitz/sdk'; + +import { ensureDefaultExport } from './ensureDefaultExport'; +import { generateImports } from './generateImports'; +import { INDEX_HTML, INDEX_TSX, PACKAGE_JSON, TSCONFIG, VITE_CONFIG } from './templateFiles'; + +/** + * Exports the current playground code as a complete + * Vite + React + CDS project to a new StackBlitz project + */ +export function openInStackBlitz(code: string, isTypeScript = true): void { + const imports = generateImports(code); + const appCode = `${imports}\n\n${ensureDefaultExport(code)}\n`; + const appFileName = isTypeScript ? 'src/App.tsx' : 'src/App.jsx'; + + sdk.openProject( + { + title: 'CDS Example', + template: 'node', + files: { + 'index.html': INDEX_HTML, + 'package.json': PACKAGE_JSON, + 'vite.config.ts': VITE_CONFIG, + 'tsconfig.json': TSCONFIG, + 'src/index.tsx': INDEX_TSX, + [appFileName]: appCode, + }, + }, + { openFile: appFileName }, + ); +} diff --git a/apps/docs/src/theme/Playground/sandbox/templateFiles.ts b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts new file mode 100644 index 0000000000..ae58ee35ad --- /dev/null +++ b/apps/docs/src/theme/Playground/sandbox/templateFiles.ts @@ -0,0 +1,108 @@ +export const INDEX_HTML = ` + + + + + CDS Example + + + + + +
    + + +`; + +export const PACKAGE_JSON = JSON.stringify( + { + name: 'cds-example', + private: true, + type: 'module', + scripts: { + dev: 'vite', + build: 'vite build', + }, + dependencies: { + react: '^18.0.0', + 'react-dom': '^18.0.0', + '@coinbase/cds-web': 'latest', + '@coinbase/cds-common': 'latest', + '@coinbase/cds-icons': 'latest', + '@coinbase/cds-illustrations': 'latest', + '@coinbase/cds-lottie-files': 'latest', + '@coinbase/cds-utils': 'latest', + '@coinbase/cds-web-visualization': 'latest', + 'framer-motion': '^10.18.0', + }, + devDependencies: { + typescript: '^5.0.0', + vite: '^5.0.0', + '@vitejs/plugin-react': '^4.0.0', + '@types/react': '^18.0.0', + '@types/react-dom': '^18.0.0', + }, + }, + null, + 2, +); + +export const VITE_CONFIG = `import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); +`; + +export const TSCONFIG = JSON.stringify( + { + compilerOptions: { + target: 'ES2020', + useDefineForClassFields: true, + lib: ['ES2020', 'DOM', 'DOM.Iterable'], + module: 'ESNext', + skipLibCheck: true, + moduleResolution: 'bundler', + allowImportingTsExtensions: true, + resolveJsonModule: true, + isolatedModules: true, + noEmit: true, + jsx: 'react-jsx', + strict: true, + noUnusedLocals: false, + noUnusedParameters: false, + }, + include: ['src'], + }, + null, + 2, +); + +export const INDEX_TSX = `import '@coinbase/cds-icons/fonts/web/icon-font.css'; +import '@coinbase/cds-web/defaultFontStyles'; +import '@coinbase/cds-web/globalStyles'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { ThemeProvider } from '@coinbase/cds-web/system/ThemeProvider'; +import { MediaQueryProvider } from '@coinbase/cds-web/system/MediaQueryProvider'; +import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; +import App from './App'; + +const root = createRoot(document.getElementById('root')!); +root.render( + + + + + + + + + , +); +`; diff --git a/apps/docs/src/theme/ReactLiveScope/index.tsx b/apps/docs/src/theme/ReactLiveScope/index.tsx index 68a2c2d6a8..36d184ac2e 100644 --- a/apps/docs/src/theme/ReactLiveScope/index.tsx +++ b/apps/docs/src/theme/ReactLiveScope/index.tsx @@ -5,7 +5,6 @@ import { useMergeRefs } from '@coinbase/cds-common/hooks/useMergeRefs'; import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap'; import { useSort } from '@coinbase/cds-common/hooks/useSort'; -import { accounts } from '@coinbase/cds-common/internal/data/accounts'; import * as CDSDataAccounts from '@coinbase/cds-common/internal/data/accounts'; import * as CDSDataAssets from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; @@ -32,40 +31,30 @@ import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; import { useSparklineArea } from '@coinbase/cds-common/visualizations/useSparklineArea'; import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'; import * as CDSLottie from '@coinbase/cds-lottie-files'; -import { Accordion } from '@coinbase/cds-web/accordion/Accordion'; -import { AccordionItem } from '@coinbase/cds-web/accordion/AccordionItem'; +import * as CDSAccordion from '@coinbase/cds-web/accordion'; import { Combobox } from '@coinbase/cds-web/alpha/combobox/Combobox'; +import { DataCard } from '@coinbase/cds-web/alpha/data-card'; import { Select } from '@coinbase/cds-web/alpha/select/Select'; import { SelectChip } from '@coinbase/cds-web/alpha/select-chip/SelectChip'; import { TabbedChips } from '@coinbase/cds-web/alpha/tabbed-chips/TabbedChips'; -import { Lottie, LottieStatusAnimation } from '@coinbase/cds-web/animation'; -import { Banner } from '@coinbase/cds-web/banner/Banner'; +import * as CDSAnimation from '@coinbase/cds-web/animation'; +import * as CDSBanner from '@coinbase/cds-web/banner'; import * as CDSButtons from '@coinbase/cds-web/buttons'; -import { ContainedAssetCard } from '@coinbase/cds-web/cards/ContainedAssetCard'; +import * as CDSCards from '@coinbase/cds-web/cards'; import * as ContentCardComponents from '@coinbase/cds-web/cards/ContentCard'; -import { FloatingAssetCard } from '@coinbase/cds-web/cards/FloatingAssetCard'; -import { NudgeCard } from '@coinbase/cds-web/cards/NudgeCard'; -import { UpsellCard } from '@coinbase/cds-web/cards/UpsellCard'; -import { - Carousel, - CarouselItem, - DefaultCarouselNavigation, - DefaultCarouselPagination, -} from '@coinbase/cds-web/carousel'; +import * as CDSCarousel from '@coinbase/cds-web/carousel'; import * as CDSCells from '@coinbase/cds-web/cells'; -import { Chip } from '@coinbase/cds-web/chips/Chip'; -import { InputChip } from '@coinbase/cds-web/chips/InputChip'; -import { MediaChip } from '@coinbase/cds-web/chips/MediaChip'; +import * as CDSChips from '@coinbase/cds-web/chips'; import { SelectChip as OldSelectChip } from '@coinbase/cds-web/chips/SelectChip'; import { TabbedChips as OldTabbedChips } from '@coinbase/cds-web/chips/TabbedChips'; -import { Coachmark } from '@coinbase/cds-web/coachmark/Coachmark'; -import { Collapsible } from '@coinbase/cds-web/collapsible/Collapsible'; +import * as CDSCoachmark from '@coinbase/cds-web/coachmark'; +import * as CDSCollapsible from '@coinbase/cds-web/collapsible'; import * as CDSControls from '@coinbase/cds-web/controls'; import { InputLabel } from '@coinbase/cds-web/controls/InputLabel'; import { Select as OldSelect } from '@coinbase/cds-web/controls/Select'; import * as CDSDates from '@coinbase/cds-web/dates'; import * as CDSDots from '@coinbase/cds-web/dots'; -import { Dropdown } from '@coinbase/cds-web/dropdown/Dropdown'; +import * as CDSDropdown from '@coinbase/cds-web/dropdown'; import { useA11yControlledVisibility } from '@coinbase/cds-web/hooks/useA11yControlledVisibility'; import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'; import { useCheckboxGroupState } from '@coinbase/cds-web/hooks/useCheckboxGroupState'; @@ -78,168 +67,233 @@ import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; import * as CDSIcons from '@coinbase/cds-web/icons'; import * as CDSIllustrations from '@coinbase/cds-web/illustrations'; import * as CDSLayout from '@coinbase/cds-web/layout'; -import { Spinner } from '@coinbase/cds-web/loaders/Spinner'; +import * as CDSLoaders from '@coinbase/cds-web/loaders'; import * as CDSMedia from '@coinbase/cds-web/media'; -import { MultiContentModule } from '@coinbase/cds-web/multi-content-module/MultiContentModule'; +import * as CDSMultiContentModule from '@coinbase/cds-web/multi-content-module'; import * as CDSNavigation from '@coinbase/cds-web/navigation'; import * as CDSNumbers from '@coinbase/cds-web/numbers'; import * as CDSOverlays from '@coinbase/cds-web/overlays'; import { useToast } from '@coinbase/cds-web/overlays/useToast'; -import { PageFooter } from '@coinbase/cds-web/page/PageFooter'; -import { PageHeader } from '@coinbase/cds-web/page/PageHeader'; -import { Pagination } from '@coinbase/cds-web/pagination/Pagination'; -import { usePagination } from '@coinbase/cds-web/pagination/usePagination'; -import { SectionHeader } from '@coinbase/cds-web/section-header/SectionHeader'; +import * as CDSPage from '@coinbase/cds-web/page'; +import * as CDSPagination from '@coinbase/cds-web/pagination'; +import * as CDSSectionHeader from '@coinbase/cds-web/section-header'; import * as StepperComponents from '@coinbase/cds-web/stepper'; import * as CDSSystem from '@coinbase/cds-web/system'; -import { MediaQueryProvider } from '@coinbase/cds-web/system/MediaQueryProvider'; -import { ThemeProvider } from '@coinbase/cds-web/system/ThemeProvider'; +import { ComponentConfigProvider } from '@coinbase/cds-web/system/ComponentConfigProvider'; import * as CDSTables from '@coinbase/cds-web/tables'; import { useSortableCell } from '@coinbase/cds-web/tables/hooks/useSortableCell'; import * as CDSTabs from '@coinbase/cds-web/tabs'; -import { Tag } from '@coinbase/cds-web/tag/Tag'; +import * as CDSTag from '@coinbase/cds-web/tag'; import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; -import { Tour } from '@coinbase/cds-web/tour/Tour'; -import { TourStep } from '@coinbase/cds-web/tour/TourStep'; +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 { JSONCodeBlock } from '@site/src/components/page/JSONCodeBlock'; -import * as motion from 'framer-motion'; +import * as framerMotion from 'framer-motion'; -import { SparklineInteractivePrice, SparklineInteractivePriceWithHeader } from '../Sparkline'; -// Add react-live imports you need here -const ReactLiveScope: Record = { - React, - ...React, - JSONCodeBlock, - defaultTheme, - // CDS tokens - avatarDotSizeMap, - avatarIconSizeMap, - // hooks - useA11yControlledVisibility, - useCheckboxGroupState, - useTheme, - useMediaQuery, - useToast, - useAlert, - useModal, - OverlayContentContext, - useOverlayContentContext, - // layout - ...CDSLayout, - Collapsible, - Accordion, - AccordionItem, - Carousel, - CarouselItem, - DefaultCarouselNavigation, - DefaultCarouselPagination, - Dropdown, - ...CDSLottie, - Lottie, - LottieStatusAnimation, - MultiContentModule, - SectionHeader, - // data display - ...CDSCells, - ...CDSTables, - // cells - ...CDSCells, - useSort, - useSortableCell, - // overlays - ...CDSOverlays, - // navigation - ...CDSNavigation, - ...CDSTabs, - Pagination, - PageHeader, - PageFooter, - // tour - Tour, - TourStep, - Coachmark, - useTourContext, - // stepper - ...StepperComponents, - useStepper, - // typography - ...CDSTypography, - // numbers - ...CDSNumbers, - Tag, - // input - ...CDSButtons, - ...CDSControls, - InputLabel, - Select, - Combobox, - OldSelect, - useMultiSelect, - ...CDSSystem, - MediaQueryProvider, - // chips - Chip, - InputChip, - MediaChip, - OldSelectChip, - SelectChip, - OldTabbedChips, - TabbedChips, - // loaders - Spinner, - // media - ...CDSMedia, - ...CDSIcons, - ...CDSIllustrations, - // cards - ContainedAssetCard, - FloatingAssetCard, - NudgeCard, - UpsellCard, - ...ContentCardComponents, - // visualizations - btcCandles, - ...CDSChartComponents, - ...CDSVisualizations, - ...CDSSparklineComponents, - useSparklinePath, - useSparklineArea, - SparklineInteractivePrice, - SparklineInteractivePriceWithHeader, - sparklineInteractiveData, - sparklineInteractiveHoverData, - // other - ...CDSDots, - ...CDSDates, - LocaleProvider, - DateInputValidationError, - Banner, - // utils - ...CDSDataAssets, - ...CDSDataAccounts, - loremIpsum, - prices, - accounts, - users, - product, - ...motion, - // hooks - useBreakpoints, - useDimensions, - useScrollBlocker, - useHasMounted, - usePreviousValue, - useIsoEffect, - useMergeRefs, - useRefMap, - useEventHandler, - usePagination, - useTabsContext, - ThemeProvider, +export type ImportMapEntry = { + source: string; + /** When the local name differs from the exported name, e.g. { candles as btcCandles } */ + exportedAs?: string; +}; + +/** + * Barrel package registrations. All runtime exports are auto-captured for + * both the react-live scope and the sandbox import map. When a new component + * is added to one of these packages, it is automatically available. + */ +const namespaceRegistrations: [Record, string][] = [ + [React, 'react'], + [CDSLayout, '@coinbase/cds-web/layout'], + [CDSButtons, '@coinbase/cds-web/buttons'], + [CDSTypography, '@coinbase/cds-web/typography'], + [CDSControls, '@coinbase/cds-web/controls'], + [CDSOverlays, '@coinbase/cds-web/overlays'], + [CDSTables, '@coinbase/cds-web/tables'], + [CDSTabs, '@coinbase/cds-web/tabs'], + [CDSNavigation, '@coinbase/cds-web/navigation'], + [CDSSystem, '@coinbase/cds-web/system'], + [CDSMedia, '@coinbase/cds-web/media'], + [CDSIcons, '@coinbase/cds-web/icons'], + [CDSIllustrations, '@coinbase/cds-web/illustrations'], + [CDSCells, '@coinbase/cds-web/cells'], + [CDSDots, '@coinbase/cds-web/dots'], + [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'], + [StepperComponents, '@coinbase/cds-web/stepper'], + [ContentCardComponents, '@coinbase/cds-web/cards/ContentCard'], + [CDSDataAssets, '@coinbase/cds-common/internal/data/assets'], + [CDSDataAccounts, '@coinbase/cds-common/internal/data/accounts'], + [CDSLottie, '@coinbase/cds-lottie-files'], + [framerMotion, 'framer-motion'], + [CDSAccordion, '@coinbase/cds-web/accordion'], + [CDSAnimation, '@coinbase/cds-web/animation'], + [CDSBanner, '@coinbase/cds-web/banner'], + [CDSCards, '@coinbase/cds-web/cards'], + [CDSCarousel, '@coinbase/cds-web/carousel'], + [CDSChips, '@coinbase/cds-web/chips'], + [CDSCoachmark, '@coinbase/cds-web/coachmark'], + [CDSCollapsible, '@coinbase/cds-web/collapsible'], + [CDSDropdown, '@coinbase/cds-web/dropdown'], + [CDSLoaders, '@coinbase/cds-web/loaders'], + [CDSMultiContentModule, '@coinbase/cds-web/multi-content-module'], + [CDSPage, '@coinbase/cds-web/page'], + [CDSPagination, '@coinbase/cds-web/pagination'], + [CDSSectionHeader, '@coinbase/cds-web/section-header'], + [CDSTag, '@coinbase/cds-web/tag'], + [CDSTour, '@coinbase/cds-web/tour'], +]; + +type ExplicitEntry = { value: unknown; source: string; exportedAs?: string }; + +/** + * Individual registrations for identifiers that come from specific subpaths + * (not in a barrel above), that override a barrel export with a different + * package (e.g. Select from alpha instead of controls), or use an alias. + * + * To add a new identifier: + * 1. Add the import statement at the top of this file + * 2. Add entry here + */ +const explicitRegistrations: Record = { + // Alpha overrides (replace barrel versions from CDSControls / chips) + Select: { value: Select, source: '@coinbase/cds-web/alpha/select/Select' }, + SelectChip: { value: SelectChip, source: '@coinbase/cds-web/alpha/select-chip/SelectChip' }, + TabbedChips: { value: TabbedChips, source: '@coinbase/cds-web/alpha/tabbed-chips/TabbedChips' }, + + // Aliased imports + OldSelect: { + value: OldSelect, + source: '@coinbase/cds-web/controls/Select', + exportedAs: 'Select', + }, + OldSelectChip: { + value: OldSelectChip, + source: '@coinbase/cds-web/chips/SelectChip', + exportedAs: 'SelectChip', + }, + OldTabbedChips: { + value: OldTabbedChips, + source: '@coinbase/cds-web/chips/TabbedChips', + exportedAs: 'TabbedChips', + }, + + // Alpha components from specific subpaths + Combobox: { value: Combobox, source: '@coinbase/cds-web/alpha/combobox/Combobox' }, + DataCard: { value: DataCard, source: '@coinbase/cds-web/alpha/data-card' }, + + // Components not exported from their barrel + InputLabel: { value: InputLabel, source: '@coinbase/cds-web/controls/InputLabel' }, + ComponentConfigProvider: { + value: ComponentConfigProvider, + source: '@coinbase/cds-web/system/ComponentConfigProvider', + }, + useToast: { value: useToast, source: '@coinbase/cds-web/overlays/useToast' }, + useSortableCell: { + value: useSortableCell, + source: '@coinbase/cds-web/tables/hooks/useSortableCell', + }, + defaultTheme: { value: defaultTheme, source: '@coinbase/cds-web/themes/defaultTheme' }, + + // CDS web hooks (no barrel for hooks/) + useA11yControlledVisibility: { + value: useA11yControlledVisibility, + source: '@coinbase/cds-web/hooks/useA11yControlledVisibility', + }, + useBreakpoints: { value: useBreakpoints, source: '@coinbase/cds-web/hooks/useBreakpoints' }, + useCheckboxGroupState: { + value: useCheckboxGroupState, + source: '@coinbase/cds-web/hooks/useCheckboxGroupState', + }, + useDimensions: { value: useDimensions, source: '@coinbase/cds-web/hooks/useDimensions' }, + useHasMounted: { value: useHasMounted, source: '@coinbase/cds-web/hooks/useHasMounted' }, + useIsoEffect: { value: useIsoEffect, source: '@coinbase/cds-web/hooks/useIsoEffect' }, + useMediaQuery: { value: useMediaQuery, source: '@coinbase/cds-web/hooks/useMediaQuery' }, + useScrollBlocker: { value: useScrollBlocker, source: '@coinbase/cds-web/hooks/useScrollBlocker' }, + useTheme: { value: useTheme, source: '@coinbase/cds-web/hooks/useTheme' }, + + // CDS common hooks & providers + useAlert: { value: useAlert, source: '@coinbase/cds-common/overlays/useAlert' }, + useModal: { value: useModal, source: '@coinbase/cds-common/overlays/useModal' }, + OverlayContentContext: { + value: OverlayContentContext, + source: '@coinbase/cds-common/overlays/OverlayContentContext', + }, + useOverlayContentContext: { + value: useOverlayContentContext, + source: '@coinbase/cds-common/overlays/OverlayContentContext', + }, + useMultiSelect: { value: useMultiSelect, source: '@coinbase/cds-common/select/useMultiSelect' }, + useStepper: { value: useStepper, source: '@coinbase/cds-common/stepper/useStepper' }, + useTabsContext: { value: useTabsContext, source: '@coinbase/cds-common/tabs/TabsContext' }, + useTourContext: { value: useTourContext, source: '@coinbase/cds-common/tour/TourContext' }, + useSort: { value: useSort, source: '@coinbase/cds-common/hooks/useSort' }, + useEventHandler: { value: useEventHandler, source: '@coinbase/cds-common/hooks/useEventHandler' }, + useMergeRefs: { value: useMergeRefs, source: '@coinbase/cds-common/hooks/useMergeRefs' }, + usePreviousValue: { + value: usePreviousValue, + source: '@coinbase/cds-common/hooks/usePreviousValue', + }, + useRefMap: { value: useRefMap, source: '@coinbase/cds-common/hooks/useRefMap' }, + useSparklineArea: { + value: useSparklineArea, + source: '@coinbase/cds-common/visualizations/useSparklineArea', + }, + useSparklinePath: { + value: useSparklinePath, + source: '@coinbase/cds-common/visualizations/useSparklinePath', + }, + LocaleProvider: { value: LocaleProvider, source: '@coinbase/cds-common/system/LocaleProvider' }, + DateInputValidationError: { + value: DateInputValidationError, + source: '@coinbase/cds-common/dates/DateInputValidationError', + }, + avatarDotSizeMap: { value: avatarDotSizeMap, source: '@coinbase/cds-common/tokens/dot' }, + avatarIconSizeMap: { value: avatarIconSizeMap, source: '@coinbase/cds-common/tokens/dot' }, + + // CDS common data + btcCandles: { + value: btcCandles, + source: '@coinbase/cds-common/internal/data/candles', + exportedAs: 'candles', + }, + loremIpsum: { value: loremIpsum, source: '@coinbase/cds-common/internal/data/loremIpsum' }, + prices: { value: prices, source: '@coinbase/cds-common/internal/data/prices' }, + product: { value: product, source: '@coinbase/cds-common/internal/data/product' }, + users: { value: users, source: '@coinbase/cds-common/internal/data/users' }, + sparklineInteractiveData: { + value: sparklineInteractiveData, + source: '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData', + }, + sparklineInteractiveHoverData: { + value: sparklineInteractiveHoverData, + source: '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData', + }, }; -export default ReactLiveScope; +const liveScope: Record = { React }; +const importMapResult: Record = {}; + +for (const [ns, source] of namespaceRegistrations) { + Object.assign(liveScope, ns); + for (const key of Object.keys(ns)) { + if (key.startsWith('_') || key === '__esModule') continue; + if (typeof (ns as Record)[key] === 'undefined') continue; + importMapResult[key] = { source }; + } +} + +for (const [name, entry] of Object.entries(explicitRegistrations)) { + liveScope[name] = entry.value; + importMapResult[name] = { + source: entry.source, + ...(entry.exportedAs ? { exportedAs: entry.exportedAs } : {}), + }; +} + +export const sandboxImportMap: Record = importMapResult; +export default liveScope; diff --git a/apps/docs/src/theme/Root.tsx b/apps/docs/src/theme/Root.tsx index 5e5991f459..2ab977819e 100644 --- a/apps/docs/src/theme/Root.tsx +++ b/apps/docs/src/theme/Root.tsx @@ -9,10 +9,8 @@ export default function Root({ children }: { children: React.ReactNode }) { const { postMetric } = useAnalytics(); useEffect(() => { - if (window.location.hash) { - const elementId = window.location.hash.slice(1); - const element = document.getElementById(elementId); - + if (location.hash) { + const elementId = location.hash.slice(1); const startTime = Date.now(); const intervalId = setInterval(() => { @@ -31,7 +29,7 @@ export default function Root({ children }: { children: React.ReactNode }) { return () => clearInterval(intervalId); } - }, []); + }, [location.hash]); // Track page view events useEffect(() => { diff --git a/apps/docs/src/theme/Sparkline/index.tsx b/apps/docs/src/theme/Sparkline/index.tsx deleted file mode 100644 index ab9eecb51e..0000000000 --- a/apps/docs/src/theme/Sparkline/index.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; -import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import type { SparklinePeriod } from '@coinbase/cds-common/internal/visualizations/SparklineInteractiveData'; -import type { ChartData, ChartDataPoint, ChartScrubParams } from '@coinbase/cds-common/types'; -import { - SparklineInteractive, - type SparklineInteractiveBaseProps, -} from '@coinbase/cds-web-visualization/sparkline/sparkline-interactive/SparklineInteractive'; -import { - SparklineInteractiveHeader, - type SparklineInteractiveHeaderRef, - type SparklineInteractiveSubHead, -} from '@coinbase/cds-web-visualization/sparkline/sparkline-interactive-header/SparklineInteractiveHeader'; - -type SparklineInteractivePriceProps = Omit< - SparklineInteractiveBaseProps, - 'periods' | 'defaultPeriod' | 'formatMinMaxLabel' | 'formatDate' -> & - Partial, 'defaultPeriod'>> & { - hideHoverDate?: boolean; - trailing?: React.ReactNode; - gutter?: ThemeVars.Space; - disableHorizontalPadding?: boolean; - timePeriodGutter?: ThemeVars.Space; - labelNode?: React.ReactNode; - allowOverflowGestures?: boolean; - }; - -const DEFAULT_PERIOD = 'day'; -const DATE_TIME_OPTIONS = { - weekday: 'long', - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', -} as const; - -const periods = [ - { - label: '1H', - value: 'hour' as const, - }, - { - label: '1D', - value: 'day' as const, - }, - { - label: '1W', - value: 'week' as const, - }, - { - label: '1M', - value: 'month' as const, - }, - { - label: '1Y', - value: 'year' as const, - }, - { - label: 'All', - value: 'all' as const, - }, -]; - -function numToLocaleString(num: number) { - return num.toLocaleString('en-US', { - maximumFractionDigits: 2, - }); -} - -const getFormattingConfigForPeriod = (period: SparklinePeriod) => { - switch (period) { - case 'hour': - case 'day': - return { - hour: 'numeric', - minute: 'numeric', - } as const; - - case 'week': - case 'month': - return { - month: 'numeric', - day: 'numeric', - } as const; - - case 'year': - case 'all': - return { - month: 'numeric', - year: 'numeric', - } as const; - } -}; - -const getDateHoverOptions = (period: SparklinePeriod) => { - switch (period) { - case 'hour': - case 'day': - case 'week': - case 'month': - return { - weekday: 'short', - month: 'short', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - } as const; - default: - return { - weekday: 'short', - year: 'numeric', - month: 'short', - day: 'numeric', - } as const; - } -}; - -export function generateSubHead( - point: ChartDataPoint, - period: SparklinePeriod, - sparklineInteractiveData: Record, -): SparklineInteractiveSubHead { - const data = sparklineInteractiveData[period]; - const firstPoint = data[0]; - - const increase = point.value > firstPoint.value; - const subHead: SparklineInteractiveSubHead = { - percent: `${numToLocaleString( - Math.abs((point.value - firstPoint.value) / firstPoint.value) * 100, - )}%`, - sign: increase ? 'upwardTrend' : 'downwardTrend', - variant: increase ? 'positive' : 'negative', - accessibilityLabel: `on ${new Intl.DateTimeFormat('en-US', DATE_TIME_OPTIONS).format( - point?.date, - )}, ${increase ? 'up' : 'down'}`, - priceChange: `$${numToLocaleString(Math.abs(point.value - firstPoint.value))}`, - }; - - return subHead; -} - -export const SparklineInteractivePrice = memo( - ({ defaultPeriod, hideHoverDate, ...props }: SparklineInteractivePriceProps) => { - const timezoneObj = useMemo(() => { - return { timeZone: 'America/New_York' }; - }, []); - - const formatDateWithConfig = useCallback( - (value: Date, period: string) => { - const config = getFormattingConfigForPeriod(period as SparklinePeriod); - - return value.toLocaleString('en-US', { - ...timezoneObj, - ...config, - }); - }, - [timezoneObj], - ); - - const formatHoverDate = useCallback( - (date: Date, period: string) => { - return date.toLocaleString('en-US', { - ...timezoneObj, - ...getDateHoverOptions(period as SparklinePeriod), - }); - }, - [timezoneObj], - ); - - return ( - - ); - }, -); - -export const SparklineInteractivePriceWithHeader = memo((props: SparklineInteractivePriceProps) => { - const { data: sparklineData, trailing, labelNode, compact } = props; - const sparklineInteractiveData = sparklineData as Record; - const headerRef = useRef(null); - const [currentPeriod, setCurrentPeriod] = useState( - props.defaultPeriod ?? DEFAULT_PERIOD, - ); - const data = sparklineInteractiveData[currentPeriod]; - const lastPoint = data[data.length - 1]; - - const handleScrub = useCallback( - ({ point, period }: ChartScrubParams) => { - headerRef.current?.update({ - title: `$${point.value.toLocaleString('en-US')}`, - subHead: generateSubHead(point, period, sparklineInteractiveData), - }); - }, - [sparklineInteractiveData], - ); - - const handleScrubEnd = useCallback(() => { - headerRef.current?.update({ - title: `$${numToLocaleString(lastPoint.value)}`, - subHead: generateSubHead(lastPoint, currentPeriod, sparklineInteractiveData), - }); - }, [currentPeriod, sparklineInteractiveData, lastPoint]); - - const handleOnPeriodChanged = useCallback( - (period: SparklinePeriod) => { - setCurrentPeriod(period); - - const newData = sparklineInteractiveData[period]; - const newLastPoint = newData[newData.length - 1]; - - headerRef.current?.update({ - title: `$${numToLocaleString(newLastPoint.value)}`, - subHead: generateSubHead(newLastPoint, period, sparklineInteractiveData), - }); - }, - [sparklineInteractiveData], - ); - - const header = ( - - ); - - return ( - - ); -}); diff --git a/apps/docs/src/utils/__tests__/isTypeAlias.test.ts b/apps/docs/src/utils/__tests__/isTypeAlias.test.ts new file mode 100644 index 0000000000..1c8e01c5ef --- /dev/null +++ b/apps/docs/src/utils/__tests__/isTypeAlias.test.ts @@ -0,0 +1,49 @@ +const isTypeAlias = require('../isTypeAlias'); + +describe('isTypeAlias', () => { + function prop(raw: string, value: unknown[]) { + return { type: { raw, value } }; + } + + it('returns true for uppercase raw name with 2+ values', () => { + expect(isTypeAlias(prop('SpacingScale', [{ value: '0' }, { value: '1' }]))).toBe(true); + }); + + it('returns true for uppercase raw name with many values', () => { + expect( + isTypeAlias(prop('IconName', [{ value: 'add' }, { value: 'remove' }, { value: 'search' }])), + ).toBe(true); + }); + + it('returns false when raw starts with lowercase', () => { + expect(isTypeAlias(prop('spacingScale', [{ value: '0' }, { value: '1' }]))).toBe(false); + }); + + it('returns false when raw contains a pipe (union type literal)', () => { + expect(isTypeAlias(prop('SpacingScale | number', [{ value: '0' }, { value: '1' }]))).toBe( + false, + ); + }); + + it('returns false when value has fewer than 2 items', () => { + expect(isTypeAlias(prop('SpacingScale', [{ value: '0' }]))).toBe(false); + }); + + it('returns false when value is empty', () => { + expect(isTypeAlias(prop('SpacingScale', []))).toBe(false); + }); + + it('returns false when value is not an array', () => { + expect(isTypeAlias({ type: { raw: 'SpacingScale', value: 'string' } })).toBe(false); + }); + + it('returns false for empty raw string', () => { + expect(isTypeAlias(prop('', [{ value: '0' }, { value: '1' }]))).toBe(false); + }); + + it('returns false when raw is undefined', () => { + expect(isTypeAlias({ type: { raw: undefined, value: [{ value: 'a' }, { value: 'b' }] } })).toBe( + false, + ); + }); +}); diff --git a/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts b/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts new file mode 100644 index 0000000000..a74823ebb2 --- /dev/null +++ b/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts @@ -0,0 +1,88 @@ +const shouldAddToParentTypes = require('../shouldAddToParentTypes'); + +function doc(displayName: string) { + return { displayName }; +} + +function prop(name: string, parent: string, required = false) { + return { name, parent, required }; +} + +describe('shouldAddToParentTypes', () => { + describe('required props are always kept in the props table', () => { + it('returns false for required props even with parent type match', () => { + expect(shouldAddToParentTypes(doc('Button'), prop('label', 'HTMLAttributes', true))).toBe( + false, + ); + }); + }); + + describe('always-included prop names', () => { + const alwaysIncluded = ['onChange', 'onPress', 'testID', 'type', 'value']; + + it.each(alwaysIncluded)('keeps %s in props table regardless of parent', (name) => { + expect(shouldAddToParentTypes(doc('Input'), prop(name, 'HTMLAttributes'))).toBe(false); + }); + }); + + describe('layout parent types', () => { + const layoutParents = [ + 'BorderedStyles', + 'BoxBaseProps', + 'DimensionStyles', + 'FlexProps', + 'PositionStyles', + 'SpacingProps', + ]; + + it.each(layoutParents)('moves %s props to parent types for non-Box components', (parent) => { + expect(shouldAddToParentTypes(doc('VStack'), prop('gap', parent))).toBe(true); + }); + + it.each(layoutParents)('keeps %s props in table for Box component', (parent) => { + expect(shouldAddToParentTypes(doc('Box'), prop('gap', parent))).toBe(false); + }); + }); + + describe('pressable parent types', () => { + const pressableParents = ['LinkableProps', 'PressableProps', 'Touchable']; + + it.each(pressableParents)( + 'moves %s props to parent types for non-Pressable components', + (parent) => { + expect(shouldAddToParentTypes(doc('Button'), prop('onPressIn', parent))).toBe(true); + }, + ); + + it.each(pressableParents)('keeps %s props in table for Pressable component', (parent) => { + expect(shouldAddToParentTypes(doc('Pressable'), prop('onPressIn', parent))).toBe(false); + }); + }); + + describe('always-moved parent types', () => { + const alwaysMoved = [ + 'AccessibilityProps', + 'AriaAttributes', + 'ComponentEventHandlerProps', + 'DOMAttributes', + 'GestureResponderHandlers', + 'HTMLAttributes', + 'TVViewProps', + 'ViewProps', + ]; + + it.each(alwaysMoved)('moves %s props to parent types', (parent) => { + expect(shouldAddToParentTypes(doc('Button'), prop('aria-label', parent))).toBe(true); + }); + }); + + describe('custom component props stay in table', () => { + it('returns false for non-matching parent types', () => { + expect(shouldAddToParentTypes(doc('Button'), prop('variant', 'ButtonProps'))).toBe(false); + }); + + it('returns false for component-specific parents', () => { + expect(shouldAddToParentTypes(doc('Avatar'), prop('size', 'AvatarBaseProps'))).toBe(false); + }); + }); +}); diff --git a/apps/docs/static/img/campaignCardBanners/color-pairing-tool.svg b/apps/docs/static/img/campaignCardBanners/color-pairing-tool.svg new file mode 100644 index 0000000000..b0d534b2e2 --- /dev/null +++ b/apps/docs/static/img/campaignCardBanners/color-pairing-tool.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/static/img/campaignCardBanners/color-pairing-tool_dark.svg b/apps/docs/static/img/campaignCardBanners/color-pairing-tool_dark.svg new file mode 100644 index 0000000000..a21f9b0e23 --- /dev/null +++ b/apps/docs/static/img/campaignCardBanners/color-pairing-tool_dark.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/static/img/componentCardBanners/graphs_dark.svg b/apps/docs/static/img/componentCardBanners/charts_dark.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_dark.svg rename to apps/docs/static/img/componentCardBanners/charts_dark.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_dark_hover.svg b/apps/docs/static/img/componentCardBanners/charts_dark_hover.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_dark_hover.svg rename to apps/docs/static/img/componentCardBanners/charts_dark_hover.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_light.svg b/apps/docs/static/img/componentCardBanners/charts_light.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_light.svg rename to apps/docs/static/img/componentCardBanners/charts_light.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_light_hover.svg b/apps/docs/static/img/componentCardBanners/charts_light_hover.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_light_hover.svg rename to apps/docs/static/img/componentCardBanners/charts_light_hover.svg diff --git a/apps/docs/static/img/logos/frameworks/expo_dark.png b/apps/docs/static/img/logos/frameworks/expo_dark.png new file mode 100644 index 0000000000..52ae390f3b Binary files /dev/null and b/apps/docs/static/img/logos/frameworks/expo_dark.png differ diff --git a/apps/docs/static/img/logos/frameworks/expo_light.png b/apps/docs/static/img/logos/frameworks/expo_light.png new file mode 100644 index 0000000000..ef1c5458bc Binary files /dev/null and b/apps/docs/static/img/logos/frameworks/expo_light.png differ diff --git a/apps/docs/static/img/tray_header.png b/apps/docs/static/img/tray_header.png new file mode 100644 index 0000000000..7687a86d6e Binary files /dev/null and b/apps/docs/static/img/tray_header.png differ diff --git a/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts b/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts new file mode 100644 index 0000000000..c660e7ff64 --- /dev/null +++ b/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts @@ -0,0 +1,93 @@ +import { loadPeerDependencyVersions, syncDependencyVersions } from '../generateComponentPeerDeps'; + +describe('syncDependencyVersions', () => { + const peerDepVersions = new Map([ + ['react-native-reanimated', '^3.14.0'], + ['react-native-gesture-handler', '^2.16.2'], + ['framer-motion', '^10.18.0'], + ['react-dom', '^18.3.1'], + ]); + + it('updates stale versions to match current peerDependencies', () => { + const deps = [{ name: 'framer-motion', version: '^9.0.0' }]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([{ name: 'framer-motion', version: '^10.18.0' }]); + expect(warnings).toEqual([]); + }); + + it('leaves up-to-date versions unchanged', () => { + const deps = [{ name: 'framer-motion', version: '^10.18.0' }]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual(deps); + }); + + it('syncs multiple dependencies at once', () => { + const deps = [ + { name: 'framer-motion', version: '^9.0.0' }, + { name: 'react-dom', version: '^17.0.0' }, + ]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([ + { name: 'framer-motion', version: '^10.18.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]); + }); + + it('warns and preserves deps not found in peerDependencies', () => { + const deps = [{ name: 'unknown-package', version: '^1.0.0' }]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([{ name: 'unknown-package', version: '^1.0.0' }]); + expect(warnings).toEqual([ + 'unknown-package is not listed in any package.json peerDependencies', + ]); + }); + + it('returns empty array for empty dependencies', () => { + const { synced, warnings } = syncDependencyVersions([], peerDepVersions); + expect(synced).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('handles mix of known and unknown deps', () => { + const deps = [ + { name: 'framer-motion', version: '^9.0.0' }, + { name: 'not-a-real-dep', version: '^1.0.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([ + { name: 'framer-motion', version: '^10.18.0' }, + { name: 'not-a-real-dep', version: '^1.0.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]); + expect(warnings).toHaveLength(1); + }); + + it('preserves extra fields on dependency objects', () => { + const deps = [{ name: 'framer-motion', version: '^9.0.0', url: 'https://example.com' }]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced[0]).toEqual({ + name: 'framer-motion', + version: '^10.18.0', + url: 'https://example.com', + }); + }); +}); + +describe('loadPeerDependencyVersions', () => { + it('loads versions from real package.json files', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.size).toBeGreaterThan(0); + expect(versions.get('react')).toBeDefined(); + }); + + it('includes mobile-specific peer deps', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.get('react-native')).toBeDefined(); + }); + + it('includes web-specific peer deps', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.get('framer-motion')).toBeDefined(); + }); +}); diff --git a/apps/docs/utils/generateComponentPeerDeps.ts b/apps/docs/utils/generateComponentPeerDeps.ts index 8bce4cdc71..4a009a0815 100644 --- a/apps/docs/utils/generateComponentPeerDeps.ts +++ b/apps/docs/utils/generateComponentPeerDeps.ts @@ -3,269 +3,187 @@ import fs from 'fs'; import { glob } from 'glob'; import path from 'path'; -import type { Dependency } from '../src/components/page/ComponentHeader'; - -type ComponentPeerDeps = { - [componentName: string]: { - filePath: string; - peerDependencies: Dependency[]; - exportPath: string; - }; -}; +import type { Dependency } from '../src/components/page/Metadata'; -type PackageAnalysis = { - web: ComponentPeerDeps; - mobile: ComponentPeerDeps; +type PackageConfig = { + packageName: string; + packageDir: string; }; -const PEER_DEPS_TO_IGNORE = ['react', 'react-native']; - -function extractImports(fileContent: string): string[] { - const importRegex = /import[\s\S]*?from\s+['"]([^'"]+)['"]/g; - const imports: string[] = []; - let match; - - while ((match = importRegex.exec(fileContent)) !== null) { - imports.push(match[1]); - } - - return imports; -} - -function getPackageName(importPath: string): string { - if (importPath.startsWith('@')) { - const parts = importPath.split('/'); - return parts.length > 1 ? `${parts[0]}/${parts[1]}` : parts[0]; +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', + }, +]; + +/** + * Build a combined map of peer dependency versions from all known packages. + * Keys are dependency names, values are the version range from package.json. + */ +function loadPeerDependencyVersions(): Map { + const versions = new Map(); + for (const pkg of PACKAGES) { + try { + const packageJson = JSON.parse(fs.readFileSync(`${pkg.packageDir}/package.json`, 'utf-8')); + const peerDeps: Record = packageJson.peerDependencies ?? {}; + for (const [name, version] of Object.entries(peerDeps)) { + versions.set(name, version); + } + } catch { + // skip if package.json can't be read + } } - return importPath.split('/')[0]; + return versions; } -function isExternalDependency(importPath: string): boolean { - return !importPath.startsWith('.') && !importPath.startsWith('/'); +/** + * Given a metadata object with a `dependencies` array and the current peer + * dependency version map, return a copy with versions synced. Only updates + * versions for deps already listed -- never adds or removes entries. + */ +function syncDependencyVersions( + dependencies: Dependency[], + peerDepVersions: Map, +): { synced: Dependency[]; warnings: string[] } { + const warnings: string[] = []; + const synced = dependencies.map((dep) => { + const currentVersion = peerDepVersions.get(dep.name); + if (!currentVersion) { + warnings.push(`${dep.name} is not listed in any package.json peerDependencies`); + return dep; + } + return { ...dep, version: currentVersion }; + }); + return { synced, warnings }; } -function getExportPath(filePath: string, platform: 'web' | 'mobile'): string { - const srcPath = `packages/${platform}/src/`; - const relativePath = filePath.replace(srcPath, ''); - const dir = path.dirname(relativePath); - return dir === '.' ? '' : `/${dir}`; -} +type MetadataFileResult = { + filePath: string; + updated: boolean; + warnings: string[]; +}; -async function analyzePackageForDocs( - packagePath: string, - platform: 'web' | 'mobile', - packageJson: any, -): Promise { - const packagePeerDependencies = packageJson.peerDependencies; - const componentFiles = await glob(`${packagePath}/src/**/*.tsx`, { - ignore: ['**/__tests__/**', '**/__stories__/**', '**/index.ts'], - }); +async function updateMetadataFiles(peerDepVersions: Map): Promise { + console.log('Syncing peer dependency versions in metadata files...'); - const results: ComponentPeerDeps = {}; + const metadataFiles = await glob('apps/docs/docs/components/**/*Metadata.json'); + const results: MetadataFileResult[] = []; - for (const filePath of componentFiles) { + for (const metadataFile of metadataFiles) { try { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const imports = extractImports(fileContent); - - const componentName = path.basename(filePath, '.tsx'); - const peerDependencies: Dependency[] = []; - - for (const importPath of imports) { - if (isExternalDependency(importPath)) { - const packageName = getPackageName(importPath); - if ( - Object.keys(packagePeerDependencies).includes(packageName) && - !PEER_DEPS_TO_IGNORE.includes(packageName) - ) { - peerDependencies.push({ - name: packageName, - version: packagePeerDependencies[packageName], - }); - } - } - } + const fileName = path.basename(metadataFile); + if (fileName !== 'webMetadata.json' && fileName !== 'mobileMetadata.json') continue; - // Only include components that are actually exported - const exportPath = getExportPath(filePath, platform); - const hasExport = - packageJson.exports && - (packageJson.exports[`.${exportPath}`] || - packageJson.exports[`.${exportPath}/${componentName}`]); - - if (hasExport || peerDependencies.length > 0) { - results[componentName] = { - filePath, - peerDependencies: peerDependencies.sort((a, b) => a.name.localeCompare(b.name)), - exportPath: exportPath || 'root', - }; + const raw = fs.readFileSync(metadataFile, 'utf-8'); + const metadata = JSON.parse(raw); + const deps: Dependency[] = metadata.dependencies ?? []; + if (deps.length === 0) continue; + + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + const changed = JSON.stringify(deps) !== JSON.stringify(synced); + + if (changed) { + metadata.dependencies = synced; + fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2) + '\n'); } + + results.push({ filePath: metadataFile, updated: changed, warnings }); } catch (error) { - console.error(`Error analyzing ${filePath}:`, error); + console.error(`Error processing ${metadataFile}:`, error); } } - return results; -} + const updatedCount = results.filter((r) => r.updated).length; + const warningResults = results.filter((r) => r.warnings.length > 0); -function generateDocumentationTable(analysis: PackageAnalysis): string { - let documentation = '# Component Peer Dependencies\n\n'; - documentation += - 'This document lists the peer dependencies required for each component when importing individually.\n\n'; - - // Web Components - documentation += '## Web Components (@coinbase/cds-web)\n\n'; - documentation += '| Component | Import Path | Peer Dependencies |\n'; - documentation += '|-----------|-------------|-------------------|\n'; - - const webComponents = Object.entries(analysis.web).sort(([a], [b]) => a.localeCompare(b)); - for (const [componentName, info] of webComponents) { - const importPath = `@coinbase/cds-web${info.exportPath === 'root' ? '' : info.exportPath}`; - const peerDeps = - info.peerDependencies.length > 0 - ? info.peerDependencies.map((d) => `${d.name}@${d.version}`).join(', ') - : 'react'; - documentation += `| ${componentName} | \`${importPath}\` | ${peerDeps} |\n`; - } + console.log(`\nVersion sync complete:`); + console.log(`- Files updated: ${updatedCount}`); - // Mobile Components - documentation += '\n## Mobile Components (@coinbase/cds-mobile)\n\n'; - documentation += '| Component | Import Path | Peer Dependencies |\n'; - documentation += '|-----------|-------------|-------------------|\n'; - - const mobileComponents = Object.entries(analysis.mobile).sort(([a], [b]) => a.localeCompare(b)); - for (const [componentName, info] of mobileComponents) { - const importPath = `@coinbase/cds-mobile${info.exportPath === 'root' ? '' : info.exportPath}`; - const peerDeps = - info.peerDependencies.length > 0 - ? info.peerDependencies.map((d) => `${d.name}@${d.version}`).join(', ') - : 'react'; - documentation += `| ${componentName} | \`${importPath}\` | ${peerDeps} |\n`; + if (warningResults.length > 0) { + console.warn(`\nWarnings:`); + for (const r of warningResults) { + for (const w of r.warnings) { + console.warn(` ${r.filePath}: ${w}`); + } + } } - - return documentation; } -function generateJSONOutput(analysis: PackageAnalysis): string { - return JSON.stringify(analysis, null, 2); -} +async function checkMetadataFiles(peerDepVersions: Map): Promise { + console.log('Checking metadata files for outdated peer dependency versions...'); -async function updateMetadataFiles(analysis: PackageAnalysis): Promise { - console.log('Updating metadata files with peer dependency information...'); - - // Find all metadata files in the docs directory const metadataFiles = await glob('apps/docs/docs/components/**/*Metadata.json'); - - let updatedFiles = 0; - let notFoundComponents = 0; + const outdatedFiles: string[] = []; for (const metadataFile of metadataFiles) { try { - const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8')); const fileName = path.basename(metadataFile); - const isWeb = fileName === 'webMetadata.json'; - const isMobile = fileName === 'mobileMetadata.json'; + if (fileName !== 'webMetadata.json' && fileName !== 'mobileMetadata.json') continue; - if (!isWeb && !isMobile) continue; + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8')); + const deps: Dependency[] = metadata.dependencies ?? []; + if (deps.length === 0) continue; - // Extract component name from the import statement - const importMatch = metadata.import?.match(/import\s*{\s*([^}]+)\s*}/); - if (!importMatch) { - console.warn(`Could not extract component name from: ${metadataFile}`); - continue; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + if (JSON.stringify(deps) !== JSON.stringify(synced)) { + outdatedFiles.push(metadataFile); } + } catch { + // skip unparseable files + } + } - const componentName = importMatch[1].trim(); - const platform = isWeb ? 'web' : 'mobile'; - const componentData = analysis[platform][componentName]; - - if (componentData) { - // Add peer dependencies to metadata - metadata.dependencies = componentData.peerDependencies; - - // Write back to file with pretty formatting - fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2) + '\n'); - updatedFiles++; - } else { - console.warn(`Component ${componentName} not found in ${platform} analysis`); - notFoundComponents++; - } - } catch (error) { - console.error(`Error updating ${metadataFile}:`, error); + if (outdatedFiles.length > 0) { + console.error( + `\n${outdatedFiles.length} metadata file(s) have outdated peer dependency versions:`, + ); + for (const file of outdatedFiles) { + console.error(` - ${file}`); } + console.error('\nRun "yarn nx run docs:peer-dependencies" to update them.'); + return false; } - console.log(`\nMetadata update complete:`); - console.log(`- Files updated: ${updatedFiles}`); - console.log(`- Components not found: ${notFoundComponents}`); + console.log('\nAll metadata files have up-to-date versions.'); + return true; } async function main(): Promise { - const shouldUpdateMetadata = await input({ - message: 'Should update metadata files? (y/n)', - default: 'y', - validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', - }); - const shouldGenerateReportFiles = await input({ - message: 'Should generate report files? (y/n)', - default: 'y', - validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', - }); + const ciMode = process.argv.includes('--ci'); + const checkMode = process.argv.includes('--fail-on-changes'); + + let shouldUpdate = 'y'; - console.log('Analyzing component peer dependencies for documentation...'); + if (!ciMode && !checkMode) { + shouldUpdate = await input({ + message: 'Sync peer dependency versions in metadata files? (y/n)', + default: 'y', + validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', + }); + } - const webPackageJson = JSON.parse(fs.readFileSync('packages/web/package.json', 'utf-8')); - const mobilePackageJson = JSON.parse(fs.readFileSync('packages/mobile/package.json', 'utf-8')); + const peerDepVersions = loadPeerDependencyVersions(); - // Analyze peer dependencies for each component in both packages - const webAnalysis = await analyzePackageForDocs('packages/web', 'web', webPackageJson); - const mobileAnalysis = await analyzePackageForDocs( - 'packages/mobile', - 'mobile', - mobilePackageJson, + console.log( + `Loaded ${peerDepVersions.size} peer dependency versions from ${PACKAGES.length} packages.`, ); - const analysis: PackageAnalysis = { - web: webAnalysis, - mobile: mobileAnalysis, - }; - - if (shouldGenerateReportFiles === 'y') { - // Generate documentation - const docsContent = generateDocumentationTable(analysis); - fs.writeFileSync('component-peer-dependencies.md', docsContent); - const jsonContent = generateJSONOutput(analysis); - fs.writeFileSync('component-peer-dependencies.json', jsonContent); + if (checkMode) { + const passed = await checkMetadataFiles(peerDepVersions); + process.exit(passed ? 0 : 1); } - if (shouldUpdateMetadata === 'y') { - // Update metadata files - await updateMetadataFiles(analysis); + if (shouldUpdate === 'y') { + await updateMetadataFiles(peerDepVersions); } - - // Print summary - console.log('\nDocumentation generated:'); - console.log('- component-peer-dependencies.md'); - console.log('- component-peer-dependencies.json'); - - console.log(`\nSummary:`); - console.log(`- Web components analyzed: ${Object.keys(webAnalysis).length}`); - console.log(`- Mobile components analyzed: ${Object.keys(mobileAnalysis).length}`); - - const webPeerDeps = new Set( - Object.values(webAnalysis).flatMap((c) => c.peerDependencies.map((d) => d.name)), - ); - const mobilePeerDeps = new Set( - Object.values(mobileAnalysis).flatMap((c) => c.peerDependencies.map((d) => d.name)), - ); - - console.log(`\nUnique peer dependencies:`); - console.log(`- Web: ${Array.from(webPeerDeps).join(', ')}`); - console.log(`- Mobile: ${Array.from(mobilePeerDeps).join(', ')}`); } if (require.main === module) { main().catch(console.error); } -export { analyzePackageForDocs, generateDocumentationTable }; +export { loadPeerDependencyVersions, syncDependencyVersions }; diff --git a/apps/mobile-app/app.config.ts b/apps/mobile-app/app.config.ts index 7ebbddb5cd..00cfabf2ba 100644 --- a/apps/mobile-app/app.config.ts +++ b/apps/mobile-app/app.config.ts @@ -1,4 +1,5 @@ import { getExpoSDKVersion } from '@expo/config'; +import { withProjectBuildGradle } from '@expo/config-plugins'; import type { ExpoConfig } from '@expo/config-types'; const profile = process.env.APP_PROFILE ?? ('debug' as const); @@ -99,5 +100,18 @@ const expo: ExpoConfig = { }; export default { - expo, + // TODO(cds-v9): remove this Gradle resolution override. + expo: withProjectBuildGradle(expo, (config) => { + config.modResults.contents += ` +subprojects { + configurations.all { + resolutionStrategy { + force 'androidx.annotation:annotation:1.9.1' + force 'androidx.annotation:annotation-jvm:1.9.1' + } + } +} +`; + return config; + }), }; diff --git a/apps/mobile-app/docs/prebuilds.md b/apps/mobile-app/docs/prebuilds.md index 15ee4c7b62..56b283e728 100644 --- a/apps/mobile-app/docs/prebuilds.md +++ b/apps/mobile-app/docs/prebuilds.md @@ -59,82 +59,86 @@ See more info about mobile builds [here](/apps/mobile-app/docs/building-mobile.m When running the debug app after a rebuild or restart, you'll most likely need to close out the Debug app and reopen it to trigger the bundler to recompile. -4. (Optional - use as needed) Run detox tests locally +4. (Optional - use as needed) Run visual regression tests locally -| Platform | Profile - engine type | Command | -| -------- | --------------------- | ---------------------------------------------- | -| ios | debug - hermes | `yarn nx run mobile-app:detox:ios-debug` | -| ios | release - hermes | `yarn nx run mobile-app:detox:ios-release` | -| android | debug -hermes | `yarn nx run mobile-app:detox:android-debug` | -| android | release -hermes | `yarn nx run mobile-app:detox:android-release` | +Before running visreg, ensure the release build is installed (step 2 above). Then: + +| Platform | Command | +| -------- | ----------------------------------- | +| ios | `yarn nx run mobile-visreg:ios` | +| android | `yarn nx run mobile-visreg:android` | + +See the [mobile-visreg README](/packages/mobile-visreg/README.md) for full setup, single-route iteration, and Percy upload instructions. ## An overview of Expo NX Targets -One should note that there are four NX targets associated with Expo that we leverage to build and run mobile-app in various contexts. The various contexts can be summarized as a debug and release mode for development and a debug and release mode for visreg tests (powered by detox). +There are three core NX targets associated with Expo that we leverage to build and run mobile-app. The various contexts can be summarized as debug and release modes for development, with release builds also serving as the basis for visual regression testing. -The four NX Targets (also declared in `/apps/mobile-app/project.json`): +The three NX Targets (also declared in `/apps/mobile-app/project.json`): 1. launch 2. start 3. build -4. detox These targets call node scripts that live in the [scripts directory of mobile-app](/apps/mobile-app/scripts/). These scripts are intuitively named the same as their respective nx targets. +Visual regression testing is handled separately by the [`packages/mobile-visreg`](/packages/mobile-visreg/README.md) package using Maestro and BrowserStack App Percy. + ## Expo Debug vs Release Builds -There are eight relevant build variations associated with mobile-app: +There are four relevant build variations associated with mobile-app: Release builds: 1. iOS Release build 2. Android Release build -3. iOS Visreg (powered by Detox) Release build -4. Android Visreg (powered by Detox) Release build Debug builds: -5. iOS Debug build -6. Android Debug build -7. iOS Visreg (powered by Detox) Debug build -8. Android Visreg (powered by Detox) Debug build +3. iOS Debug build +4. Android Debug build There are two key ideas to understand about these build variations: 1. The difference between a release and a debug build -2. Why visreg needs its own debug and release build +2. Why visreg uses release builds ## The difference between a release and a debug build The key difference between release and debug builds is how the javascript is bundled with the native portion of mobile-app. In release builds a fully optimized version of the javascript bundle is packaged into the iOS ipa or Android apk and is referenced by the native app entry point. In a debug build the javascript bundle is not bundled into the app artifact, instead it is kept external to the shippable native portion and the native entry point references a bundle managed by the metro bundler (the metro bundler is what runs in your terminal when you run the start target). This difference is key to understanding why hot-reloading works in debug builds but not in release builds. It is also important to note here that debug is clearly a very different environment compared to release, which is why our visreg tests must be run in the context of release build as opposed to debug. -## Why visreg needs its own debug and release build +## Why visreg uses release builds -Visreg tests are powered by an end-to-end testing framework called [Detox](https://github.com/wix/Detox). Detox is different compared to other e2e testing frameworks because it a "gray-box" testing framework. What this means is that Detox has specialized code injected into the native portion of our app build to enable Detox to watch what is happening inside of the target test app. This is great for ensuring more reliable e2e tests, but its major drawback is that it requires us to build a specialized detox build to hook into the app lifecycle. +Visual regression testing is powered by [Maestro](https://maestro.mobile.dev/), which drives the app via deep-links (`:///Debug`) to navigate directly to each component route and capture a screenshot. Deep-link navigation requires the app to handle the link at the React Navigation layer — but debug builds run inside the Expo Dev Client shell, which intercepts incoming deep links before React Navigation can process them. This means debug builds cannot be used for visreg; only standard release builds are supported. -For debugging visreg locally we highly recommend using the visreg debug build. Using the visreg debug build allows you to test your changes quicker because your javascript updates are hot-reloaded via metro. +Unlike the previous Detox-based approach, Maestro does not require any specialized native build configuration or code injection. The same release prebuild used for deployment is used for visreg, which simplifies the build matrix significantly. -Run iOS visreg debug tests: `yarn nx run mobile-app:detox:ios-debug` -Run iOS visreg release tests: `yarn nx run mobile-app:detox:ios-release` +To run visreg locally, install the release build and then run: -## How visreg patching works +```bash +yarn nx run mobile-visreg:ios # iOS +yarn nx run mobile-visreg:android # Android +``` -The key to understand Visreg tests is by investigating the `apps/mobile-app/scripts/detox.mjs` script. +See the [mobile-visreg README](/packages/mobile-visreg/README.md) for the full workflow. -The commands in that file are pretty straight forward; they initialize the needed emulator/simulator, launch them, and run the visreg tests via detox, but there is a key step in that file that is not immediately obvious; the patch step: +## How visreg bundle patching works -``` -if (profile === 'release') { - if (platform === 'android') await android.patchBundle(); - if (platform === 'ios') await ios.patchBundle(); -} +A key performance optimization keeps the committed prebuilds (native `.ipa` / `.apk` artifacts) from having to be fully rebuilt on every CI run. Because CDS developers rarely change native modules, it would be wasteful to re-run the full native build (8+ minutes on iOS, 6+ minutes on Android) just to pick up JS changes. + +Instead, CI uses a patch step: + +```bash +yarn nx run mobile-app:patch-bundle-ios # iOS +yarn nx run mobile-app:patch-bundle-android # Android ``` -The purpose of this step is to enable updating the javascript bundle of the visreg release build (on iOS and Android) in CI without having to rebuild the native portion of the app. The command varies depending on platform because their artifact architectures are different, but the goal across the two platforms is the same; uncompress given artifact, update javascript bundle with new bundle, re-compress artifact to a state that is acceptable by the given platform. +These scripts uncompress the committed release artifact, swap in the freshly bundled JS, and re-compress it into a valid platform artifact. This makes CI visreg runs fast while keeping the native prebuilds in sync with the JS codebase. -Why is this so important? -The native portion of the app takes over 6 minutes on Android and 8 minutes on iOS. But CDS developers rarely change the code that actually modifies the native portion of the app (native modules). Because of this it would be wasteful to rebuild the native portion of the app on every CI run. This patch method offers a very quick way of making sure all javascript code is up to date for the visreg tests without executing the long running build commands on every CI run. +**When should the native prebuilds be rebuilt?** +Any time native dependencies, native Expo configs, or relevant build tooling changes. When this happens, regenerate and commit the updated prebuilds: -When should the native portion of the app be rebuilt? -The short answer to this is any time native dependencies, native expo configs, or relevant build tooling changes. -Unfortunately to know exactly when this condition is true is not the easiest case to recognize. As a result this is definitely a shortcoming of this repo; ideally we would have a check that identifies whether any of these conditions are met and fail CI to indicate that a new build must be created. Until this tool is added to the repo the team is in a risky state because the native build could easily slip out of date if a developer forgets to rebuild when they should have. +```bash +yarn nx run mobile-app:build:ios-release +yarn nx run mobile-app:build:android-release +``` diff --git a/apps/mobile-app/docs/upgrade-rn.md b/apps/mobile-app/docs/upgrade-rn.md index e557ca51d9..01e871fcde 100644 --- a/apps/mobile-app/docs/upgrade-rn.md +++ b/apps/mobile-app/docs/upgrade-rn.md @@ -14,7 +14,7 @@ yarn workspace mobile-app add expo@^ cd apps/mobile-app && npx expo install --fix ``` -3. Upgrade all native dependencies within our repo (cds-mobile, ui-mobile-playground, ui-mobile-visreg, etc) to match the versions provided by expo. +3. Upgrade all native dependencies within our repo (cds-mobile, etc) to match the versions provided by expo. **This is super important because that native versions must match for the mobile-app build to be successful** diff --git a/apps/mobile-app/docs/upgrading-mobile-dep.md b/apps/mobile-app/docs/upgrading-mobile-dep.md index 4fa22ed80a..8143c17799 100644 --- a/apps/mobile-app/docs/upgrading-mobile-dep.md +++ b/apps/mobile-app/docs/upgrading-mobile-dep.md @@ -6,12 +6,11 @@ Check out this doc [for more about mobile builds in general](/apps/mobile-app/do **We can stray from their recommendations, but with caution.** -1. Update to the new package in all relevant packages. Check ui-mobile-playground and ui-mobile-visreg packages for upgrades if needed. +1. Update to the new package in all relevant packages. ```shell yarn workspace mobile-app add @ yarn workspace @coinbase/cds-mobile add @ -//< ui-mobile-playground and ui mobile-visreg should also be updated if needed> yarn ``` @@ -23,7 +22,7 @@ yarn nx run mobile:test 3. Test that your applications work locally as expected. You will need to build a new debug build & likely uninstall the previous application and reinstall your new build, following [setup instructions](/apps/mobile-app/README.md). -4. Generate the new shared, native module builds for everyone to use in visreg. Be sure to commit all 3 outputs. This step is vital since visreg uses these builds to compare UI changes. android-debug build is too large to be committed locally, but should be tested. +4. Generate the new shared, native module builds for everyone to use. Be sure to commit the release builds. Visreg (via `packages/mobile-visreg`) uses the release builds to capture and compare screenshots. The android-debug build is too large to be committed locally, but should be tested. ```shell yarn nx run mobile-app:build:ios-debug diff --git a/apps/mobile-app/e2e/environment.js b/apps/mobile-app/e2e/environment.js deleted file mode 100644 index a48a5d22d3..0000000000 --- a/apps/mobile-app/e2e/environment.js +++ /dev/null @@ -1,19 +0,0 @@ -const { DetoxCircusEnvironment } = require('detox/runners/jest'); - -class CustomDetoxEnvironment extends DetoxCircusEnvironment { - async handleTestEvent(event, state) { - const { name } = event; - - if (['test_start', 'test_fn_start'].includes(name)) { - this.global.testFailed = false; - } - - if (name === 'test_fn_failure') { - this.global.testFailed = true; - } - - await super.handleTestEvent(event, state); - } -} - -module.exports = CustomDetoxEnvironment; diff --git a/apps/mobile-app/e2e/jest.config.js b/apps/mobile-app/e2e/jest.config.js deleted file mode 100644 index ab1f06cfc2..0000000000 --- a/apps/mobile-app/e2e/jest.config.js +++ /dev/null @@ -1,30 +0,0 @@ -// A preset cant extend another preset, so we need to import and inherit the RN config -// https://github.com/facebook/react-native/blob/main/jest-preset.js -const rnPreset = require('react-native/jest-preset'); - -/** @type {import('@jest/types').Config.InitialOptions} */ -module.exports = { - ...rnPreset, - maxWorkers: 1, - testRunner: 'jest-circus/runner', - testTimeout: 120000, - testRegex: '\\.e2e\\.ts$', - testPathIgnorePatterns: [ - '/node_modules/', - '/cjs/', - '/dts/', - '/esm/', - '/lib/', - '/mjs/', - '/__fixtures__/', - '.*\\.d\\.ts', - ], - transform: { - ...rnPreset.transform, - // Required to find the root babel config when jest is ran in sub-folders - '^.+\\.(js|ts|tsx)$': ['babel-jest', { rootMode: 'upward' }], - }, - setupFilesAfterEnv: ['./setup.ts'], - testEnvironment: './environment.js', - verbose: true, -}; diff --git a/apps/mobile-app/e2e/setup.ts b/apps/mobile-app/e2e/setup.ts deleted file mode 100644 index 51cd91ffcd..0000000000 --- a/apps/mobile-app/e2e/setup.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { initialRouteName } from '@coinbase/ui-mobile-playground/components/staticRoutes'; -import { finishVisregTests, navigateToHome } from '@coinbase/ui-mobile-visreg'; - -import { openApp } from './utils/openApp'; - -beforeAll(async () => { - await openApp(); -}); - -beforeEach(async () => { - // return to initial screen to reset state, required to close modals - await navigateToHome(`cds://${initialRouteName}`); -}); - -afterEach(async () => { - if (testFailed) { - await device.takeScreenshot('test_failure'); - } -}); - -afterAll(() => { - finishVisregTests(); -}); diff --git a/apps/mobile-app/e2e/tests/playgroundRoutes.e2e.ts b/apps/mobile-app/e2e/tests/playgroundRoutes.e2e.ts deleted file mode 100644 index ae2f42550b..0000000000 --- a/apps/mobile-app/e2e/tests/playgroundRoutes.e2e.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { keyToRouteName } from '@coinbase/ui-mobile-playground/components/keyToRouteName'; -import { - getPlaygroundRoutes, - navigateToRoute, - routes, - uploadScreenshotsToPercyForRoute, -} from '@coinbase/ui-mobile-visreg'; - -const affectedRouteKeys = process.env.DETOX_AFFECTED_ROUTE_KEYS?.split(','); - -const filteredRoutes = !affectedRouteKeys - ? routes - : routes.filter((route) => affectedRouteKeys.includes(route.key)); - -const disabledRoutes = { - disabledRoutes: [ - 'AnimatedCaret', // Animations not relevant for Visreg - 'DotMisc', // Contains a11y, animations, flows, or other stories not relevant for Visreg - 'DrawerMisc', // Contains a11y, animations, flows, or other stories not relevant for Visreg - 'HintMotion', // Animations not relevant for Visreg - 'LottieStatusAnimation', // Animations not relevant for Visreg - 'TooltipV2', // Stories need to be adjusted to make them useful for Visreg - 'Toast', // Stories need to be adjusted to make them useful for Visreg - 'TrayMisc', // Contains a11y, animations, flows, or other stories not relevant for Visreg - ], - iosDisabledRoutes: [], - androidDisabledRoutes: [ - 'AlertBasic', // Modal displays status bar, resulting in false positive - 'AlertLongTitle', // Modal displays status bar, resulting in false positive - 'AlertOverModal', // Modal displays status bar, resulting in false positive - 'AlertPortal', // Modal displays status bar, resulting in false positive - 'AlertSingleAction', // Modal displays status bar, resulting in false positive - 'DrawerBottom', // Modal displays status bar, resulting in false positive - 'DrawerFallback', // Modal displays status bar, resulting in false positive - 'DrawerLeft', // Modal displays status bar, resulting in false positive - 'DrawerRight', // Modal displays status bar, resulting in false positive - 'DrawerScrollable', // Modal displays status bar, resulting in false positive - 'DrawerTop', // Modal displays status bar, resulting in false positive - 'ModalBackButton', // Modal displays status bar, resulting in false positive - 'ModalBasic', // Modal displays status bar, resulting in false positive - 'ModalLong', // Modal displays status bar, resulting in false positive - 'ModalPortal', // Modal displays status bar, resulting in false positive - 'Overlay', // Modal displays status bar, resulting in false positive - 'PatternDisclosureHighFrictionBenefit', // Modal displays status bar, resulting in false positive - 'PatternDisclosureHighFrictionRisk', // Modal displays status bar, resulting in false positive - 'PatternDisclosureLowFriction', // Modal displays status bar, resulting in false positive - 'PatternDisclosureMedFriction', // Modal displays status bar, resulting in false positive - 'PatternError', // Modal displays status bar, resulting in false positive - 'StickyFooterWithTray', // Modal displays status bar, resulting in false positive - 'TrayBasic', // Modal displays status bar, resulting in false positive - 'TrayFallback', // Modal displays status bar, resulting in false positive - 'TrayFeedCard', // Modal displays status bar, resulting in false positive - 'TrayNavigation', // Modal displays status bar, resulting in false positive - 'TrayScrollable', // Modal displays status bar, resulting in false positive - 'TrayTall', // Modal displays status bar, resulting in false positive - 'TrayWithTitle', // Modal displays status bar, resulting in false positive - ], -}; - -const testRoutes = getPlaygroundRoutes({ routes: filteredRoutes, ...disabledRoutes }); - -if (!testRoutes.length) process.exit(0); - -describe('All Playground Routes', () => { - it.each(testRoutes)('%p Visual Diff Test.', async (routeName) => { - await navigateToRoute(`cds://${keyToRouteName(routeName)}`); - await uploadScreenshotsToPercyForRoute(routeName); - }); -}); diff --git a/apps/mobile-app/e2e/utils/openApp.ts b/apps/mobile-app/e2e/utils/openApp.ts deleted file mode 100644 index e7db333b3f..0000000000 --- a/apps/mobile-app/e2e/utils/openApp.ts +++ /dev/null @@ -1,31 +0,0 @@ -const sleep = async (time: number) => - new Promise((res) => { - setTimeout(res, time); - }); - -function getUrl(platform: 'ios' | 'android') { - const url = `http://localhost:8081/index.bundle?platform=${platform}&dev=true&minify=false&disableOnboarding=1`; - return encodeURIComponent(url); -} - -export async function openApp() { - const platform = 'ios'; - const deepLinkUrl = `cds://expo-development-client/?url=${getUrl(platform)}`; - - if (platform === 'ios') { - await device.launchApp({ - newInstance: true, - }); - await sleep(3000); - await device.openURL({ - url: deepLinkUrl, - }); - } else { - await device.launchApp({ - newInstance: true, - url: deepLinkUrl, - }); - } - - await sleep(3000); -} diff --git a/apps/mobile-app/metro.config.js b/apps/mobile-app/metro.config.js index f064257201..78e709847d 100644 --- a/apps/mobile-app/metro.config.js +++ b/apps/mobile-app/metro.config.js @@ -24,7 +24,6 @@ const aliases = { __dirname, '../../packages/ui-mobile-playground/src', ), - '@coinbase/ui-mobile-visreg': path.resolve(__dirname, '../../packages/ui-mobile-visreg/src'), }; const pkgCache = {}; diff --git a/apps/mobile-app/package.json b/apps/mobile-app/package.json index 191625f226..30263a96ad 100644 --- a/apps/mobile-app/package.json +++ b/apps/mobile-app/package.json @@ -18,7 +18,6 @@ "@coinbase/cds-mobile": "workspace:^", "@coinbase/cds-mobile-visualization": "workspace:^", "@coinbase/ui-mobile-playground": "workspace:^", - "@coinbase/ui-mobile-visreg": "workspace:^", "@config-plugins/detox": "^6.0.0", "@expo-google-fonts/inter": "^0.3.0", "@expo-google-fonts/source-code-pro": "^0.3.0", @@ -49,7 +48,6 @@ "lottie-react-native": "6.7.0", "react": "^18.3.1", "react-native": "0.74.5", - "react-native-date-picker": "4.4.2", "react-native-gesture-handler": "2.16.2", "react-native-inappbrowser-reborn": "3.7.0", "react-native-navigation-bar-color": "2.0.2", diff --git a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz b/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz index 5c840139f5..003859b2aa 100644 Binary files a/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz and b/apps/mobile-app/prebuilds/ios-release-hermes.tar.gz differ diff --git a/apps/mobile-app/project.json b/apps/mobile-app/project.json index 0492b27c90..8a30e68170 100644 --- a/apps/mobile-app/project.json +++ b/apps/mobile-app/project.json @@ -119,14 +119,16 @@ "command": "node ./scripts/validate.mjs" } }, - "visreg": { - "executor": "nx:run-commands", + "patch-bundle-ios": { + "command": "node ./scripts/patch-bundle.mjs --platform ios --profile release --jsEngine hermes", "options": { - "commands": [ - "buildkite-agent pipeline upload .buildkite/visreg-ios.yml", - "buildkite-agent pipeline upload .buildkite/visreg-android.yml" - ], - "parallel": true + "cwd": "apps/mobile-app" + } + }, + "patch-bundle-android": { + "command": "node ./scripts/patch-bundle.mjs --platform android --profile release --jsEngine hermes", + "options": { + "cwd": "apps/mobile-app" } }, "lint": { diff --git a/apps/mobile-app/scripts/patch-bundle.mjs b/apps/mobile-app/scripts/patch-bundle.mjs new file mode 100644 index 0000000000..52725f70df --- /dev/null +++ b/apps/mobile-app/scripts/patch-bundle.mjs @@ -0,0 +1,10 @@ +import { argv } from 'zx'; + +import { getBuildInfo } from './utils/getBuildInfo.mjs'; +import { setEnvVars } from './utils/setEnvVars.mjs'; + +setEnvVars(); +const { ios, android } = getBuildInfo(); + +if (argv.platform === 'ios') await ios.patchBundle(); +if (argv.platform === 'android') await android.patchBundle(); diff --git a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs b/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs index 2a4ba30c36..af260a8619 100644 --- a/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs +++ b/apps/mobile-app/scripts/utils/getAffectedRoutes.mjs @@ -5,6 +5,10 @@ import pkg from '../../package.json' with { type: 'json' }; import { routes } from './routes.mjs'; +const IGNORE_CHANGED_FILES_REGEX = + /^((CHANGELOG|README|MIGRATION|CONTRIBUTING)(\.md)?|[^/]+\.yml|OWNERS|project\.json|[^/]+\.[dD]ockerfile|tsconfig\.json|jest\.config\.js|\.?eslint.*)$/; +const DEV_FILES_REGEX = /(\.(spec|test|figma)\.[jt]sx?(\.snap)?$)/; + /** * Returns an array of changed filepaths between a branch and another base branch */ @@ -72,6 +76,21 @@ const getImportPathsFromFiles = (files, sourceDirectories, workspaceDirectoryMap return truncatedFilepath.replace(matchingDirectory, workspaceDependency); }); +/** + * Returns true when a changed file should impact mobile visreg. + */ +const isFileVisregRelevant = (file, sourceDirectories) => { + const matchingDirectory = sourceDirectories.find((directory) => file.startsWith(directory)); + if (!matchingDirectory) { + return false; + } + + const relativeFilePath = file.slice(matchingDirectory.length + 1); + return ( + !DEV_FILES_REGEX.test(relativeFilePath) && !IGNORE_CHANGED_FILES_REGEX.test(relativeFilePath) + ); +}; + /** * Returns an object with a boolean for whether the common package changed and an array of * ui-mobile-playground route keys that were affected by the changed files. @@ -79,7 +98,6 @@ const getImportPathsFromFiles = (files, sourceDirectories, workspaceDirectoryMap export const getAffectedRoutes = async (log = false) => { const baseBranch = process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH || 'master'; const changedFiles = getChangedFilesOnBranch('HEAD', baseBranch); - const commonChanged = changedFiles.some((file) => file.match('^packages/common/.*/')); const workspaceDependencies = getWorkspaceDependencies(pkg.dependencies); const MONOREPO_ROOT = process.env.PROJECT_CWD ?? process.env.NX_MONOREPO_ROOT; @@ -90,12 +108,10 @@ export const getAffectedRoutes = async (log = false) => { const workspaceDirectoryMap = getWorkspaceDirectoryMap(workspaceDependencies, tsconfigPaths); const sourceDirectories = Object.values(workspaceDirectoryMap).flat(); - // The relevantChangedFiles don't include packages/common files because cds-common is not in the - // package.json. This won't matter because we just run all the tests if any of the files in - // packages/common changed. const relevantChangedFiles = changedFiles.filter((file) => - sourceDirectories.some((directory) => file.startsWith(directory)), + isFileVisregRelevant(file, sourceDirectories), ); + const commonChanged = relevantChangedFiles.some((file) => file.startsWith('packages/common/')); const affectedImportPaths = getImportPathsFromFiles( relevantChangedFiles, diff --git a/apps/mobile-app/scripts/utils/getBuildInfo.mjs b/apps/mobile-app/scripts/utils/getBuildInfo.mjs index c575424e99..dba2660cc0 100644 --- a/apps/mobile-app/scripts/utils/getBuildInfo.mjs +++ b/apps/mobile-app/scripts/utils/getBuildInfo.mjs @@ -83,7 +83,7 @@ export function getBuildInfo() { await $`mkdir -p ${outputName}`; const testFolder = `${outputName}/androidTest/${profile}`; const buildFolder = `${outputName}/${profile}`; - await $`tar -xf ${this.zipFile} -C ${outputName}`; + await $`unzip -q ${this.zipFile} -d ${outputName}`; await $`mv ${testFolder}/app-${profile}-androidTest.apk ${this.testApk}`; await $`mv ${buildFolder}/app-${profile}.apk ${this.apk.signed}`; await $`rm -rf ${path.dirname(testFolder)} && rm -rf ${buildFolder}`; diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index d5d4c026bd..ac70f70b4d 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -121,6 +121,10 @@ export const routes = [ 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, @@ -142,9 +146,16 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartAccessibility', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') + .default, + }, + { + key: 'ChartTransitions', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -170,6 +181,22 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, }, + { + key: 'Combobox', + getComponent: () => + require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, + }, + { + key: 'ComponentConfigProvider', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, + }, + { + key: 'ComponentConfigProviderCustom', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') + .default, + }, { key: 'ContainedAssetCard', getComponent: () => @@ -195,6 +222,11 @@ export const routes = [ 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, @@ -236,6 +268,11 @@ export const routes = [ 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: () => @@ -251,6 +288,10 @@ export const routes = [ 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: () => @@ -303,6 +344,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => @@ -341,10 +387,19 @@ export const routes = [ 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: () => @@ -355,6 +410,11 @@ export const routes = [ 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: () => @@ -460,6 +520,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, }, + { + key: 'PercentageBarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/PercentageBarChart.stories') + .default, + }, { key: 'PeriodSelector', getComponent: () => @@ -522,6 +588,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, }, + { + key: 'Scrubber', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + .default, + }, { key: 'SearchInput', getComponent: () => @@ -805,6 +877,16 @@ export const routes = [ 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: () => diff --git a/apps/mobile-app/src/App.tsx b/apps/mobile-app/src/App.tsx index a1529d7376..901d982d8b 100644 --- a/apps/mobile-app/src/App.tsx +++ b/apps/mobile-app/src/App.tsx @@ -9,7 +9,7 @@ import { ThemeProvider } from '@coinbase/cds-mobile/system/ThemeProvider'; import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; import { ChartBridgeProvider } from '@coinbase/cds-mobile-visualization/chart'; import { Playground } from '@coinbase/ui-mobile-playground'; -import { NavigationContainer } from '@react-navigation/native'; +import { CommonActions, NavigationContainer } from '@react-navigation/native'; import * as Linking from 'expo-linking'; import * as SplashScreen from 'expo-splash-screen'; @@ -18,6 +18,18 @@ import { routes as codegenRoutes } from './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] }), }; // this code allows the use of toLocaleString() on Android diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts index 24168f0230..7807a52976 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -121,6 +121,10 @@ export const routes = [ 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, @@ -142,9 +146,16 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartAccessibility', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') + .default, + }, + { + key: 'ChartTransitions', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -175,6 +186,17 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, }, + { + key: 'ComponentConfigProvider', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, + }, + { + key: 'ComponentConfigProviderCustom', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') + .default, + }, { key: 'ContainedAssetCard', getComponent: () => @@ -200,6 +222,11 @@ export const routes = [ 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, @@ -241,6 +268,11 @@ export const routes = [ 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: () => @@ -256,6 +288,10 @@ export const routes = [ 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: () => @@ -308,6 +344,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => @@ -346,10 +387,19 @@ export const routes = [ 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: () => @@ -360,6 +410,16 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/ModalBasic.stories').default, }, + { + key: 'ModalCustomHeader', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalCustomHeader.stories').default, + }, + { + key: 'ModalCustomPadding', + getComponent: () => + require('@coinbase/cds-mobile/overlays/__stories__/ModalCustomPadding.stories').default, + }, { key: 'ModalLong', getComponent: () => @@ -465,6 +525,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, }, + { + key: 'PercentageBarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/PercentageBarChart.stories') + .default, + }, { key: 'PeriodSelector', getComponent: () => @@ -527,6 +593,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, }, + { + key: 'Scrubber', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + .default, + }, { key: 'SearchInput', getComponent: () => @@ -810,6 +882,16 @@ export const routes = [ 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: () => diff --git a/apps/mobile-app/tsconfig.json b/apps/mobile-app/tsconfig.json index a976689884..1352a8ed5f 100644 --- a/apps/mobile-app/tsconfig.json +++ b/apps/mobile-app/tsconfig.json @@ -38,9 +38,6 @@ { "path": "../../packages/ui-mobile-playground" }, - { - "path": "../../packages/ui-mobile-visreg" - }, { "path": "../../packages/mobile-visualization" } diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts index 7348712a6c..9549d3063d 100644 --- a/apps/storybook/.storybook/main.ts +++ b/apps/storybook/.storybook/main.ts @@ -17,10 +17,17 @@ const createClassName = (hash: string, title: string) => { const isAnalyze = process.env.ANALYZE === 'true'; const isAnalyzeModeJson = process.env.ANALYZE_MODE_JSON === 'true'; +const isPercyBuild = process.env.STORYBOOK_PERCY === 'true'; const bundleStatsFilename = path.resolve( MONOREPO_ROOT, process.env.ANALYZE_REPORT_PATH || 'bundle-stats.json', ); +const addons = [ + // '@chromatic-com/storybook', + '@storybook/addon-storysource', + '@storybook-community/storybook-dark-mode', + ...(!isPercyBuild ? ['@storybook/addon-a11y', '@storybook/addon-vitest'] : []), +]; if (isAnalyze) { console.log('Bundle analyzer enabled because process.env.ANALYZE === "true"'); @@ -38,14 +45,10 @@ const config: StorybookConfig = { name: '@storybook/react-vite', options: {}, }, - addons: [ - // '@chromatic-com/storybook', - '@storybook/addon-storysource', - '@storybook-community/storybook-dark-mode', - ], + addons, stories: [ - path.resolve(MONOREPO_ROOT, 'packages/web/**/*.stories.@(tsx|mdx)'), - path.resolve(MONOREPO_ROOT, 'packages/web-visualization/**/*.stories.@(tsx|mdx)'), + '../../../packages/web/**/*.stories.@(tsx|mdx)', + '../../../packages/web-visualization/**/*.stories.@(tsx|mdx)', ], staticDirs: [ { diff --git a/apps/storybook/.storybook/preview.ts b/apps/storybook/.storybook/preview.ts index 3a5ec9ce90..7090edef8b 100644 --- a/apps/storybook/.storybook/preview.ts +++ b/apps/storybook/.storybook/preview.ts @@ -66,7 +66,10 @@ const preview: Preview = { // 'todo' - show a11y violations in the test UI only // 'error' - fail CI on a11y violations // 'off' - skip a11y checks entirely - test: 'todo', + test: 'error', + options: { + runOnly: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa'], + }, }, options: { storySort: { diff --git a/apps/storybook/.storybook/vitest.setup.ts b/apps/storybook/.storybook/vitest.setup.ts new file mode 100644 index 0000000000..a08badd02f --- /dev/null +++ b/apps/storybook/.storybook/vitest.setup.ts @@ -0,0 +1,8 @@ +import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview'; +import { setProjectAnnotations } from '@storybook/react-vite'; + +import * as projectAnnotations from './preview'; + +// This is an important step to apply the right configuration when testing your stories. +// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations +setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]); diff --git a/apps/storybook/package.json b/apps/storybook/package.json index 077e2612c9..561f5f3ab3 100644 --- a/apps/storybook/package.json +++ b/apps/storybook/package.json @@ -23,7 +23,9 @@ "@percy/storybook": "^9.0.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-vitest": "^9.1.2", "@storybook/jest": "^0.2.3", "@storybook/react-vite": "^9.1.2", "@storybook/testing-library": "^0.2.2", @@ -31,10 +33,14 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.0.0", + "@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", "typescript": "~5.9.2", - "vite": "^7.1.2" + "vite": "^7.1.2", + "vitest": "^4.0.18" } } diff --git a/apps/storybook/project.json b/apps/storybook/project.json index dfccd1496c..0706bfb3da 100644 --- a/apps/storybook/project.json +++ b/apps/storybook/project.json @@ -29,6 +29,36 @@ "{projectRoot}/dist" ] }, + "build-for-percy": { + "command": "storybook build --output-dir dist", + "dependsOn": [ + "^build" + ], + "inputs": [ + "{projectRoot}/*", + "{projectRoot}/**/*", + "{projectRoot}/**/__stories__/**", + "{projectRoot}/**/*.stories.*", + "{workspaceRoot}/packages/web/**/*.stories.*", + "{workspaceRoot}/packages/web-visualization/**/*.stories.*", + "!{projectRoot}/scripts/**" + ], + "outputs": [ + "{projectRoot}/dist" + ], + "options": { + "cwd": "apps/storybook", + "env": { + "STORYBOOK_PERCY": "true" + } + } + }, + "test-a11y": { + "command": "vitest --project=storybook", + "options": { + "cwd": "apps/storybook" + } + }, "lint": { "executor": "@nx/eslint:lint" }, @@ -128,7 +158,7 @@ "percy": { "command": "tsx ./scripts/run-percy.ts", "dependsOn": [ - "build" + "build-for-percy" ], "inputs": [ "{projectRoot}/dist" diff --git a/apps/storybook/scripts/shouldRunVisreg.mjs b/apps/storybook/scripts/shouldRunVisreg.mjs index 4bbf910645..b8185fef25 100644 --- a/apps/storybook/scripts/shouldRunVisreg.mjs +++ b/apps/storybook/scripts/shouldRunVisreg.mjs @@ -1,32 +1,13 @@ -import { spawnSync } from 'node:child_process'; - -const WEB_PACKAGES = ['common', 'web', 'web-visualization', 'icons', 'illustrations']; - -const getChangedFilesOnBranch = (branch, baseBranch) => { - const command = `git diff --name-only ${branch} $(git merge-base ${branch} ${baseBranch})`.split( - ' ', - ); - const changedFiles = spawnSync(command.shift() ?? '', command, { encoding: 'utf8', shell: true }); - return changedFiles.stdout.split('\n').filter(Boolean); -}; - -const checkIfPackagesHaveChanged = async () => { - const baseBranch = process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH || 'master'; - const changedFiles = getChangedFilesOnBranch('HEAD', baseBranch); - return changedFiles.some((file) => { - return WEB_PACKAGES.some((packageName) => file.includes(`packages/${packageName}/`)); - }); -}; - -const checkIfStorybookHasChanged = async () => { - const baseBranch = process.env.BUILDKITE_PULL_REQUEST_BASE_BRANCH || 'master'; - const changedFiles = getChangedFilesOnBranch('HEAD', baseBranch); - return changedFiles.some((file) => file.includes('apps/storybook/')); -}; - -const havePackagesChanged = await checkIfPackagesHaveChanged(); -const hasStorybookChanged = await checkIfStorybookHasChanged(); - -if (!havePackagesChanged && !hasStorybookChanged) process.exit(1); - +import { shouldRunVisreg } from '../../../scripts/ci/shouldRunVisreg.mjs'; + +const RELEVANT_ROOTS = [ + 'apps/storybook', + 'packages/common', + 'packages/web', + 'packages/web-visualization', + 'packages/icons', + 'packages/illustrations', +]; + +if (!shouldRunVisreg(RELEVANT_ROOTS)) process.exit(1); process.exit(0); diff --git a/apps/storybook/tsconfig.json b/apps/storybook/tsconfig.json index fc206dcf66..f1dd49fa55 100644 --- a/apps/storybook/tsconfig.json +++ b/apps/storybook/tsconfig.json @@ -11,7 +11,9 @@ "vite.config.ts", "vite-env.d.ts" ], - "exclude": [], + "exclude": [ + "scripts/shouldRunVisreg.mjs" + ], "references": [ { "path": "../../packages/web" diff --git a/apps/storybook/vitest.config.ts b/apps/storybook/vitest.config.ts new file mode 100644 index 0000000000..5411eaea80 --- /dev/null +++ b/apps/storybook/vitest.config.ts @@ -0,0 +1,34 @@ +import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'; +import { playwright } from '@vitest/browser-playwright'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vitest/config'; + +const dirname = + typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); + +// More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon +export default defineConfig({ + test: { + projects: [ + { + extends: true, + plugins: [ + // The plugin will run tests for the stories defined in your Storybook config + // See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest + storybookTest({ configDir: path.join(dirname, '.storybook') }), + ], + test: { + name: 'storybook', + browser: { + enabled: true, + headless: true, + provider: playwright({}), + instances: [{ browser: 'chromium' }], + }, + setupFiles: ['.storybook/vitest.setup.ts'], + }, + }, + ], + }, +}); diff --git a/apps/storybook/vitest.shims.d.ts b/apps/storybook/vitest.shims.d.ts new file mode 100644 index 0000000000..03b1801a60 --- /dev/null +++ b/apps/storybook/vitest.shims.d.ts @@ -0,0 +1 @@ +/// diff --git a/apps/vite-app/src/App.tsx b/apps/vite-app/src/App.tsx index 38f3a1fdf3..363c45871e 100644 --- a/apps/vite-app/src/App.tsx +++ b/apps/vite-app/src/App.tsx @@ -79,8 +79,10 @@ export const App = () => {
    diff --git a/apps/vite-app/src/components/AssetList/index.tsx b/apps/vite-app/src/components/AssetList/index.tsx index 1332bf2f69..6051f6357e 100644 --- a/apps/vite-app/src/components/AssetList/index.tsx +++ b/apps/vite-app/src/components/AssetList/index.tsx @@ -23,7 +23,7 @@ export const AssetList = ({ pageSize }: { pageSize: number }) => { const accountsCopy = mockAccounts.slice(startIndex, endIndex); return ( - +
    diff --git a/apps/vite-app/src/components/CardList/RecurringBuyCard.tsx b/apps/vite-app/src/components/CardList/RecurringBuyCard.tsx index 7679e43b11..02db766397 100644 --- a/apps/vite-app/src/components/CardList/RecurringBuyCard.tsx +++ b/apps/vite-app/src/components/CardList/RecurringBuyCard.tsx @@ -6,6 +6,7 @@ import { Box } from '@coinbase/cds-web/layout'; export const RecurringBuyCard = () => { return ( Get started diff --git a/apps/vite-app/src/components/Navbar/MoreMenu.tsx b/apps/vite-app/src/components/Navbar/MoreMenu.tsx index 3e8041d23c..64f4ffef4c 100644 --- a/apps/vite-app/src/components/Navbar/MoreMenu.tsx +++ b/apps/vite-app/src/components/Navbar/MoreMenu.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { IconButton } from '@coinbase/cds-web/buttons'; import { SelectOption } from '@coinbase/cds-web/controls'; import { Dropdown } from '@coinbase/cds-web/dropdown'; +import { useA11yControlledVisibility } from '@coinbase/cds-web/hooks/useA11yControlledVisibility'; import { Box } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; @@ -9,6 +10,20 @@ const moreMenuOptions = ['Option 1', 'Option 2', 'Option 3', 'Option 4', 'Option export const MoreMenu = () => { const [value, setValue] = useState(moreMenuOptions[0]); + const [dropdownVisible, setDropdownVisible] = useState(false); + + const { controlledElementAccessibilityProps } = useA11yControlledVisibility(dropdownVisible, { + accessibilityLabel: 'More options menu', + hasPopupType: 'menu', + }); + + const handleOpenMenu = useCallback(() => { + setDropdownVisible(true); + }, []); + + const handleCloseMenu = useCallback(() => { + setDropdownVisible(false); + }, []); const moreMenuContent = ( <> @@ -24,8 +39,15 @@ export const MoreMenu = () => { ); return ( - - + + ); }; diff --git a/apps/vite-app/src/components/Navbar/UserMenu.tsx b/apps/vite-app/src/components/Navbar/UserMenu.tsx index 298d8cf773..c227c39a0b 100644 --- a/apps/vite-app/src/components/Navbar/UserMenu.tsx +++ b/apps/vite-app/src/components/Navbar/UserMenu.tsx @@ -1,6 +1,7 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { SelectOption } from '@coinbase/cds-web/controls'; import { Dropdown } from '@coinbase/cds-web/dropdown'; +import { useA11yControlledVisibility } from '@coinbase/cds-web/hooks/useA11yControlledVisibility'; import { Pictogram } from '@coinbase/cds-web/illustrations'; import { Box, HStack } from '@coinbase/cds-web/layout'; import { Avatar } from '@coinbase/cds-web/media'; @@ -24,6 +25,21 @@ const userMenuOptions = [ export const UserMenu = () => { const [value, setValue] = useState(userMenuOptions[0].value); + const [dropdownVisible, setDropdownVisible] = useState(false); + + const { controlledElementAccessibilityProps } = useA11yControlledVisibility(dropdownVisible, { + accessibilityLabel: 'User menu', + hasPopupType: 'menu', + }); + + const handleOpenMenu = useCallback(() => { + setDropdownVisible(true); + }, []); + + const handleCloseMenu = useCallback(() => { + setDropdownVisible(false); + }, []); + const userMenuContent = ( <> @@ -42,8 +58,17 @@ export const UserMenu = () => { ))} ); + return ( - + diff --git a/apps/vite-app/src/components/Navbar/index.tsx b/apps/vite-app/src/components/Navbar/index.tsx index 12475b4edf..237e6c5abb 100644 --- a/apps/vite-app/src/components/Navbar/index.tsx +++ b/apps/vite-app/src/components/Navbar/index.tsx @@ -21,7 +21,11 @@ export const Navbar = ({ end={ - + } diff --git a/eslint.config.mjs b/eslint.config.mjs index 3962a700d4..aa1085e4b6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,7 +2,6 @@ import globals from 'globals'; import * as tseslint from 'typescript-eslint'; import eslintJs from '@eslint/js'; import eslintImport from 'eslint-plugin-import'; -import eslintSimpleImportSort from 'eslint-plugin-simple-import-sort'; import eslintReact from 'eslint-plugin-react'; import eslintReactHooks from 'eslint-plugin-react-hooks'; import eslintReactPerf from 'eslint-plugin-react-perf'; @@ -14,6 +13,8 @@ import eslintReactNativeA11y from 'eslint-plugin-react-native-a11y'; import eslintReactNative from 'eslint-plugin-react-native'; import eslintCodegen from 'eslint-plugin-codegen'; import internalPlugin from '@coinbase/eslint-plugin-internal'; +import eslintSimpleImportSort from 'eslint-plugin-simple-import-sort'; +import cds from '@coinbase/eslint-plugin-cds'; const ignores = [ '*.md', @@ -36,7 +37,6 @@ const ignores = [ '**/getAffectedRoutes.mjs', '**/getBuildInfo.mjs', 'apps/mobile-app/prebuilds', - 'apps/mobile-app/prebuilds', // within their NX project, these files are not included by the Typescript config // when linting with TS types (e.g. internal/safely-spread-props) this will raise an error 'packages/web/optimize-css.ts', @@ -48,6 +48,8 @@ const ignores = [ // These rules apply to all files const sharedRules = { + 'internal/no-object-rest-spread-in-worklet': 'error', + 'internal/deprecated-jsdoc-has-removal-version': 'error', 'import/default': 'off', 'import/extensions': 'off', 'import/named': 'off', @@ -164,6 +166,12 @@ const typescriptRules = { '@typescript-eslint/no-unsafe-function-type': 'error', '@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/prefer-namespace-keyword': 'off', + '@coinbase/cds/control-has-associated-label-extended': 'warn', + '@coinbase/cds/has-valid-accessibility-descriptors-extended': 'warn', + '@coinbase/cds/web-tooltip-interactive-content': 'warn', + '@coinbase/cds/web-chart-scrubbing-accessibility': 'warn', + '@coinbase/cds/mobile-chart-scrubbing-accessibility': 'warn', + '@coinbase/cds/no-v7-imports': 'warn', }; // These rules only apply to test files @@ -180,12 +188,14 @@ const testRules = { // These plugins apply to all files const sharedPlugins = { + internal: internalPlugin, 'simple-import-sort': eslintSimpleImportSort, }; // These plugins only apply to TS/TSX files const typescriptPlugins = { codegen: eslintCodegen, + '@coinbase/cds': cds, }; // These plugins only apply to React Native files @@ -265,7 +275,6 @@ export default tseslint.config( ignores: [ 'packages/illustrations/src/__generated__/**', 'packages/ui-mobile-playground/**', - 'packages/ui-mobile-visreg/**', 'packages/**/__stories__/**', 'packages/**/__tests__/**', 'packages/**/__mocks__/**', @@ -310,6 +319,10 @@ export default tseslint.config( files: ['**/*.figma.tsx'], extends: [internalPlugin.configs.figmaConnectRules], }, + { + files: ['**/*.mdx'], + processor: internalPlugin.processors.mdx, + }, { files: ['**/*.test.{ts,tsx}', '**/__tests__/**', '**/setup.js'], settings: sharedSettings, diff --git a/libs/codegen/src/playground/prepareRoutes.ts b/libs/codegen/src/playground/prepareRoutes.ts index c78803858e..d4eb5cbb47 100644 --- a/libs/codegen/src/playground/prepareRoutes.ts +++ b/libs/codegen/src/playground/prepareRoutes.ts @@ -85,13 +85,6 @@ export async function prepare() { dest: `packages/ui-mobile-playground/src/routes.ts`, }); - // Write to ui-mobile-visreg package. The keys are required for usage in the jest context to direct visreg tests. - await writeFile({ - data: { routes: consumerRoutes }, - template: 'mobileRoutes.ejs', - dest: `packages/ui-mobile-visreg/src/routes.ts`, - }); - // Write to mobile-app. This is required for hot reload - internal packages need src in path for hot reload, while consumers do not. await writeFile({ data: { routes: hotReloadRoutes }, diff --git a/libs/docusaurus-plugin-docgen/package.json b/libs/docusaurus-plugin-docgen/package.json index 40a6978902..5e988e810a 100644 --- a/libs/docusaurus-plugin-docgen/package.json +++ b/libs/docusaurus-plugin-docgen/package.json @@ -38,8 +38,8 @@ "type-fest": "^2.19.0" }, "dependencies": { - "@docusaurus/logger": "^3.7.0", - "@docusaurus/utils": "^3.7.0", + "@docusaurus/logger": "~3.7.0", + "@docusaurus/utils": "~3.7.0", "ejs": "^3.1.7", "react-docgen-typescript": "^2.4.0" }, @@ -48,7 +48,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "@docusaurus/types": "^3.7.0", + "@docusaurus/types": "~3.7.0", "@types/ejs": "^3.1.0", "@types/lodash": "^4.14.178" } diff --git a/libs/docusaurus-plugin-docgen/src/plugin.ts b/libs/docusaurus-plugin-docgen/src/plugin.ts index 9ac2ecaf74..e2137737c8 100644 --- a/libs/docusaurus-plugin-docgen/src/plugin.ts +++ b/libs/docusaurus-plugin-docgen/src/plugin.ts @@ -4,31 +4,14 @@ import path from 'node:path'; import { docgenRunner } from './scripts/docgenRunner'; import { docgenWriter } from './scripts/docgenWriter'; -import { getMinutesBetweenDates } from './utils/getMinutesBetweenDates'; import { logger } from './utils/logger'; import type { PluginContent, PluginOptions } from './types'; const PLUGIN_ID = '@coinbase/docusaurus-plugin-docgen'; -/** - * Persist build state as a global, since the plugin is re-evaluated every hot reload. - * Because of this, we can't use state in the plugin or module scope. - */ -declare module global { - export let docgenBuild: { - lastRun: Date | undefined; - isRunning: boolean; - count: number; - }; -} - -if (!global.docgenBuild) { - global.docgenBuild = { lastRun: undefined, isRunning: false, count: 0 }; -} - export default function plugin( { generatedFilesDir }: LoadContext, - { enabled = true, watchInterval = 5, ...options }: PluginOptions, + { enabled = true, ...options }: PluginOptions, ): Plugin { /** * The directory where we want to output docgen data and components. @@ -38,25 +21,20 @@ export default function plugin( return { name: PLUGIN_ID, + getPathsToWatch() { + if (!enabled) return []; + // Watch the src/ directory of each entry point package so that changes + // to component source files trigger a reload + return options.entryPoints.map( + (tsconfigPath) => `${path.dirname(tsconfigPath)}/src/**/*.{ts,tsx}`, + ); + }, async loadContent() { if (enabled) { logger.init(); - - const { isRunning, lastRun } = global.docgenBuild; - const isFirstRun = lastRun === undefined; - const lastUpdate = getMinutesBetweenDates(lastRun, new Date()); - - if (!isFirstRun) logger.lastUpdate(lastUpdate); - - const shouldUpdate = !isRunning && (isFirstRun || lastUpdate >= watchInterval); - logger.initStatus(shouldUpdate); - - if (shouldUpdate) { - return docgenRunner({ ...options, pluginDir }); - } - } else { - logger.enabledOff(); + return docgenRunner({ ...options, pluginDir }); } + logger.enabledOff(); return undefined; }, configureWebpack(_webpackConfig, _isServer, _utils, content) { @@ -94,11 +72,36 @@ export default function plugin( ); } + // Styles API aliases - only create for docs that have styles data + const stylesDataAliases = content + ? Object.fromEntries( + content.parsedDocs + .filter((item) => item.styles && item.styles.selectors.length > 0) + .map((item) => [ + path.join(':docgen', path.relative(pluginDir, item.cacheDirectory), 'styles-data'), + path.join(item.cacheDirectory, 'styles-data.js'), + ]), + ) + : {}; + + const tocStylesAliases = content + ? Object.fromEntries( + content.parsedDocs + .filter((item) => item.styles && item.styles.selectors.length > 0) + .map((item) => [ + path.join(':docgen', path.relative(pluginDir, item.cacheDirectory), 'toc-styles'), + path.join(item.cacheDirectory, 'toc-styles.js'), + ]), + ) + : {}; + const aliases = { ...apiAliases, ...metadataAliases, ...dataAliases, ...tocPropsAliases, + ...stylesDataAliases, + ...tocStylesAliases, [`:docgen/_types/sharedTypeAliases`]: path.join(pluginDir, '_types/sharedTypeAliases'), [`:docgen/_types/sharedParentTypes`]: path.join(pluginDir, '_types/sharedParentTypes'), }; @@ -115,12 +118,6 @@ export default function plugin( await docgenWriter(filesToWrite); actions.setGlobalData({ enabled: true, projects }); logger.pluginComplete(); - - global.docgenBuild = { - count: (global.docgenBuild.count += 1), - lastRun: new Date(), - isRunning: false, - }; } else { actions.setGlobalData({ enabled: false, projects: [] }); } diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts new file mode 100644 index 0000000000..ca60844dcc --- /dev/null +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts @@ -0,0 +1,86 @@ +import type { Doc } from '../types'; + +import { formatPropItemType, formatString, getDocExample } from './docgenParser'; + +describe('formatString', () => { + it('removes single and double quotes', () => { + expect(formatString(`"hello" 'world'`)).toBe('hello world'); + }); + + it('replaces newlines with spaces', () => { + expect(formatString('line1\nline2\nline3')).toBe('line1 line2 line3'); + }); + + it('removes backticks', () => { + expect(formatString('`code`')).toBe('code'); + }); + + it('handles all transformations together', () => { + expect(formatString(`"hello"\n'world' \`code\``)).toBe('hello world code'); + }); + + it('returns empty string for empty input', () => { + expect(formatString('')).toBe(''); + }); + + it('leaves normal text unchanged', () => { + expect(formatString('just plain text')).toBe('just plain text'); + }); +}); + +describe('formatPropItemType', () => { + it('simplifies ReactElement type', () => { + expect(formatPropItemType('ReactElement>')).toBe( + 'ReactElement', + ); + }); + + it('simplifies ReactNode union type', () => { + expect( + formatPropItemType( + 'Iterable | ReactElement> | ReactPortal | false | null | number | string | true | {}', + ), + ).toBe('ReactNode'); + }); + + it('simplifies Animated ViewStyle type', () => { + expect( + formatPropItemType( + 'false | RegisteredStyle | Value | AnimatedInterpolation | WithAnimatedObject | WithAnimatedArray<...> | null', + ), + ).toBe('Animated | ViewStyle'); + }); + + it('passes unrecognized types through formatString', () => { + expect(formatPropItemType('string')).toBe('string'); + expect(formatPropItemType('number | undefined')).toBe('number | undefined'); + }); + + it('cleans quotes and backticks from unrecognized types', () => { + expect(formatPropItemType(`"primary" | "secondary"`)).toBe('primary | secondary'); + }); +}); + +describe('getDocExample', () => { + function docWithExample(example?: string): Doc { + return { tags: example !== undefined ? { example } : undefined } as unknown as Doc; + } + + it('returns undefined when no tags exist', () => { + expect(getDocExample({ tags: undefined } as unknown as Doc)).toBeUndefined(); + }); + + it('returns undefined when no example tag exists', () => { + expect(getDocExample(docWithExample(undefined))).toBeUndefined(); + }); + + it('wraps plain code examples in tsx live fences', () => { + const example = ''; + expect(getDocExample(docWithExample(example))).toBe('```tsx live\n\n```'); + }); + + it('replaces tsx with tsx live in pre-fenced examples', () => { + const example = '```tsx\n\n```'; + expect(getDocExample(docWithExample(example))).toBe('```tsx live\n\n```'); + }); +}); diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts index 66cadfb370..55c10d0f38 100644 --- a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts @@ -1,4 +1,4 @@ -import { withCustomConfig } from 'react-docgen-typescript'; +import { withCompilerOptions } from 'react-docgen-typescript'; import mapValues from 'lodash/mapValues'; import omit from 'lodash/omit'; import orderBy from 'lodash/orderBy'; @@ -14,6 +14,8 @@ import type { ProcessedDoc, ProcessedPropItem, PropItem, + StylesData, + StyleSelector, } from '../types'; export const sharedParentTypesCache = new Set(); @@ -55,7 +57,6 @@ function createTsProgramContext(tsconfigPath: string, filesToParse: string[]): T ...config.options, noEmit: true, }, - projectReferences: config.projectReferences, }); const checker = program.getTypeChecker(); @@ -114,6 +115,272 @@ function getDefaultIntrinsicElementName( return undefined; } +/** + * Extract style selectors from a component's *ClassNames export. + * + * Looks for exports matching the pattern `${componentName}ClassNames` (case-insensitive first char) + * and extracts each property as a style selector with its JSDoc description. + * + * @example + * ```ts + * export const navigationBarClassNames = { + * /** Root element *\/ + * root: 'cds-NavigationBar', + * /** Start slot *\/ + * start: 'cds-NavigationBar-start', + * } as const; + * ``` + * + * Would produce: + * ```ts + * [ + * { selector: 'root', className: 'cds-NavigationBar', description: 'Root element' }, + * { selector: 'start', className: 'cds-NavigationBar-start', description: 'Start slot' }, + * ] + * ``` + */ +function extractStyleSelectorsFromClassNamesExport( + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + componentName: string, +): StylesData | undefined { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return undefined; + + // Look for export matching pattern: componentNameClassNames (case-insensitive first char) + // e.g., NavigationBar -> navigationBarClassNames or NavigationBarClassNames + const lowerFirstChar = componentName.charAt(0).toLowerCase() + componentName.slice(1); + const classNamesExportName = `${lowerFirstChar}ClassNames`; + + const exports = checker.getExportsOfModule(moduleSymbol); + const classNamesSymbol = exports.find( + (s) => s.name.toLowerCase() === classNamesExportName.toLowerCase(), + ); + + if (!classNamesSymbol) return undefined; + + // Get the type of the classNames object + const classNamesType = checker.getTypeOfSymbolAtLocation(classNamesSymbol, sourceFile); + const properties = checker.getPropertiesOfType(classNamesType); + + if (properties.length === 0) return undefined; + + const selectors: StyleSelector[] = properties.map((prop) => { + const propName = prop.getName(); + + // Get the value (class name string) + const propType = checker.getTypeOfSymbolAtLocation(prop, sourceFile); + let className = ''; + if (propType.flags & ts.TypeFlags.StringLiteral) { + className = (propType as ts.LiteralType).value as string; + } + + // Get JSDoc comment for description + const jsDocComment = ts.displayPartsToString(prop.getDocumentationComment(checker)); + const description = formatString(jsDocComment); + + return { + selector: propName, + className, + description, + }; + }); + + return { selectors }; +} + +/** + * Recursively walk a type alias's AST to find a `styles` property symbol. + * + * This handles cases where the top-level type resolution fails to expose `styles`, + * such as when `styles` is defined in an inline intersection inside a complex generic + * wrapper like `Polymorphic.Props`. + * + * The function walks: + * - Type reference arguments (e.g., the `B` in `SomeType`) + * - Intersection type members (e.g., each part of `A & B & { styles?: {...} }`) + */ +function findStylesPropertyInTypeNode( + checker: ts.TypeChecker, + typeNode: ts.TypeNode, +): ts.Symbol | undefined { + // For type references with type arguments (e.g., Polymorphic.Props), + // check each type argument for a `styles` property. + if (ts.isTypeReferenceNode(typeNode) && typeNode.typeArguments) { + for (const arg of typeNode.typeArguments) { + const argType = checker.getTypeAtLocation(arg); + const found = checker.getPropertiesOfType(argType).find((p) => p.getName() === 'styles'); + if (found) return found; + + // Recurse into nested type references and intersections + const nested = findStylesPropertyInTypeNode(checker, arg); + if (nested) return nested; + } + } + + // For intersection types (A & B & { styles?: {...} }), + // check each member individually. + if (ts.isIntersectionTypeNode(typeNode)) { + for (const member of typeNode.types) { + const memberType = checker.getTypeAtLocation(member); + const found = checker.getPropertiesOfType(memberType).find((p) => p.getName() === 'styles'); + if (found) return found; + + // Recurse into nested type references + const nested = findStylesPropertyInTypeNode(checker, member); + if (nested) return nested; + } + } + + return undefined; +} + +/** + * Extract style selectors from a component's `styles` prop type definition. + * + * Uses the TypeScript type checker to resolve the full type, which handles: + * - Inline styles prop definitions + * - Inherited styles from base types (e.g., SidebarBaseProps -> SidebarProps) + * - Intersection types (A & B & { styles: {...} }) + * + * @example + * ```ts + * // Works with inline definitions: + * export type StepperProps = { + * styles?: { + * /** Inline styles for the root element *\/ + * root?: React.CSSProperties; + * }; + * }; + * + * // Also works with inherited types: + * export type SidebarBaseProps = { + * styles?: { root?: React.CSSProperties; }; + * }; + * export type SidebarProps = SidebarBaseProps & { ... }; + * ``` + */ +function extractStyleSelectorsFromStylesProp( + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + componentName: string, +): StylesData | undefined { + const moduleSymbol = checker.getSymbolAtLocation(sourceFile); + if (!moduleSymbol) return undefined; + + // Look for the component's Props type export + // e.g., Stepper -> StepperProps + const propsTypeName = `${componentName}Props`; + + const exports = checker.getExportsOfModule(moduleSymbol); + let propsSymbol = exports.find((s) => s.name === propsTypeName); + + if (!propsSymbol) return undefined; + + // Handle re-exported types: follow the alias to the actual declaration + // e.g., `export type { SelectProps } from './types'` -> resolve to actual SelectProps + if (propsSymbol.flags & ts.SymbolFlags.Alias) { + propsSymbol = checker.getAliasedSymbol(propsSymbol); + } + + // Check if the symbol has type parameters (generic type) + const declarations = propsSymbol.getDeclarations(); + const isGenericType = declarations?.some( + (d) => ts.isTypeAliasDeclaration(d) && d.typeParameters && d.typeParameters.length > 0, + ); + + let propsType: ts.Type; + + if (isGenericType) { + // For generic types like SelectProps or RollingNumberProps, + // getDeclaredTypeOfSymbol returns the uninstantiated type which may not resolve + // nested properties correctly. Instead, get the type from the RHS of the type alias. + const typeAliasDecl = declarations?.find(ts.isTypeAliasDeclaration); + if (typeAliasDecl) { + propsType = checker.getTypeAtLocation(typeAliasDecl.type); + } else { + propsType = checker.getDeclaredTypeOfSymbol(propsSymbol); + } + } else { + // For non-generic types, use getDeclaredTypeOfSymbol (handles inheritance and intersections) + propsType = checker.getDeclaredTypeOfSymbol(propsSymbol); + } + + const propsProperties = checker.getPropertiesOfType(propsType); + + // Find the 'styles' property in the resolved type + let stylesSymbol = propsProperties.find((p) => p.getName() === 'styles'); + + // If styles not found in the resolved type (e.g. complex generic wrappers like Polymorphic.Props), + // walk the type alias's AST to find styles in type arguments or intersection members. + if (!stylesSymbol && isGenericType) { + const typeAliasDecl = declarations?.find(ts.isTypeAliasDeclaration); + if (typeAliasDecl) { + stylesSymbol = findStylesPropertyInTypeNode(checker, typeAliasDecl.type); + } + } + + if (!stylesSymbol) return undefined; + + // Get the type of the styles property + const stylesType = checker.getTypeOfSymbolAtLocation(stylesSymbol, sourceFile); + + // Handle optional types (styles?: {...}) - get the non-undefined type + const nonNullableStylesType = checker.getNonNullableType(stylesType); + const stylesProperties = checker.getPropertiesOfType(nonNullableStylesType); + + if (stylesProperties.length === 0) return undefined; + + // Extract selectors from the styles type properties + const selectors: StyleSelector[] = stylesProperties.map((prop) => { + const propName = prop.getName(); + + // Get JSDoc comment for description using the type checker + let description = ts.displayPartsToString(prop.getDocumentationComment(checker)); + description = formatString(description); + + // Clean up the description - remove common prefixes to make descriptions more concise + description = description + .replace(/^Inline styles for\s+(the\s+)?/i, '') + .replace(/^Custom styles for\s+(the\s+)?/i, '') + .replace(/^Custom style for\s+(the\s+)?/i, '') + .replace(/^Styles for\s+(the\s+)?/i, '') + .replace(/^A CSS class name applied to\s+(the\s+)?/i, ''); + + return { + selector: propName, + className: '', // No static class name for inline styles-based components + description, + }; + }); + + return { selectors }; +} + +/** + * Extract style selectors from a component - tries multiple extraction methods: + * 1. First looks for a *ClassNames export (preferred, has static class names) + * 2. Falls back to extracting from `styles` prop type definition + */ +function extractStyleSelectors( + checker: ts.TypeChecker, + sourceFile: ts.SourceFile, + componentName: string, +): StylesData | undefined { + // First try to get from *ClassNames export (has static class names) + const fromClassNames = extractStyleSelectorsFromClassNamesExport( + checker, + sourceFile, + componentName, + ); + if (fromClassNames && fromClassNames.selectors.length > 0) { + return fromClassNames; + } + + // Fall back to extracting from styles prop type + return extractStyleSelectorsFromStylesProp(checker, sourceFile, componentName); +} + /** * Augment docgen output for **web polymorphic components** by injecting props inherited from the * component's default intrinsic element. @@ -226,14 +493,14 @@ function getDocParent({ declarations = [], parent }: PropItem) { return declaration ?? parent?.name ?? ''; } -function getDocExample(doc: Doc) { +export function getDocExample(doc: Doc) { if (!doc.tags?.example) return undefined; return doc.tags.example.includes('tsx') ? doc.tags.example.replaceAll('tsx', 'tsx live') : '```tsx live\n' + doc.tags.example + '\n```'; } -function formatPropItemType(value: string) { +export function formatPropItemType(value: string) { switch (value) { case 'ReactElement>': return 'ReactElement'; @@ -347,7 +614,7 @@ export function docgenParser({ } /** React docgen integration */ - return withCustomConfig(params.tsconfigPath, { + return withCompilerOptions(tsCtx.program.getCompilerOptions(), { savePropValueAsString: true, shouldExtractValuesFromUnion: true, shouldExtractLiteralValuesFromEnum: true, @@ -355,7 +622,7 @@ export function docgenParser({ shouldIncludePropTagMap: true, shouldIncludeExpression: true, }) - .parse(filesToParse) + .parseWithProgramProvider(filesToParse, () => tsCtx.program) .map((doc) => { const parentTypes: Record = {}; @@ -376,6 +643,17 @@ export function docgenParser({ addToSharedTypeAliases, formatString, }); - return processDoc({ ...consumerProcessedDoc, parentTypes }); + const processedDoc = processDoc({ ...consumerProcessedDoc, parentTypes }); + + // Extract style selectors from *ClassNames exports + const sourceFile = tsCtx.program.getSourceFile(doc.filePath); + if (sourceFile) { + const styles = extractStyleSelectors(tsCtx.checker, sourceFile, doc.displayName); + if (styles && styles.selectors.length > 0) { + return { ...processedDoc, styles }; + } + } + + return processedDoc; }); } diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenRunner.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenRunner.ts index 2fb7345b7f..d4d8696bd4 100644 --- a/libs/docusaurus-plugin-docgen/src/scripts/docgenRunner.ts +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenRunner.ts @@ -15,6 +15,8 @@ import type { Projects, WriteFileConfig, } from '../types'; +import type { EntryPointCacheEntry } from '../utils/docgenCache'; +import { computeEntryPointHash, loadDocgenCache, saveDocgenCache } from '../utils/docgenCache'; import { getPackageJsonFromTsconfig } from '../utils/getPackageJsonFromTsconfig'; import { logger } from '../utils/logger'; @@ -122,6 +124,9 @@ export async function docgenRunner(params: DocgenRunnerParams): Promise = {}; + projects.forEach(({ tsconfigPath, projectDir, files }) => { const { name: packageNameWithScope = '', @@ -161,75 +166,134 @@ export async function docgenRunner(params: DocgenRunnerParams): Promise { - /** - * Turn absolute path of parsed doc into path relative to project. - * This should match what was provided in config. - * i.e. `Users/katherinemartinez/cds/packages/web/src/accordions/Accordion.tsx` into `web/accordions/Accordion.tsx`. - */ - const destDir = getTempDirForDoc({ projectDir, doc }); - const [, ...destDirWithoutProjectArray] = destDir.split('/'); - const slug = destDirWithoutProjectArray.join('/'); - - const data: OutputDoc = { - ...doc, - cacheDirectory: path.join(pluginDir, destDir), - repoUrl, - importBlock: { - name: doc.displayName, - path: path.join(packageNameWithScope, slug), - }, - apiPartial: { - name: `${capitalize(`${projectName}`)}PropsTable`, - path: path.join(':docgen', destDir, 'api.mdx'), - }, - changelogPartial: { - name: `${capitalize(`${projectName}`)}Changelog`, - path: path.join(':docgen', destDir, 'changelog.mdx'), - }, - tab: { label: capitalize(projectName), value: projectName }, - slug, - }; - - docs.add(data); - - /** TODO: Pull codegen 2.0 into separate package and pull in here. - * Then we can just pass in the directory and it will run codegen on all templates in directory - * rather than having to define each separately. - */ - - /** Data from react-docgen-typescript */ + /** + * Content-hash disk cache: if the source files and tsconfig for this entry point + * haven't changed since the last run, reuse the cached parse results instead of + * re-creating a TypeScript program and running react-docgen-typescript. + */ + const absoluteFiles = files.map((f) => path.join(projectDir, f)); + const entryPointHash = computeEntryPointHash(absoluteFiles, tsconfigPath); + const cachedEntry = diskCache?.entryPoints[tsconfigPath]; + + let parsedDocs: ProcessedDoc[]; + + if (cachedEntry && cachedEntry.hash === entryPointHash) { + // Cache hit — skip parsing, replay shared cache contributions + logger.cacheHit(path.basename(projectDir)); + parsedDocs = cachedEntry.docs; + for (const prop of cachedEntry.parentTypeProps) { + sharedParentTypesCache.add(prop); + } + for (const [key, value] of cachedEntry.typeAliases) { + sharedTypeAliasesCache.set(key, value); + } + newCacheEntries[tsconfigPath] = cachedEntry; + } else { + // Cache miss — parse and capture shared cache contributions + logger.cacheMiss(path.basename(projectDir)); + const parentTypesBefore = new Set(sharedParentTypesCache); + const typeAliasesBefore = new Map(sharedTypeAliasesCache); + + parsedDocs = docgenParser({ tsconfigPath, projectDir, files, onProcessDoc }); + + const newParentTypeProps = [...sharedParentTypesCache].filter( + (p) => !parentTypesBefore.has(p), + ); + const newTypeAliases = [...sharedTypeAliasesCache.entries()].filter( + ([k]) => !typeAliasesBefore.has(k), + ); + newCacheEntries[tsconfigPath] = { + hash: entryPointHash, + docs: parsedDocs, + parentTypeProps: newParentTypeProps, + typeAliases: newTypeAliases, + }; + } + + selectPrimaryDocs(parsedDocs).forEach(({ example, ...doc }) => { + /** + * Turn absolute path of parsed doc into path relative to project. + * This should match what was provided in config. + * i.e. `Users/katherinemartinez/cds/packages/web/src/accordions/Accordion.tsx` into `web/accordions/Accordion.tsx`. + */ + const destDir = getTempDirForDoc({ projectDir, doc }); + const [, ...destDirWithoutProjectArray] = destDir.split('/'); + const slug = destDirWithoutProjectArray.join('/'); + + const data: OutputDoc = { + ...doc, + cacheDirectory: path.join(pluginDir, destDir), + repoUrl, + importBlock: { + name: doc.displayName, + path: path.join(packageNameWithScope, slug), + }, + apiPartial: { + name: `${capitalize(`${projectName}`)}PropsTable`, + path: path.join(':docgen', destDir, 'api.mdx'), + }, + changelogPartial: { + name: `${capitalize(`${projectName}`)}Changelog`, + path: path.join(':docgen', destDir, 'changelog.mdx'), + }, + tab: { label: capitalize(projectName), value: projectName }, + slug, + }; + + docs.add(data); + + /** TODO: Pull codegen 2.0 into separate package and pull in here. + * Then we can just pass in the directory and it will run codegen on all templates in directory + * rather than having to define each separately. + */ + + /** Data from react-docgen-typescript */ + filesToWriteToDisk.push({ + data, + dest: path.join(pluginDir, destDir, 'data.js'), + template: 'shared/objectMap', + }); + + filesToWriteToDisk.push({ + data: data.props.map((item) => ({ id: item.name, level: 3, value: item.name })), + dest: path.join(pluginDir, destDir, 'toc-props.js'), + template: 'shared/objectMap', + }); + + /** Styles API data - extracted from *ClassNames exports */ + if (data.styles && data.styles.selectors.length > 0) { filesToWriteToDisk.push({ - data, - dest: path.join(pluginDir, destDir, 'data.js'), + data: data.styles, + dest: path.join(pluginDir, destDir, 'styles-data.js'), template: 'shared/objectMap', }); filesToWriteToDisk.push({ - data: data.props.map((item) => ({ id: item.name, level: 3, value: item.name })), - dest: path.join(pluginDir, destDir, 'toc-props.js'), + data: [{ id: 'selectors', level: 3, value: 'Selectors' }], + dest: path.join(pluginDir, destDir, 'toc-styles.js'), template: 'shared/objectMap', }); + } + + /** MDX file with PropsTable react component. Passes in props from js file in .docusaurus cache */ + filesToWriteToDisk.push({ + data, + dest: path.join(pluginDir, destDir, 'api.mdx'), + template: 'doc-item/api', + }); - /** MDX file with PropsTable react component. Passes in props from js file in .docusaurus cache */ + if (example) { filesToWriteToDisk.push({ - data, - dest: path.join(pluginDir, destDir, 'api.mdx'), - template: 'doc-item/api', + data: { example }, + dest: path.join(pluginDir, destDir, 'example.mdx'), + template: 'doc-item/example', }); - - if (example) { - filesToWriteToDisk.push({ - data: { example }, - dest: path.join(pluginDir, destDir, 'example.mdx'), - template: 'doc-item/example', - }); - } - }, - ); + } + }); }); + saveDocgenCache(pluginDir, newCacheEntries); + logger.preppingData(); if (docsDir) { diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenWriter.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenWriter.ts index 5ad1ea2247..6f5509a763 100644 --- a/libs/docusaurus-plugin-docgen/src/scripts/docgenWriter.ts +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenWriter.ts @@ -4,7 +4,6 @@ import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; import fs from 'node:fs'; import path from 'node:path'; -import prettier from 'prettier'; import type { WriteFileConfig } from '../types'; @@ -22,32 +21,11 @@ const helpers = { startCase, }; -function getParser(dest: string): prettier.BuiltInParserName { - const ext = path.extname(dest); - switch (ext) { - case '.mdx': - return 'mdx'; - case '.js': - return 'babel'; - case '.json': - return 'json'; - case '.ts': - default: - return 'typescript'; - } -} - export async function writeFile({ dest, data }: WriteFileParams) { const content = typeof data === 'string' ? data : JSON.stringify(data); const dirForFile = path.dirname(dest); - // If directory doesn't already exist, create it. await fs.promises.mkdir(dirForFile, { recursive: true }); - const prettierOptions = await prettier.resolveConfig('./prettierConfig.json'); - const prettiered = await prettier.format(content, { - ...prettierOptions, - parser: getParser(dest), - }); - return fs.promises.writeFile(dest, prettiered, writeConfig); + return fs.promises.writeFile(dest, content, writeConfig); } const templatesDir = path.join(__dirname, '../templates'); diff --git a/libs/docusaurus-plugin-docgen/src/types.ts b/libs/docusaurus-plugin-docgen/src/types.ts index 57cf503684..a3a1e1d6ac 100644 --- a/libs/docusaurus-plugin-docgen/src/types.ts +++ b/libs/docusaurus-plugin-docgen/src/types.ts @@ -15,8 +15,7 @@ export type PluginOptions = { */ docsDir?: string; /** - * Determines if plugin should run. If plugin is too slow in development, - * you can either increase watchInterval or set this to false. + * Determines if plugin should run. Set to false to disable docgen entirely. * @default true */ enabled?: boolean; @@ -46,12 +45,6 @@ export type PluginOptions = { * An array of source files you want docgen to parse. */ sourceFiles: string[]; - /** - * How frequently (in minutes) should plugin run after it was last run. - * This is typically triggered via on save of project file. - * @default 5 - */ - watchInterval?: number; }; /* -------------------------------------------------------------------------- */ @@ -133,6 +126,8 @@ export type PreProcessedPropItem = Omit & { props: ProcessedPropItem[]; parentTypes: Record; + /** Styles API data extracted from *ClassNames exports */ + styles?: StylesData; }; export type ProcessedPropItem = Omit & { @@ -221,6 +216,30 @@ export type SharedTypeAliases = Record; export type SharedParentTypes = Record>; export type Projects = DocgenProjectMetadata[]; +/* -------------------------------------------------------------------------- */ +/* Styles API Types */ +/* -------------------------------------------------------------------------- */ + +/** + * Represents a style selector extracted from a component's *ClassNames export. + */ +export type StyleSelector = { + /** The selector key (e.g., "root", "start", "content") */ + selector: string; + /** The static CSS class name (e.g., "cds-NavigationBar", "cds-NavigationBar-start") */ + className: string; + /** Description from JSDoc comment */ + description: string; +}; + +/** + * Styles API data extracted from a component. + */ +export type StylesData = { + /** Array of style selectors for the component */ + selectors: StyleSelector[]; +}; + export type DocgenProjectMetadata = { label: string; name: string; diff --git a/libs/docusaurus-plugin-docgen/src/utils/docgenCache.test.ts b/libs/docusaurus-plugin-docgen/src/utils/docgenCache.test.ts new file mode 100644 index 0000000000..e13d238d15 --- /dev/null +++ b/libs/docusaurus-plugin-docgen/src/utils/docgenCache.test.ts @@ -0,0 +1,171 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { ProcessedDoc, ProcessedPropItem } from '../types'; + +import type { EntryPointCacheEntry } from './docgenCache'; +import { computeEntryPointHash, loadDocgenCache, saveDocgenCache } from './docgenCache'; + +function createTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'docgen-cache-test-')); +} + +function createTempFile(dir: string, name: string, content: string): string { + const filePath = path.join(dir, name); + fs.writeFileSync(filePath, content, 'utf-8'); + return filePath; +} + +function mockDoc(displayName: string): ProcessedDoc { + return { displayName, filePath: `/mock/${displayName}.tsx` } as unknown as ProcessedDoc; +} + +function mockPropItem(name: string, parent: string): ProcessedPropItem { + return { name, parent, type: 'string' } as unknown as ProcessedPropItem; +} + +function mockCacheEntry(overrides?: Partial): EntryPointCacheEntry { + return { + hash: 'abc123', + docs: [mockDoc('Button')], + parentTypeProps: [mockPropItem('onClick', 'HTMLAttributes')], + typeAliases: [['SpacingValue', 'number | string']], + ...overrides, + }; +} + +describe('computeEntryPointHash', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('returns a consistent hash for the same files and tsconfig', () => { + const tsconfig = createTempFile(tempDir, 'tsconfig.json', '{"compilerOptions": {}}'); + const fileA = createTempFile(tempDir, 'a.tsx', 'export const A = 1;'); + const fileB = createTempFile(tempDir, 'b.tsx', 'export const B = 2;'); + + const hash1 = computeEntryPointHash([fileA, fileB], tsconfig); + const hash2 = computeEntryPointHash([fileA, fileB], tsconfig); + + expect(hash1).toBe(hash2); + }); + + it('produces the same hash regardless of file order', () => { + const tsconfig = createTempFile(tempDir, 'tsconfig.json', '{}'); + const fileA = createTempFile(tempDir, 'a.tsx', 'A'); + const fileB = createTempFile(tempDir, 'b.tsx', 'B'); + + const hash1 = computeEntryPointHash([fileA, fileB], tsconfig); + const hash2 = computeEntryPointHash([fileB, fileA], tsconfig); + + expect(hash1).toBe(hash2); + }); + + it('produces a different hash when a source file changes', () => { + const tsconfig = createTempFile(tempDir, 'tsconfig.json', '{}'); + const file = createTempFile(tempDir, 'comp.tsx', 'version 1'); + + const hash1 = computeEntryPointHash([file], tsconfig); + + fs.writeFileSync(file, 'version 2', 'utf-8'); + const hash2 = computeEntryPointHash([file], tsconfig); + + expect(hash1).not.toBe(hash2); + }); + + it('produces a different hash when the tsconfig changes', () => { + const tsconfig = createTempFile(tempDir, 'tsconfig.json', '{"v": 1}'); + const file = createTempFile(tempDir, 'comp.tsx', 'unchanged'); + + const hash1 = computeEntryPointHash([file], tsconfig); + + fs.writeFileSync(tsconfig, '{"v": 2}', 'utf-8'); + const hash2 = computeEntryPointHash([file], tsconfig); + + expect(hash1).not.toBe(hash2); + }); + + it('handles missing files gracefully', () => { + const tsconfig = createTempFile(tempDir, 'tsconfig.json', '{}'); + const missingFile = path.join(tempDir, 'does-not-exist.tsx'); + + expect(() => computeEntryPointHash([missingFile], tsconfig)).not.toThrow(); + }); +}); + +describe('saveDocgenCache / loadDocgenCache', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('round-trips cache data through save and load', () => { + const entry = mockCacheEntry(); + saveDocgenCache(tempDir, { '/path/to/tsconfig.json': entry }); + + const loaded = loadDocgenCache(tempDir); + + expect(loaded).not.toBeNull(); + expect(loaded!.entryPoints['/path/to/tsconfig.json']).toEqual(entry); + }); + + it('returns null when no cache file exists', () => { + const loaded = loadDocgenCache(tempDir); + expect(loaded).toBeNull(); + }); + + it('returns null when cache file contains invalid JSON', () => { + fs.writeFileSync(path.join(tempDir, '.docgen-cache.json'), 'not json', 'utf-8'); + + const loaded = loadDocgenCache(tempDir); + expect(loaded).toBeNull(); + }); + + it('returns null when cache version does not match', () => { + const staleCache = { version: '0', entryPoints: {} }; + fs.writeFileSync(path.join(tempDir, '.docgen-cache.json'), JSON.stringify(staleCache), 'utf-8'); + + const loaded = loadDocgenCache(tempDir); + expect(loaded).toBeNull(); + }); + + it('preserves multiple entry points', () => { + const entryA = mockCacheEntry({ hash: 'aaa' }); + const entryB = mockCacheEntry({ hash: 'bbb', docs: [mockDoc('Avatar')] }); + + saveDocgenCache(tempDir, { + '/packages/web/tsconfig.json': entryA, + '/packages/mobile/tsconfig.json': entryB, + }); + + const loaded = loadDocgenCache(tempDir); + + expect(loaded!.entryPoints['/packages/web/tsconfig.json'].hash).toBe('aaa'); + expect(loaded!.entryPoints['/packages/mobile/tsconfig.json'].hash).toBe('bbb'); + expect(loaded!.entryPoints['/packages/mobile/tsconfig.json'].docs[0].displayName).toBe( + 'Avatar', + ); + }); + + it('overwrites previous cache on save', () => { + saveDocgenCache(tempDir, { '/a': mockCacheEntry({ hash: 'first' }) }); + saveDocgenCache(tempDir, { '/b': mockCacheEntry({ hash: 'second' }) }); + + const loaded = loadDocgenCache(tempDir); + + expect(loaded!.entryPoints['/a']).toBeUndefined(); + expect(loaded!.entryPoints['/b'].hash).toBe('second'); + }); +}); diff --git a/libs/docusaurus-plugin-docgen/src/utils/docgenCache.ts b/libs/docusaurus-plugin-docgen/src/utils/docgenCache.ts new file mode 100644 index 0000000000..89b0c0e4ba --- /dev/null +++ b/libs/docusaurus-plugin-docgen/src/utils/docgenCache.ts @@ -0,0 +1,71 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { ProcessedDoc, ProcessedPropItem } from '../types'; + +export type EntryPointCacheEntry = { + /** Combined hash of all source file contents + tsconfig for this entry point */ + hash: string; + /** The parsed docs for this entry point */ + docs: ProcessedDoc[]; + /** Parent type props contributed by this entry point (replayed on cache hit) */ + parentTypeProps: ProcessedPropItem[]; + /** Type alias entries contributed by this entry point (replayed on cache hit) */ + typeAliases: [string, unknown][]; +}; + +type CacheData = { + version: string; + entryPoints: Record; +}; + +/** + * Bump this when the parser output format changes to invalidate all caches. + * A cache version mismatch causes a full re-parse on the next run. + */ +const CACHE_VERSION = '1'; + +/** + * Compute a content hash for all source files in an entry point plus its tsconfig. + * Changes to any file or the tsconfig will produce a different hash, triggering a re-parse. + */ +export function computeEntryPointHash(absoluteFilePaths: string[], tsconfigPath: string): string { + const hash = crypto.createHash('md5'); + try { + hash.update(fs.readFileSync(tsconfigPath, 'utf-8')); + } catch { + hash.update(`missing-tsconfig:${tsconfigPath}`); + } + for (const filePath of absoluteFilePaths.sort()) { + try { + hash.update(filePath); + hash.update(fs.readFileSync(filePath, 'utf-8')); + } catch { + hash.update(`missing:${filePath}`); + } + } + return hash.digest('hex'); +} + +export function loadDocgenCache(pluginDir: string): CacheData | null { + const cachePath = path.join(pluginDir, '.docgen-cache.json'); + try { + const raw = fs.readFileSync(cachePath, 'utf-8'); + const data: CacheData = JSON.parse(raw); + if (data.version !== CACHE_VERSION) return null; + return data; + } catch { + return null; + } +} + +export function saveDocgenCache( + pluginDir: string, + entryPoints: Record, +): void { + const cachePath = path.join(pluginDir, '.docgen-cache.json'); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + const data: CacheData = { version: CACHE_VERSION, entryPoints }; + fs.writeFileSync(cachePath, JSON.stringify(data)); +} diff --git a/libs/docusaurus-plugin-docgen/src/utils/logger.ts b/libs/docusaurus-plugin-docgen/src/utils/logger.ts index 83dfbf324c..b39de16fcb 100644 --- a/libs/docusaurus-plugin-docgen/src/utils/logger.ts +++ b/libs/docusaurus-plugin-docgen/src/utils/logger.ts @@ -6,12 +6,6 @@ export const logger = { init: () => { log.info(`${PREFIX}: Checking config`); }, - lastUpdate: (lastUpdate: number) => { - log.info(`${PREFIX}: Last update was ${lastUpdate.toFixed(2)} minutes ago...`); - }, - initStatus: (shouldUpdate: boolean) => { - log.info(`${PREFIX}: ${shouldUpdate ? 'Starting...' : 'Skipping...'}`); - }, enabledOff: () => { log.info(`${PREFIX}: enabled:false. Skipping...`); }, @@ -30,6 +24,12 @@ export const logger = { preppingDoc: (doc: string) => { log.info(`${PREFIX}: ${doc} has not been generated yet. Prepping...`); }, + cacheHit: (entryPoint: string) => { + log.info(`${PREFIX}: Cache hit for ${entryPoint}, skipping parse`); + }, + cacheMiss: (entryPoint: string) => { + log.info(`${PREFIX}: Cache miss for ${entryPoint}, parsing...`); + }, writingData: () => { log.info(`${PREFIX}: Writing data...`); }, diff --git a/libs/docusaurus-plugin-kbar/package.json b/libs/docusaurus-plugin-kbar/package.json index 74914ed67e..6d32eeef4a 100644 --- a/libs/docusaurus-plugin-kbar/package.json +++ b/libs/docusaurus-plugin-kbar/package.json @@ -29,9 +29,9 @@ ], "dependencies": { "@coinbase/cds-common": "workspace:^", - "@docusaurus/logger": "^3.7.0", - "@docusaurus/plugin-content-docs": "^3.7.0", - "@docusaurus/types": "^3.7.0", + "@docusaurus/logger": "~3.7.0", + "@docusaurus/plugin-content-docs": "~3.7.0", + "@docusaurus/types": "~3.7.0", "kbar": "^0.1.0-beta.45", "lodash": "^4.17.21", "type-fest": "^2.19.0" diff --git a/libs/docusaurus-plugin-llm-dev-server/package.json b/libs/docusaurus-plugin-llm-dev-server/package.json index 9e93d62470..a5140a1d2e 100644 --- a/libs/docusaurus-plugin-llm-dev-server/package.json +++ b/libs/docusaurus-plugin-llm-dev-server/package.json @@ -27,7 +27,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "@docusaurus/types": "^3.7.0", + "@docusaurus/types": "~3.7.0", "@types/express": "^4.17.21" } } diff --git a/libs/docusaurus-plugin-llm-dev-server/src/index.ts b/libs/docusaurus-plugin-llm-dev-server/src/index.ts index 99533c22a5..95b2015cc7 100644 --- a/libs/docusaurus-plugin-llm-dev-server/src/index.ts +++ b/libs/docusaurus-plugin-llm-dev-server/src/index.ts @@ -11,7 +11,7 @@ type PluginOptions = { }; type Platform = 'web' | 'mobile'; -type DocType = 'components' | 'hooks' | 'getting-started'; +type DocType = 'components' | 'hooks' | 'getting-started' | 'guides'; export default function plugin(context: LoadContext, options: PluginOptions = {}): Plugin { const { siteDir } = context; @@ -81,7 +81,7 @@ export default function plugin(context: LoadContext, options: PluginOptions = {} return res.status(404).send('Platform not found'); } - if (!['components', 'hooks', 'getting-started'].includes(docType)) { + if (!['components', 'hooks', 'getting-started', 'guides'].includes(docType)) { return res.status(404).send('Doc type not found'); } diff --git a/libs/eslint-plugin-internal/README.md b/libs/eslint-plugin-internal/README.md index d6db86da4c..9fdc7a32a8 100644 --- a/libs/eslint-plugin-internal/README.md +++ b/libs/eslint-plugin-internal/README.md @@ -6,7 +6,64 @@ For simplicity there is no build process since the repo root depends on this lib The plugin encapsulates the following rules: -### no-deprecated-jsdoc +## deprecated-jsdoc-has-removal-version + +Enforces that every JSDoc `@deprecated` tag meets two requirements: + +1. The `@deprecated` text ends with the standard prose: `This will be removed in a future major release.` +2. The same JSDoc block includes a `@deprecationExpectedRemoval vX[.Y.Z]` tag specifying the planned removal version. + +Together these rules: + +1. ensure consumers see a consistent removal notice in their IDE tooltips +2. gives us a way to track and be held accountable for older deprecations + +**Invalid** — missing both: + +```ts +/** @deprecated Use React.useState instead. */ +function useToggler() {} +``` + +**Invalid** — prose present but tag missing: + +```ts +/** + * @deprecated Use React.useState instead. This will be removed in a future major release. + */ +function useToggler() {} +``` + +**Invalid** — tag present but prose missing or not at end of `@deprecated` text: + +```ts +/** + * @deprecated Use React.useState instead. + * @deprecationExpectedRemoval v7 + */ +function useToggler() {} +``` + +**Valid:** + +```ts +/** + * @deprecated Use React.useState instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v7.0.0 + */ +function useToggler() {} +``` + +The rule catches deprecation markers on the same node types as `no-deprecated-jsdoc`: + +- Function declarations +- Variable/const declarations +- Type alias declarations (including properties within object types) +- Interface declarations (including properties) +- Class declarations (including members) +- Export declarations + +## no-deprecated-jsdoc Detects JSDoc comments containing `@deprecated` tags. This rule helps identify deprecated code that should be migrated or removed in later, breaking version releases. @@ -19,18 +76,60 @@ The rule catches deprecation markers on: - Class declarations (including members) - Export declarations -### safely-spread-props +## no-object-rest-spread-in-worklet + +Disallows object rest/spread syntax inside functions marked with the Reanimated `'worklet'` directive. + +This prevents crashes where transpiled helper functions (such as Babel's `_objectWithoutPropertiesLoose`) are called on the UI thread as non-worklet functions. + +Examples this rule flags inside worklets: + +- `const { delay, ...config } = transition` +- `const next = { ...config, duration: 200 }` + +Recommended pattern inside worklets: + +- Read fields directly (for example, `const delayMs = transition.delay`) +- Pass existing objects directly when safe, rather than reconstructing with spread + +## safely-spread-props This rule checks that React component `...spread` props do not contain properties that the receiving component does not expect. CDS components often compose together type interfaces from many other components. In some of those cases the component with the majority of the props usually receives its props with a `...spread`. -We have encounted situations where developers accidentally forgot to descture a prop intended for a different element and it ended up passed to the wrong component via the spread props. +We have encountered situations where developers accidentally forgot to destructure a prop intended for a different element and it ended up passed to the wrong component via spread props. -At this time this rule is intended to only be used within this repo in the cds-web and cds-modile packages. However, after a trial period we may consider opening it up to a wider audience. +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. -### example-screen-default +## example-screen-default Ensures every Storybook file default-exports a component whose rendered output is rooted in `ExampleScreen`. This keeps documentation consistent and aligns with the patterns showcased in the mobile package. -### example-screen-contains-example +## example-screen-contains-example Validates that any `ExampleScreen` Storybook story ultimately renders at least one `` component. The rule looks through components defined in the same file to make sure examples exist even when they are encapsulated in helper components. + +## figma-connect-imports-required + +Ensures that `figma.connect()` calls have a non-empty `imports` array. This rule validates that: + +- The `imports` property exists in the config object +- The `imports` property is an array +- The `imports` array contains at least one import statement + +## figma-connect-imports-package-match + +Ensures that import paths in `figma.connect()` calls match the package context of the file. This rule validates that imports come from the same package as the file containing the `figma.connect()` call. Shared packages like `@coinbase/cds-common` are allowed from any context. + +## no-typescript-in-jsx-codeblock + +An ESLint _processor_ (not a traditional rule) for MDX files that detects fenced code blocks marked as ` ```jsx ` which contain TypeScript syntax. These blocks should either use `tsx` as the language tag or have the TypeScript annotations removed. + +Because MDX files cannot be parsed by standard JavaScript/TypeScript parsers, this is implemented as a processor that scans raw MDX text for code fence patterns and injects lint messages in postprocess. It supports autofix, replacing `jsx` with `tsx` in the language tag. + +TypeScript patterns detected include: + +- Type alias and interface declarations +- Parameter type annotations (destructured and non-destructured) +- Variable type annotations +- Return type annotations on arrow functions +- Generic type arguments diff --git a/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.mjs b/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.mjs new file mode 100644 index 0000000000..dbd32a50b3 --- /dev/null +++ b/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.mjs @@ -0,0 +1,181 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator(() => null); + +const EXPECTED_REMOVAL_TAG_PATTERN = /@deprecationExpectedRemoval\s+v(\d+(?:\.\d+\.\d+)?)/; +const FUTURE_MAJOR_RELEASE_SUFFIX = 'This will be removed in a future major release.'; + +/** + * Rule: deprecated-jsdoc-has-removal-version + * + * Enforces that any JSDoc @deprecated tag: + * 1. Has its text end with "This will be removed in a future major release." + * 2. Is accompanied by a @deprecationExpectedRemoval vX[.Y.Z] tag in the same block. + */ +const rule = createRule({ + name: 'deprecated-jsdoc-has-removal-version', + meta: { + type: 'problem', + docs: { + description: + 'Require JSDoc @deprecated tags to end with the standard removal prose and include a @deprecationExpectedRemoval tag', + recommended: 'error', + }, + schema: [], + messages: { + missingRemovalProse: + '@deprecated tag text must end with "This will be removed in a future major release."', + missingRemovalTag: + 'JSDoc with @deprecated must include a @deprecationExpectedRemoval vX[.Y.Z] tag.', + }, + }, + defaultOptions: [], + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode?.(); + + function checkComment(comment) { + if (comment.type !== 'Block' || !comment.value.startsWith('*')) return; + if (!comment.value.includes('@deprecated')) return; + + const hasRemovalTag = EXPECTED_REMOVAL_TAG_PATTERN.test(comment.value); + + // Extract the @deprecated line(s) to check the prose ending. + // The @deprecated tag text runs from @deprecated to the next @ tag or end of comment. + const deprecatedIndex = comment.value.indexOf('@deprecated'); + const afterDeprecated = comment.value.slice(deprecatedIndex + '@deprecated'.length); + + // Find where the @deprecated tag content ends (at the next @ tag or end of comment body) + const nextTagMatch = afterDeprecated.match(/\n\s*\*\s*@/); + const deprecatedContent = nextTagMatch + ? afterDeprecated.slice(0, nextTagMatch.index) + : afterDeprecated; + + // Strip leading/trailing whitespace and asterisks from each line, then join + const deprecatedText = deprecatedContent + .split('\n') + .map((l) => l.replace(/^\s*\*?\s?/, '').trimEnd()) + .join(' ') + .trim(); + + const hasProse = deprecatedText.endsWith(FUTURE_MAJOR_RELEASE_SUFFIX); + + if (!hasProse || !hasRemovalTag) { + // Point the error at the @deprecated token itself + const textBefore = comment.value.slice(0, deprecatedIndex); + const linesBeforeDeprecated = textBefore.split('\n').length - 1; + const deprecatedLine = comment.loc.start.line + linesBeforeDeprecated; + + const lastNewlineIndex = textBefore.lastIndexOf('\n'); + let deprecatedColumn; + if (lastNewlineIndex === -1) { + deprecatedColumn = comment.loc.start.column + 2 + deprecatedIndex; + } else { + deprecatedColumn = deprecatedIndex - lastNewlineIndex - 1; + } + + const loc = { + start: { line: deprecatedLine, column: deprecatedColumn }, + end: { line: deprecatedLine, column: deprecatedColumn + '@deprecated'.length }, + }; + + if (!hasProse) { + context.report({ loc, messageId: 'missingRemovalProse' }); + } + if (!hasRemovalTag) { + context.report({ loc, messageId: 'missingRemovalTag' }); + } + } + } + + function getJsDocComment(node) { + const comments = sourceCode.getCommentsBefore(node); + if (!comments || comments.length === 0) return null; + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + if (comment.type === 'Block' && comment.value.startsWith('*')) return comment; + } + return null; + } + + function checkNode(node) { + const comment = getJsDocComment(node); + if (comment) checkComment(comment); + } + + function checkTypeProperties(node) { + const members = node.body?.body || node.members || []; + for (const member of members) { + checkNode(member); + } + } + + function checkTypeAnnotationForLiterals(typeNode) { + if (!typeNode) return; + + switch (typeNode.type) { + case 'TSTypeLiteral': + checkTypeProperties(typeNode); + break; + case 'TSIntersectionType': + case 'TSUnionType': + for (const type of typeNode.types || []) { + checkTypeAnnotationForLiterals(type); + } + break; + case 'TSParenthesizedType': + checkTypeAnnotationForLiterals(typeNode.typeAnnotation); + break; + case 'TSTypeReference': + for (const param of typeNode.typeArguments?.params || + typeNode.typeParameters?.params || + []) { + checkTypeAnnotationForLiterals(param); + } + break; + case 'TSMappedType': + case 'TSConditionalType': + if (typeNode.typeAnnotation) checkTypeAnnotationForLiterals(typeNode.typeAnnotation); + if (typeNode.trueType) checkTypeAnnotationForLiterals(typeNode.trueType); + if (typeNode.falseType) checkTypeAnnotationForLiterals(typeNode.falseType); + break; + case 'TSArrayType': + checkTypeAnnotationForLiterals(typeNode.elementType); + break; + case 'TSTupleType': + for (const element of typeNode.elementTypes || []) { + checkTypeAnnotationForLiterals(element); + } + break; + } + } + + return { + FunctionDeclaration: checkNode, + VariableDeclaration: checkNode, + + TSTypeAliasDeclaration(node) { + checkNode(node); + checkTypeAnnotationForLiterals(node.typeAnnotation); + }, + + TSInterfaceDeclaration(node) { + checkNode(node); + checkTypeProperties(node); + }, + + ClassDeclaration(node) { + checkNode(node); + checkTypeProperties(node); + }, + + ExportNamedDeclaration(node) { + const comment = getJsDocComment(node); + if (comment) checkComment(comment); + }, + + ExportDefaultDeclaration: checkNode, + }; + }, +}); + +export default rule; diff --git a/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.test.mjs b/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.test.mjs new file mode 100644 index 0000000000..efc92a4e6d --- /dev/null +++ b/libs/eslint-plugin-internal/src/deprecated-jsdoc-has-removal-version/index.test.mjs @@ -0,0 +1,230 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from './index.mjs'; + +RuleTester.afterAll = afterAll; +RuleTester.describe = describe; +RuleTester.it = it; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +describe("'deprecated-jsdoc-has-removal-version' rule", () => { + ruleTester.run('deprecated-jsdoc-has-removal-version', rule, { + valid: [ + { + // No @deprecated tag — no requirement + code: ` + /** This is a regular comment */ + const foo = 'bar'; + `, + filename: 'valid.ts', + }, + { + // Single-line @deprecated with both required elements + code: ` + /** + * @deprecated Use React.useState instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v7 + */ + function useToggler() {} + `, + filename: 'valid.ts', + }, + { + // Full semver in removal tag + code: ` + /** + * @deprecated Use React.useState instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v7.0.0 + */ + const useGroupToggler = () => {}; + `, + filename: 'valid.ts', + }, + { + // Multi-line JSDoc with additional content after @deprecated + code: ` + /** + * @deprecated Use the visible and onRequestClose props instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8.0.0 + * @see SomeOtherComponent + */ + export const useModal = () => ({}); + `, + filename: 'valid.ts', + }, + { + // Deprecated property in type + code: ` + export type IconCounterButtonBaseProps = { + icon: string; + /** + * @deprecated Use \`size\` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v7.0.0 + */ + iconSize?: number; + size?: number; + }; + `, + filename: 'valid.ts', + }, + { + // Non-JSDoc block comment — rule should not apply + code: ` + /* @deprecated not a JSDoc comment */ + const foo = 'bar'; + `, + filename: 'valid.ts', + }, + { + // Line comment — rule should not apply + code: ` + // @deprecated not a JSDoc comment + const foo = 'bar'; + `, + filename: 'valid.ts', + }, + ], + invalid: [ + { + // @deprecated with no prose and no removal tag + code: ` + /** @deprecated Use React.useState instead. */ + function useToggler() {} + `, + filename: 'useToggler.ts', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + { + // @deprecated with correct prose but missing removal tag + code: ` + /** + * @deprecated Use React.useState instead. This will be removed in a future major release. + */ + function useToggler() {} + `, + filename: 'useToggler.ts', + errors: [{ messageId: 'missingRemovalTag' }], + }, + { + // @deprecated with removal tag but missing standard prose + code: ` + /** + * @deprecated Use React.useState instead. + * @deprecationExpectedRemoval v7 + */ + function useToggler() {} + `, + filename: 'useToggler.ts', + errors: [{ messageId: 'missingRemovalProse' }], + }, + { + // Old "Targeting removal in vX" sentence — no longer valid + code: ` + /** @deprecated Targeting removal in v7. */ + const useGroupToggler = () => {}; + `, + filename: 'useGroupToggler.ts', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + { + // @deprecated on export — missing both + code: ` + /** + * @deprecated Use the visible and onRequestClose props instead. + */ + export const useModal = () => ({}); + `, + filename: 'useModal.ts', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + { + // @deprecated on exported type — missing both + code: ` + /** @deprecated Use NudgeCard instead */ + export type FeatureEntryCardProps = { name: string }; + `, + filename: 'FeatureEntryCard.tsx', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + { + // @deprecated property in type — missing both + code: ` + export type IconCounterButtonBaseProps = { + icon: string; + /** @deprecated Use \`size\` instead. */ + iconSize?: number; + size?: number; + }; + `, + filename: 'IconCounterButton.tsx', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + { + // removal tag present but version format is invalid (missing v prefix) + code: ` + /** + * @deprecated Use X instead. This will be removed in a future major release. + * @deprecationExpectedRemoval 7 + */ + const oldThing = () => {}; + `, + filename: 'oldThing.ts', + errors: [{ messageId: 'missingRemovalTag' }], + }, + { + // Multiple @deprecated annotations — each missing both elements + code: ` + /** + * @deprecated Please use SelectChip alpha instead. + */ + export type SelectChipProps = { + active?: boolean; + /** + * @deprecated The prop will be removed in a future version. + */ + children?: React.ReactNode; + }; + + /** + * @deprecated Please use SelectChip alpha instead. + */ + export const SelectChip = () => {}; + `, + filename: 'SelectChip.tsx', + errors: [ + { messageId: 'missingRemovalProse' }, + { messageId: 'missingRemovalTag' }, + { messageId: 'missingRemovalProse' }, + { messageId: 'missingRemovalTag' }, + { messageId: 'missingRemovalProse' }, + { messageId: 'missingRemovalTag' }, + ], + }, + { + // Deprecated property inside intersection type — missing both + code: ` + type BaseProps = { name: string }; + export type SelectChipProps = { + /** + * @deprecated The prop will be removed in a future version. + */ + children?: React.ReactNode; + } & BaseProps; + `, + filename: 'intersection-deprecated-prop.tsx', + errors: [{ messageId: 'missingRemovalProse' }, { messageId: 'missingRemovalTag' }], + }, + ], + }); +}); diff --git a/libs/eslint-plugin-internal/src/index.mjs b/libs/eslint-plugin-internal/src/index.mjs index 3ad003fe17..84dfec0f75 100644 --- a/libs/eslint-plugin-internal/src/index.mjs +++ b/libs/eslint-plugin-internal/src/index.mjs @@ -1,8 +1,11 @@ +import deprecatedJsdocHasRemovalVersionRule from './deprecated-jsdoc-has-removal-version/index.mjs'; import exampleScreenContainsExampleRule from './example-screen-contains-example/index.mjs'; import exampleScreenDefaultRule from './example-screen-default/index.mjs'; import figmaConnectImportsPackageMatchRule from './figma-connect-imports-package-match/index.mjs'; import figmaConnectImportsRequiredRule from './figma-connect-imports-required/index.mjs'; import noDeprecatedJsdocRule from './no-deprecated-jsdoc/index.mjs'; +import noObjectRestSpreadInWorkletRule from './no-object-rest-spread-in-worklet/index.mjs'; +import { processor as noTypescriptInJsxCodeblockProcessor } from './no-typescript-in-jsx-codeblock/index.mjs'; import safelySpreadPropsRule from './safely-spread-props/index.mjs'; const plugin = { @@ -11,10 +14,15 @@ const plugin = { 'safely-spread-props': safelySpreadPropsRule, 'example-screen-default': exampleScreenDefaultRule, 'example-screen-contains-example': exampleScreenContainsExampleRule, + 'deprecated-jsdoc-has-removal-version': deprecatedJsdocHasRemovalVersionRule, 'no-deprecated-jsdoc': noDeprecatedJsdocRule, + 'no-object-rest-spread-in-worklet': noObjectRestSpreadInWorkletRule, 'figma-connect-imports-required': figmaConnectImportsRequiredRule, 'figma-connect-imports-package-match': figmaConnectImportsPackageMatchRule, }, + processors: { + mdx: noTypescriptInJsxCodeblockProcessor, + }, configs: {}, }; diff --git a/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.mjs b/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.mjs new file mode 100644 index 0000000000..7669c8fb05 --- /dev/null +++ b/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.mjs @@ -0,0 +1,70 @@ +import { ESLintUtils } from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator((name) => null); + +const hasWorkletDirective = (functionNode) => { + if (!functionNode?.body || functionNode.body.type !== 'BlockStatement') { + return false; + } + + const firstStatement = functionNode.body.body[0]; + return ( + firstStatement?.type === 'ExpressionStatement' && + firstStatement.expression?.type === 'Literal' && + firstStatement.expression.value === 'worklet' + ); +}; + +const isInsideWorkletFunction = (node) => { + let current = node.parent; + + while (current) { + if ( + current.type === 'FunctionDeclaration' || + current.type === 'FunctionExpression' || + current.type === 'ArrowFunctionExpression' + ) { + return hasWorkletDirective(current); + } + current = current.parent; + } + + return false; +}; + +const rule = createRule({ + name: 'no-object-rest-spread-in-worklet', + meta: { + type: 'problem', + docs: { + description: + 'Disallow object rest/spread inside Reanimated worklets to avoid non-worklet helper calls on the UI thread', + recommended: 'error', + }, + schema: [], + messages: { + noObjectRestSpreadInWorklet: + 'Do not use object rest/spread inside a worklet. Reanimated may transpile this into non-worklet helpers and crash on the UI thread.', + }, + }, + defaultOptions: [], + create(context) { + const reportIfWorklet = (node) => { + if (!isInsideWorkletFunction(node)) { + return; + } + + context.report({ + node, + messageId: 'noObjectRestSpreadInWorklet', + }); + }; + + return { + 'ObjectPattern > RestElement': reportIfWorklet, + 'ObjectExpression > SpreadElement': reportIfWorklet, + }; + }, +}); + +export default rule; diff --git a/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.test.mjs b/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.test.mjs new file mode 100644 index 0000000000..b1eaa95fa5 --- /dev/null +++ b/libs/eslint-plugin-internal/src/no-object-rest-spread-in-worklet/index.test.mjs @@ -0,0 +1,65 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import rule from './index.mjs'; + +RuleTester.afterAll = afterAll; +RuleTester.describe = describe; +RuleTester.it = it; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, +}); + +describe("'no-object-rest-spread-in-worklet' rule", () => { + ruleTester.run('no-object-rest-spread-in-worklet', rule, { + valid: [ + { + code: ` + const fn = () => { + const { a, ...rest } = obj; + return { ...rest, a }; + }; + `, + filename: 'valid-non-worklet.ts', + }, + { + code: ` + function fn() { + 'worklet'; + const { a, b } = obj; + return a + b; + } + `, + filename: 'valid-worklet-no-rest.ts', + }, + ], + invalid: [ + { + code: ` + function fn() { + 'worklet'; + const { delay, ...config } = transition; + return config; + } + `, + filename: 'invalid-worklet-object-rest.ts', + errors: [{ messageId: 'noObjectRestSpreadInWorklet' }], + }, + { + code: ` + const fn = () => { + 'worklet'; + return { ...baseConfig, duration: 200 }; + }; + `, + filename: 'invalid-worklet-object-spread.ts', + errors: [{ messageId: 'noObjectRestSpreadInWorklet' }], + }, + ], + }); +}); diff --git a/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.mjs b/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.mjs new file mode 100644 index 0000000000..ac843c42ea --- /dev/null +++ b/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.mjs @@ -0,0 +1,136 @@ +/** + * ESLint Processor: no-typescript-in-jsx-codeblock + * + * Detects fenced code blocks marked as `jsx` (e.g. ```jsx live) that contain + * TypeScript syntax. These should either use `tsx` as the language tag or have + * the TypeScript syntax removed. + * + * This is implemented as an ESLint processor because MDX files cannot be parsed + * by standard JavaScript/TypeScript parsers. The processor scans the raw MDX text + * for code fence patterns and injects lint messages in postprocess. + */ + +const RULE_ID = 'internal/no-typescript-in-jsx-codeblock'; + +/** + * Regex patterns that reliably indicate TypeScript syntax in JSX code. + * Each pattern is chosen for low false-positive risk in normal JSX code. + */ +const TYPESCRIPT_PATTERNS = [ + // Destructured parameter with type annotation: }: TypeName followed by ), comma, or generic < + // Catches: ({ a, b }: Props), ({ ...rest }: Props) + // Requires a trailing param-context char to avoid matching JSX text like {i + 1}: Lorem + /}\s*:\s*[A-Z]\w+\s*[,)<]/, + + // Non-destructured parameter with type annotation: (param: TypeName) + // Catches: (props: FooProps), (ref: React.Ref) + /\(\s*(?:\.\.\.)?(?:\w+)\s*:\s*[A-Z]\w+/, + + // Type alias declaration: type Name = ... + /(?:^|\n)\s*(?:export\s+)?type\s+[A-Z]\w+\s*(?:<[^>]*>)?\s*=/, + + // Interface declaration: interface Name { ... } + /(?:^|\n)\s*(?:export\s+)?interface\s+[A-Z]\w+/, + + // Variable with type annotation: const name: Type = ... or const name: Type<...> = + /\b(?:const|let|var)\s+\w+\s*:\s*[A-Z]\w+/, + + // Function parameter with primitive type annotation: (x: number), (x: string) + /\(\s*(?:\.\.\.)?(?:\w+)\s*:\s*(?:string|number|boolean|bigint|symbol|object|void|never|any|unknown)\s*[,)]/, + + // Return type annotation before arrow function: ): Type => + /\)\s*:\s*(?:[A-Z]\w+|string|number|boolean|void)\s*=>/, + + // Generic type argument: identifier (e.g. useState(), Map) + // Safe from JSX: self-closing JSX uses /> not >, and opening JSX tags () + // are preceded by whitespace/delimiters, never a word character + /\w<(?:[A-Z]\w+|string|number|boolean|void|never|any|unknown)\s*[,>]/, +]; + +/** + * Checks whether a code string contains TypeScript syntax. + * @param {string} code - The code content of a fenced code block + * @returns {boolean} + */ +export function containsTypeScript(code) { + return TYPESCRIPT_PATTERNS.some((pattern) => pattern.test(code)); +} + +/** + * Finds all ```jsx code blocks in MDX text and returns diagnostic info + * for any that contain TypeScript syntax. + */ +export function findViolations(text) { + const violations = []; + + // Match ```jsx or ```jsx live (with optional modifiers after jsx) + // The 'm' flag makes ^ match line starts + const codeBlockRegex = /^```(jsx)[^\n]*\n([\s\S]*?)^```\s*$/gm; + let match; + + while ((match = codeBlockRegex.exec(text)) !== null) { + const codeContent = match[2]; + + if (containsTypeScript(codeContent)) { + const blockStartOffset = match.index; + const textBefore = text.substring(0, blockStartOffset); + const line = textBefore.split('\n').length; + const langTagStart = blockStartOffset + 3; // length of "```" + + violations.push({ + line, + column: 4, // 1-indexed, after "```" + endLine: line, + endColumn: 7, // end of "jsx" + langTagOffset: langTagStart, + }); + } + } + + return violations; +} + +// Store source text between preprocess and postprocess +const sourceTexts = new Map(); + +/** + * ESLint processor for MDX files that detects TypeScript in JSX code blocks. + */ +export const processor = { + meta: { + name: 'no-typescript-in-jsx-codeblock', + version: '1.0.0', + }, + + preprocess(text, filename) { + sourceTexts.set(filename, text); + // Return a dummy valid JS file so ESLint doesn't choke on MDX syntax + return [{ text: '"";', filename: '0.js' }]; + }, + + postprocess(messages, filename) { + const text = sourceTexts.get(filename); + sourceTexts.delete(filename); + + if (!text) return []; + + const violations = findViolations(text); + + return violations.map((v) => ({ + ruleId: RULE_ID, + severity: 1, + message: + 'Code block is marked as `jsx` but contains TypeScript syntax. Use `tsx` as the language tag instead, or remove the TypeScript annotations.', + line: v.line, + column: v.column, + endLine: v.endLine, + endColumn: v.endColumn, + fix: { + range: [v.langTagOffset, v.langTagOffset + 3], + text: 'tsx', + }, + })); + }, + + supportsAutofix: true, +}; diff --git a/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.test.mjs b/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.test.mjs new file mode 100644 index 0000000000..e0c7090e59 --- /dev/null +++ b/libs/eslint-plugin-internal/src/no-typescript-in-jsx-codeblock/index.test.mjs @@ -0,0 +1,317 @@ +import { containsTypeScript, findViolations, processor } from './index.mjs'; + +describe('containsTypeScript', () => { + describe('detects TypeScript syntax', () => { + it('detects destructured parameter type annotation', () => { + expect(containsTypeScript('({ a, b }: Props) => {}')).toBe(true); + }); + + it('detects destructured param with spread and type annotation', () => { + expect(containsTypeScript('({ ...rest }: SomeType) => {}')).toBe(true); + }); + + it('detects type alias declaration', () => { + expect(containsTypeScript('type MyProps = { name: string };')).toBe(true); + }); + + it('detects exported type alias', () => { + expect(containsTypeScript('export type ButtonProps = { label: string };')).toBe(true); + }); + + it('detects interface declaration', () => { + expect(containsTypeScript('interface FooBar { name: string }')).toBe(true); + }); + + it('detects exported interface', () => { + expect(containsTypeScript('export interface BarBaz {}')).toBe(true); + }); + + it('detects variable type annotation', () => { + expect(containsTypeScript('const handler: EventHandler = () => {}')).toBe(true); + }); + + it('detects let type annotation', () => { + expect(containsTypeScript('let count: Number = 0')).toBe(true); + }); + + it('detects function parameter with primitive type', () => { + expect(containsTypeScript('function foo(x: number) {}')).toBe(true); + }); + + it('detects parameter with string type', () => { + expect(containsTypeScript('(name: string) => name')).toBe(true); + }); + + it('detects parameter with boolean type', () => { + expect(containsTypeScript('(flag: boolean) => flag')).toBe(true); + }); + + it('detects return type annotation before arrow', () => { + expect(containsTypeScript('(x): ReactNode =>
    ')).toBe(true); + }); + + it('detects return type with primitive before arrow', () => { + expect(containsTypeScript('(x): string => x.toString()')).toBe(true); + }); + + it('detects generic type argument on hook call', () => { + expect(containsTypeScript('const ref = useRef(null)')).toBe(true); + }); + + it('detects generic type argument with primitive', () => { + expect(containsTypeScript('const [val, setVal] = useState("")')).toBe(true); + }); + + it('detects generic type argument with multiple params', () => { + expect(containsTypeScript('const map = new Map()')).toBe(true); + }); + }); + + describe('does not flag valid JSX patterns', () => { + it('allows object literals with capitalized values', () => { + expect(containsTypeScript('const x = { color: "red", size: 12 }')).toBe(false); + }); + + it('allows JSX with props', () => { + expect(containsTypeScript('')).toBe(false); + }); + + it('allows ternary expressions', () => { + expect(containsTypeScript('const x = condition ? valueA : valueB')).toBe(false); + }); + + it('allows regular arrow functions', () => { + expect(containsTypeScript('const fn = (a, b) => a + b')).toBe(false); + }); + + it('allows destructured parameters without types', () => { + expect(containsTypeScript('const fn = ({ a, b }) => a + b')).toBe(false); + }); + + it('allows regular function declarations', () => { + expect(containsTypeScript('function handleClick() { console.log("clicked") }')).toBe(false); + }); + + it('allows object methods', () => { + expect(containsTypeScript('const obj = { render() { return
    } }')).toBe(false); + }); + + it('allows useState and hooks', () => { + expect(containsTypeScript('const [state, setState] = useState(false)')).toBe(false); + }); + + it('allows array/object spread', () => { + expect(containsTypeScript('const merged = { ...defaults, ...overrides }')).toBe(false); + }); + + it('allows JSX expressions followed by colon and capitalized text (ModalBody Lorem ipsum)', () => { + const code = `function ScrollableExample() { + return ( + + + {Array.from({ length: 20 }, (_, i) => ( + + Section {i + 1}: Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + ))} + + + ); + }`; + expect(containsTypeScript(code)).toBe(false); + }); + }); + + describe('real-world cases from the codebase', () => { + it('detects props type annotation (DottedAreaProps)', () => { + const code = `const MyGradient = memo((props: DottedAreaProps) => { + return ; + });`; + expect(containsTypeScript(code)).toBe(true); + }); + + it('detects destructured params with type (AxisBounds)', () => { + const code = `stops: ({ min, max }: AxisBounds) => [ + { offset: min, color: negativeColor, opacity: 1 }, + ]`; + expect(containsTypeScript(code)).toBe(true); + }); + + it('detects const with component type annotation', () => { + const code = `const BTCTab: TabComponent = memo( + forwardRef(({ label, ...props }, ref) => { + return ; + }), + );`; + expect(containsTypeScript(code)).toBe(true); + }); + + it('detects type declaration in code block', () => { + const code = `type CompactChartProps = { + data: number[]; + showArea?: boolean; + color?: string; + referenceY: number; + };`; + expect(containsTypeScript(code)).toBe(true); + }); + + it('detects function param with number type', () => { + const code = `function handleGoToPage(pageIndex: number) { + if (carouselRef.current) { + carouselRef.current.goToPage(pageIndex); + } + }`; + expect(containsTypeScript(code)).toBe(true); + }); + + it('does not flag clean JSX code', () => { + const code = `function MyComponent() { + const [count, setCount] = useState(0); + return ( +
    +

    Count: {count}

    + +
    + ); + }`; + expect(containsTypeScript(code)).toBe(false); + }); + }); +}); + +describe('findViolations', () => { + it('returns empty array for MDX with no jsx code blocks', () => { + const mdx = `# Hello + +Some text here. + +\`\`\`tsx live +const MyComponent = (props: FooProps) =>
    ; +\`\`\` +`; + expect(findViolations(mdx)).toEqual([]); + }); + + it('returns empty array for jsx code blocks without TypeScript', () => { + const mdx = `# Hello + +\`\`\`jsx live +function MyComponent() { + return
    Hello
    ; +} +\`\`\` +`; + expect(findViolations(mdx)).toEqual([]); + }); + + it('detects a jsx code block with TypeScript syntax', () => { + const mdx = `# Hello + +\`\`\`jsx live +const MyComponent = memo((props: DottedAreaProps) => { + return
    ; +}); +\`\`\` +`; + const violations = findViolations(mdx); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(3); + }); + + it('detects multiple violations in a single file', () => { + const mdx = `# Charts + +\`\`\`jsx live +const A = memo((props: FooProps) =>
    ); +\`\`\` + +Some text. + +\`\`\`jsx live +type BarProps = { name: string }; +const B = memo(({ name }: BarProps) => {name}); +\`\`\` + +\`\`\`jsx live +function C() { + return
    No types here
    ; +} +\`\`\` +`; + const violations = findViolations(mdx); + expect(violations).toHaveLength(2); + }); + + it('ignores tsx code blocks (correctly tagged)', () => { + const mdx = `\`\`\`tsx live +const MyComponent = (props: FooProps) =>
    ; +\`\`\` +`; + expect(findViolations(mdx)).toEqual([]); + }); + + it('handles jsx code blocks without the live modifier', () => { + const mdx = `\`\`\`jsx +type MyType = { value: number }; +\`\`\` +`; + const violations = findViolations(mdx); + expect(violations).toHaveLength(1); + }); + + it('provides correct line numbers for violations', () => { + const mdx = `line 1 +line 2 +line 3 +\`\`\`jsx live +const x: MyType = 5; +\`\`\` +`; + const violations = findViolations(mdx); + expect(violations).toHaveLength(1); + expect(violations[0].line).toBe(4); + }); +}); + +describe('processor', () => { + it('preprocess returns a dummy JS file', () => { + const result = processor.preprocess('# Hello\n```jsx\ncode\n```', 'test.mdx'); + expect(result).toHaveLength(1); + expect(result[0].filename).toBe('0.js'); + }); + + it('postprocess returns violations for jsx blocks with TypeScript', () => { + const mdx = `# Example +\`\`\`jsx live +const MyGradient = memo((props: DottedAreaProps) => { + return ; +}); +\`\`\` +`; + processor.preprocess(mdx, 'example.mdx'); + const messages = processor.postprocess([[]], 'example.mdx'); + + expect(messages).toHaveLength(1); + expect(messages[0].ruleId).toBe('internal/no-typescript-in-jsx-codeblock'); + expect(messages[0].severity).toBe(1); + expect(messages[0].message).toContain('jsx'); + expect(messages[0].message).toContain('TypeScript'); + expect(messages[0].fix).toBeDefined(); + expect(messages[0].fix.text).toBe('tsx'); + }); + + it('postprocess returns empty array for clean jsx blocks', () => { + const mdx = `\`\`\`jsx live +function Hello() { return
    Hi
    ; } +\`\`\` +`; + processor.preprocess(mdx, 'clean.mdx'); + const messages = processor.postprocess([[]], 'clean.mdx'); + expect(messages).toHaveLength(0); + }); + + it('supports autofix', () => { + expect(processor.supportsAutofix).toBe(true); + }); +}); diff --git a/nx.json b/nx.json index fea49bed67..9997297c6a 100644 --- a/nx.json +++ b/nx.json @@ -98,6 +98,14 @@ ] }, "lint": { + "dependsOn": [ + { + "projects": [ + "eslint-plugin-cds" + ], + "target": "build" + } + ], "inputs": [ "default", "^production" diff --git a/package.json b/package.json index 0c8e05063d..0be66abb35 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ { "name": "Harry Hao @haoruikun" }, + { + "name": "Stephen Vergara @sverg84" + }, { "name": "Siddharth Kulkarni @siddharthkul" }, @@ -55,14 +58,14 @@ "clean-dist-outs": "rm -rf packages/*/dist || true", "reset-nx-daemon": "nx reset", "clean": "yarn clean-nx-dir && yarn clean-packemon-outs && yarn clean-dist-outs && yarn reset-nx-daemon", - "clean-expo": "rm -rf apps/mobile-app/ios && rm -rf apps/mobile-app/.expo", - "clean-expo2": "rm -rf apps/mobile-app/ios && rm -rf apps/mobile-app/.expo", + "clean-expo": "rm -rf apps/mobile-app/ios && rm -rf apps/mobile-app/android && rm -rf apps/mobile-app/.expo", "release": "tsx tools/ci/validators/validateVersioned.ts && yarn nx run codegen:update-packages-generic-bump && tsx ./tools/validateCDSVersions.ts", "generate-tarballs": "node tools/generateTarballs.mjs", "audit-figma-integration": "node scripts/auditFigmaIntegration/index.mjs", "code-connect:publish": "yarn code-connect:publish:web && yarn code-connect:publish:mobile", "code-connect:publish:web": "figma connect publish --config figma.config.web.json --exit-on-unreadable-files --batch-size 50", - "code-connect:publish:mobile": "figma connect publish --config figma.config.mobile.json --exit-on-unreadable-files --batch-size 50" + "code-connect:publish:mobile": "figma connect publish --config figma.config.mobile.json --exit-on-unreadable-files --batch-size 50", + "perf:component-config": "yarn exec jest --config packages/web/jest.config.js --runTestsByPath packages/web/src/perf/component-config/Button.component-config.perf-test.tsx packages/web/src/perf/component-config/ComponentConfigProvider.perf-test.tsx packages/web/src/perf/component-config/ComponentConfigStickerSheet.perf-test.tsx --testMatch='**/*.perf-test.tsx' && yarn exec jest --config packages/mobile/jest.config.js --runTestsByPath packages/mobile/src/perf/component-config/Button.component-config.perf-test.tsx packages/mobile/src/perf/component-config/ComponentConfigProvider.perf-test.tsx packages/mobile/src/perf/component-config/ComponentConfigStickerSheet.perf-test.tsx --testMatch='**/*.perf-test.tsx'" }, "resolutions": { "@testing-library/user-event@^14.0.4": "patch:@testing-library/user-event@npm:14.0.4#.yarn/patches/@testing-library-user-event-npm-14.0.4-109d618170", @@ -73,18 +76,34 @@ "expo-splash-screen": "patch:expo-splash-screen@npm:0.27.5#.yarn/patches/expo-splash-screen-npm-0.27.5-f91e0b41df.patch", "react-native-navigation-bar-color": "patch:react-native-navigation-bar-color@npm:2.0.2#.yarn/patches/react-native-navigation-bar-color-npm-2.0.2-9a2ea3aaf6.patch", "expo-dev-launcher": "patch:expo-dev-launcher@npm:4.0.27#.yarn/patches/expo-dev-launcher-npm-4.0.27-c2ab5dd4a5.patch", - "react-helmet-async": "^2.0.5" + "react-helmet-async": "^2.0.5", + "ajv@^6.0.0": "^6.14.0", + "elliptic": "^6.6.0", + "ip": "^2.0.1", + "minimatch": "^10.2.4", + "glob@7.1.6": "patch:glob@npm:7.1.6#.yarn/patches/glob-npm-7.1.6-minimatch10-symbol.patch", + "koa": "^3.1.2", + "tar": "^7.5.12", + "bn.js": "^5.2.3" }, "resolutionComments": { "@testing-library/user-event@^14.0.4": "Create subpath export for types.", "framer-motion@^10.18.0": "Export missing types", - "@expo/cli": "[Managed by CMR] Necessary to use the proper appId when opening the app via Expo CLI shortcuts `i` or `a`", + "@expo/cli": "[Managed by CMR] Necessary to use the proper appId when opening the app via Expo CLI shortcuts `i` or `a`. Handle tar v7 compatibility (tar v7 sets __esModule:true with no default export).", "react-native": "[Managed by CMR] Fixes bug presenting a modal on iOS.", "react-native-gesture-handler": "[Managed by CMR] Fix performance regression. Can remove once upgrading to version 2.18.0", "expo-splash-screen": "[Managed by CMR] Adds support for transitions and bottom images in the splash screen. We aim to upstream some of those improvements to Expo.", "react-native-navigation-bar-color": "[Managed by CMR] Promisify react-native-navigation-bar-color.", "expo-dev-launcher": "[Managed by CMR] Necessary to install the Expo network inspector for the Android `development` build type. Issue tracked internally by Expo.", - "react-helmet-async": "Working around Nx graph resolution issue" + "react-helmet-async": "Working around Nx graph resolution issue", + "ajv": "Request from Coinbase Security team to fix security vulnerability", + "elliptic": "Request from Coinbase Security team to fix security vulnerability", + "ip": "Request from Coinbase Security team to fix security vulnerability", + "minimatch": "Request from Coinbase Security team to fix security vulnerability", + "glob@7.1.6": "Handle minimatch@10's Symbol(GLOBSTAR) in pattern arrays (used by @expo/config). Avoids 'Cannot convert a Symbol value to a string' when starting Expo.", + "koa": "Request from Coinbase Security team to fix security vulnerability", + "tar": "Request from Coinbase Security team to fix security vulnerability", + "bn.js": "Request from Coinbase Security team to fix security vulnerability" }, "workspaces": [ "actions/*", @@ -107,8 +126,9 @@ "@babel/runtime": "^7.28.2", "@babel/template": "^7.20.7", "@babel/types": "^7.20.7", + "@coinbase/eslint-plugin-cds": "workspace:^", "@coinbase/eslint-plugin-internal": "workspace:^", - "@figma/code-connect": "^1.3.12", + "@figma/code-connect": "^1.3.13", "@graphql-tools/jest-transform": "^2.0.0", "@inquirer/prompts": "^7.5.3", "@joshcena/docusaurus-plugin-utils": "^0.1.3", @@ -155,8 +175,7 @@ "babel-plugin-module-resolver": "^4.1.0", "babel-plugin-replace-ts-export-assignment": "^0.0.2", "chalk": "^4.1.2", - "depcheck": "^1.4.7", - "detox": "^20.14.8", + "depcheck": "patch:depcheck@npm%3A1.4.7#.yarn/patches/depcheck-npm-1.4.7-d4cc813cc3.patch", "diff": "^5.1.0", "dotenv-webpack": "7.0.3", "eslint": "^9.22.0", diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 9b2fcfcc1a..4a9533c5a0 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,306 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Fix incorrect sample data for docs. [[#502](https://github.com/coinbase/cds/pull/502)] + +## 8.66.0 ((4/16/2026, 01:57 PM PST)) + +This is an artificial version bump with no new change. + +## 8.65.0 ((4/16/2026, 10:06 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.5 ((4/16/2026, 06:50 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.4 ((4/10/2026, 01:20 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.3 ((4/8/2026, 05:54 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.2 ((4/8/2026, 11:26 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.1 (4/7/2026 PST) + +#### 🐞 Fixes + +- Adds deprecations to several types for the Tour web/mobile components. [[#592](https://github.com/coinbase/cds/pull/592)] + +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- UseTabs: Added an optional second generic TTab extends TabValue so tabs, activeTab, and onChange can be typed with custom tab row shapes (defaults preserve the old behavior). [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.62.1 (4/1/2026 PST) + +#### 🐞 Fixes + +- Remove usage of Array.prototype.at(). [[#575](https://github.com/coinbase/cds/pull/575)] + +## 8.62.0 ((3/30/2026, 06:52 PM PST)) + +This is an artificial version bump with no new change. + +## 8.61.0 ((3/30/2026, 02:40 PM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Deprecate Card-related types. [[#562](https://github.com/coinbase/cds/pull/562)] + +## 8.60.0 (3/29/2026 PST) + +#### 🚀 Updates + +- Deprecate useProgressSize and replace with getProgressSize. [[#501](https://github.com/coinbase/cds/pull/501)] + +## 8.59.0 ((3/27/2026, 05:43 AM PST)) + +This is an artificial version bump with no new change. + +## 8.58.0 ((3/25/2026, 11:42 AM PST)) + +This is an artificial version bump with no new change. + +## 8.57.1 ((3/24/2026, 01:14 PM PST)) + +This is an artificial version bump with no new change. + +## 8.57.0 ((3/24/2026, 12:46 PM PST)) + +This is an artificial version bump with no new change. + +## 8.56.1 ((3/24/2026, 08:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.56.0 ((3/23/2026, 06:31 AM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + +## 8.55.1 ((3/22/2026, 01:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.55.0 ((3/19/2026, 01:41 PM PST)) + +This is an artificial version bump with no new change. + +## 8.54.0 ((3/18/2026, 02:27 PM PST)) + +This is an artificial version bump with no new change. + +## 8.53.1 ((3/17/2026, 10:58 AM PST)) + +This is an artificial version bump with no new change. + +## 8.53.0 ((3/16/2026, 01:45 PM PST)) + +This is an artificial version bump with no new change. + +## 8.52.2 ((3/11/2026, 10:02 AM PST)) + +This is an artificial version bump with no new change. + +## 8.52.1 ((3/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + +## 8.52.0 (3/10/2026 PST) + +#### 🚀 Updates + +- Deprecated all exports from LottieStatusAnimationProps file in common. [[#388](https://github.com/coinbase/cds/pull/388)] +- Added lottieStatusToAccessibilityLabel constant. [[#388](https://github.com/coinbase/cds/pull/388)] +- Deprecated LottieStatusAnimationType and renamed it to LottieStatus. [[#388](https://github.com/coinbase/cds/pull/388)] + +## 8.51.0 ((3/9/2026, 06:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.50.0 ((3/6/2026, 09:36 AM PST)) + +This is an artificial version bump with no new change. + +## 8.49.2 ((3/6/2026, 09:04 AM PST)) + +This is an artificial version bump with no new change. + +## 8.49.1 ((3/5/2026, 03:13 PM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Improve jsdocs. [[#446](https://github.com/coinbase/cds/pull/446)] + +## 8.49.0 ((2/26/2026, 04:03 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.3 ((2/25/2026, 08:36 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.2 ((2/25/2026, 04:21 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 ((2/24/2026, 10:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.4 ((2/23/2026, 03:04 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.1 ((2/19/2026, 01:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.0 ((2/19/2026, 08:05 AM PST)) + +This is an artificial version bump with no new change. + +## 8.46.1 ((2/12/2026, 01:01 PM PST)) + +This is an artificial version bump with no new change. + +## 8.46.0 ((2/12/2026, 11:34 AM PST)) + +This is an artificial version bump with no new change. + +## 8.45.0 ((2/12/2026, 07:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.44.2 ((2/10/2026, 08:38 AM PST)) + +This is an artificial version bump with no new change. + +## 8.44.1 ((2/10/2026, 12:05 PM PST)) + +This is an artificial version bump with no new change. + +## 8.44.0 (2/9/2026 PST) + +#### 🚀 Updates + +- Add new tray design. [[#349](https://github.com/coinbase/cds/pull/349)] + +## 8.43.2 (2/9/2026 PST) + +#### 🐞 Fixes + +- Allow contenteditable elements to be focusable in Modals. [[#371](https://github.com/coinbase/cds/pull/371)] + +## 8.43.1 ((2/6/2026, 02:15 PM PST)) + +This is an artificial version bump with no new change. + +## 8.43.0 (2/6/2026 PST) + +#### 🚀 Updates + +- Carousel autoplay. [[#361](https://github.com/coinbase/cds/pull/361)] + +## 8.42.0 ((2/4/2026, 01:51 PM PST)) + +This is an artificial version bump with no new change. + +## 8.41.0 ((2/4/2026, 09:22 AM PST)) + +This is an artificial version bump with no new change. + +## 8.40.2 ((2/2/2026, 11:25 AM PST)) + +This is an artificial version bump with no new change. + +## 8.40.1 ((1/30/2026, 04:58 PM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Add descriptive names for generic types. [[#341](https://github.com/coinbase/cds/pull/341)] [DX-5037] + +## 8.40.0 (1/28/2026 PST) + +#### 🚀 Updates + +- Add token manager logo. + +## 8.39.1 ((1/28/2026, 06:48 AM PST)) + +This is an artificial version bump with no new change. + +## 8.39.0 ((1/27/2026, 11:17 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.7 ((1/26/2026, 10:28 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.6 (1/23/2026 PST) + +#### 🐞 Fixes + +- Chore: align version with web package. + +## 8.38.5 ((1/23/2026, 06:35 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.4 ((1/22/2026, 01:55 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.3 ((1/22/2026, 01:42 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.2 ((1/22/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.1 ((1/15/2026, 10:22 AM PST)) + +This is an artificial version bump with no new change. + ## 8.38.0 ((1/14/2026, 01:30 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 13d61e0ec5..dacc9612f1 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.38.0", + "version": "8.66.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/common/src/animation/drawer.ts b/packages/common/src/animation/drawer.ts index ef47458acd..a544dc0e72 100644 --- a/packages/common/src/animation/drawer.ts +++ b/packages/common/src/animation/drawer.ts @@ -1,8 +1,8 @@ /** Upper limit for how much the drawer can be extended in length by panning */ export const MAX_OVER_DRAG = 40; -/** Dragging a Drawer by more than this percentage of the Drawer will dismiss it */ +/** Maximum drag distance (in pixels) required to dismiss. */ export const DISMISSAL_DRAG_THRESHOLD = 150; -/** Quick swipes will dismiss the Tray, especially useful for Trays that are very short */ +/** Velocity threshold (px/ms) for quick swipes to dismiss, regardless of distance dragged. */ export const DISMISSAL_VELOCITY_THRESHOLD = 0.8; /** Minimum panning distance required to capture pan gesture */ export const MIN_PAN_DISTANCE = 2; diff --git a/packages/common/src/carousel/__tests__/useCarouselAutoplay.test.ts b/packages/common/src/carousel/__tests__/useCarouselAutoplay.test.ts new file mode 100644 index 0000000000..2aed327576 --- /dev/null +++ b/packages/common/src/carousel/__tests__/useCarouselAutoplay.test.ts @@ -0,0 +1,459 @@ +import { act, renderHook } from '@testing-library/react-hooks'; + +import type { CarouselAutoplayOptions } from '../useCarouselAutoplay'; +import { useCarouselAutoplay } from '../useCarouselAutoplay'; + +describe('useCarouselAutoplay', () => { + const defaultOptions: CarouselAutoplayOptions = { + enabled: true, + interval: 3000, + }; + + describe('initial state', () => { + it('should return initial state with isPlaying true when enabled', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + const autoplay = result.current; + + expect(autoplay.isPlaying).toBe(true); + expect(autoplay.isStopped).toBe(false); + expect(autoplay.isPaused).toBe(false); + + expect(autoplay).toHaveProperty('start'); + expect(autoplay).toHaveProperty('stop'); + expect(autoplay).toHaveProperty('toggle'); + expect(autoplay).toHaveProperty('reset'); + expect(autoplay).toHaveProperty('getRemainingTime'); + expect(autoplay).toHaveProperty('addCompletionListener'); + }); + + it('should return initial state with isPlaying false when not enabled', () => { + const { result } = renderHook(() => + useCarouselAutoplay({ ...defaultOptions, enabled: false }), + ); + const autoplay = result.current; + + expect(autoplay.isPlaying).toBe(false); + expect(autoplay.isStopped).toBe(false); + }); + }); + + describe('start', () => { + it('should set isPlaying to true when called after stop', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.stop(); + }); + expect(result.current.isPlaying).toBe(false); + expect(result.current.isStopped).toBe(true); + + act(() => { + result.current.start(); + }); + expect(result.current.isPlaying).toBe(true); + expect(result.current.isStopped).toBe(false); + }); + }); + + describe('stop', () => { + it('should set isStopped to true and isPlaying to false', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.stop(); + }); + + expect(result.current.isPlaying).toBe(false); + expect(result.current.isStopped).toBe(true); + }); + + it('should call onStop callback when stopping', () => { + const onStop = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay({ ...defaultOptions, onStop })); + + act(() => { + result.current.stop(); + }); + + expect(onStop).toHaveBeenCalledTimes(1); + }); + }); + + describe('toggle', () => { + it('should toggle from playing to stopped', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + expect(result.current.isPlaying).toBe(true); + + act(() => { + result.current.toggle(); + }); + + expect(result.current.isPlaying).toBe(false); + expect(result.current.isStopped).toBe(true); + }); + + it('should toggle from stopped to playing', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.stop(); + }); + expect(result.current.isStopped).toBe(true); + + act(() => { + result.current.toggle(); + }); + + expect(result.current.isPlaying).toBe(true); + expect(result.current.isStopped).toBe(false); + }); + }); + + describe('reset', () => { + it('should restart the timer when playing', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + // Subscribe to completion + act(() => { + result.current.addCompletionListener(listener); + }); + + // Advance halfway + act(() => { + jest.advanceTimersByTime(1500); + }); + + // Reset should restart the timer + act(() => { + result.current.reset(); + }); + + // After another 1500ms, listener should NOT have been called yet + // because we reset the timer + act(() => { + jest.advanceTimersByTime(1500); + }); + + expect(listener).not.toHaveBeenCalled(); + + // After another 1500ms (total 3000ms from reset), listener should be called + act(() => { + jest.advanceTimersByTime(1500); + }); + + expect(listener).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + }); + + describe('addCompletionListener', () => { + it('should call listener after interval elapses', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.addCompletionListener(listener); + }); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(listener).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('should call listener repeatedly when reset is called after each advance', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.addCompletionListener(listener); + }); + + // First advance + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(listener).toHaveBeenCalledTimes(1); + + // Reset to restart timer (simulates what goToPage does) + act(() => { + result.current.reset(); + }); + + // Second advance + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(listener).toHaveBeenCalledTimes(2); + + // Reset again + act(() => { + result.current.reset(); + }); + + // Third advance + act(() => { + jest.advanceTimersByTime(3000); + }); + expect(listener).toHaveBeenCalledTimes(3); + + jest.useRealTimers(); + }); + + it('should not call listener when stopped', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.addCompletionListener(listener); + }); + + act(() => { + result.current.stop(); + }); + + act(() => { + jest.advanceTimersByTime(10000); + }); + + expect(listener).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should support multiple listeners', () => { + jest.useFakeTimers(); + const listener1 = jest.fn(); + const listener2 = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.addCompletionListener(listener1); + result.current.addCompletionListener(listener2); + }); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(listener1).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('should unsubscribe when calling returned function', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + let unsubscribe: () => void; + act(() => { + unsubscribe = result.current.addCompletionListener(listener); + }); + + // Unsubscribe before timer fires + act(() => { + unsubscribe(); + }); + + act(() => { + jest.advanceTimersByTime(3000); + }); + + expect(listener).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + }); + + describe('timing info', () => { + it('should provide getRemainingTime function', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + expect(typeof result.current.getRemainingTime).toBe('function'); + }); + + it('should return decreasing remaining time as timer progresses', () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + const initialRemaining = result.current.getRemainingTime(); + expect(initialRemaining).toBeLessThanOrEqual(3000); + + act(() => { + jest.advanceTimersByTime(1000); + }); + + const remainingAfter1s = result.current.getRemainingTime(); + expect(remainingAfter1s).toBeLessThan(initialRemaining); + + jest.useRealTimers(); + }); + }); + + describe('enabled prop changes', () => { + it('should start autoplay when enabled changes from false to true', () => { + const onStart = jest.fn(); + const { result, rerender } = renderHook((props) => useCarouselAutoplay(props), { + initialProps: { ...defaultOptions, enabled: false, onStart }, + }); + + expect(onStart).not.toHaveBeenCalled(); + expect(result.current.isPlaying).toBe(false); + + rerender({ ...defaultOptions, enabled: true, onStart }); + + expect(result.current.isPlaying).toBe(true); + }); + + it('should not auto-stop when enabled changes to false (user must call stop)', () => { + const { result, rerender } = renderHook((props) => useCarouselAutoplay(props), { + initialProps: defaultOptions, + }); + + expect(result.current.isPlaying).toBe(true); + + rerender({ ...defaultOptions, enabled: false }); + + expect(result.current.isPlaying).toBe(false); + expect(result.current.isStopped).toBe(false); + }); + }); + + describe('state consistency', () => { + it('should maintain referential stability for API methods', () => { + const { result, rerender } = renderHook(() => useCarouselAutoplay(defaultOptions)); + const initialAutoplay = result.current; + + rerender(); + const rerenderAutoplay = result.current; + + expect(initialAutoplay.start).toBe(rerenderAutoplay.start); + expect(initialAutoplay.stop).toBe(rerenderAutoplay.stop); + expect(initialAutoplay.toggle).toBe(rerenderAutoplay.toggle); + }); + + it('should return new object when state changes', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + const initialAutoplay = result.current; + + act(() => { + result.current.stop(); + }); + + const newAutoplay = result.current; + expect(initialAutoplay).not.toBe(newAutoplay); + }); + }); + + describe('edge cases', () => { + it('should handle rapid start/stop calls', () => { + const onStart = jest.fn(); + const onStop = jest.fn(); + const { result } = renderHook(() => + useCarouselAutoplay({ ...defaultOptions, onStart, onStop }), + ); + + act(() => { + result.current.stop(); + result.current.start(); + result.current.stop(); + result.current.start(); + }); + + expect(result.current.isPlaying).toBe(true); + }); + + it('should handle zero interval gracefully', () => { + expect(() => { + renderHook(() => useCarouselAutoplay({ ...defaultOptions, interval: 0 })); + }).not.toThrow(); + }); + + it('should cleanup on unmount', () => { + const { unmount } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + jest.useFakeTimers(); + + unmount(); + + expect(() => { + act(() => { + jest.advanceTimersByTime(10000); + }); + }).not.toThrow(); + + jest.useRealTimers(); + }); + }); + + describe('pause and resume', () => { + it('should pause and resume correctly', () => { + jest.useFakeTimers(); + const listener = jest.fn(); + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.addCompletionListener(listener); + }); + + // Advance halfway + act(() => { + jest.advanceTimersByTime(1500); + }); + + // Pause + act(() => { + result.current.pause(); + }); + expect(result.current.isPaused).toBe(true); + expect(result.current.isPlaying).toBe(false); + + // Time passes while paused - listener should not be called + act(() => { + jest.advanceTimersByTime(5000); + }); + expect(listener).not.toHaveBeenCalled(); + + // Resume + act(() => { + result.current.resume(); + }); + expect(result.current.isPaused).toBe(false); + expect(result.current.isPlaying).toBe(true); + + // After remaining time, listener should be called + act(() => { + jest.advanceTimersByTime(1500); + }); + expect(listener).toHaveBeenCalledTimes(1); + + jest.useRealTimers(); + }); + + it('should not resume if stopped', () => { + const { result } = renderHook(() => useCarouselAutoplay(defaultOptions)); + + act(() => { + result.current.stop(); + }); + + act(() => { + result.current.resume(); + }); + + expect(result.current.isPlaying).toBe(false); + expect(result.current.isStopped).toBe(true); + }); + }); +}); diff --git a/packages/common/src/carousel/index.ts b/packages/common/src/carousel/index.ts new file mode 100644 index 0000000000..f953dda5d1 --- /dev/null +++ b/packages/common/src/carousel/index.ts @@ -0,0 +1 @@ +export * from './useCarouselAutoplay'; diff --git a/packages/common/src/carousel/useCarouselAutoplay.ts b/packages/common/src/carousel/useCarouselAutoplay.ts new file mode 100644 index 0000000000..1eabcf814d --- /dev/null +++ b/packages/common/src/carousel/useCarouselAutoplay.ts @@ -0,0 +1,255 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useTimer } from '../hooks/useTimer'; + +export type CarouselAutoplayOptions = { + /** + * Whether autoplay is enabled. + */ + enabled: boolean; + /** + * The interval in milliseconds between auto-advances. + */ + interval: number; + /** + * Callback fired when autoplay starts. + */ + onStart?: () => void; + /** + * Callback fired when autoplay stops. + */ + onStop?: () => void; +}; + +export type CarouselAutoplayState = { + /** + * Whether autoplay is actively running (enabled AND not stopped AND not paused). + */ + isPlaying: boolean; + /** + * Whether autoplay has been stopped by the user. + */ + isStopped: boolean; + /** + * Whether autoplay is temporarily paused due to user interaction (hover/touch). + */ + isPaused: boolean; +}; + +export type CarouselAutoplayApi = { + /** + * Start autoplay. Resumes from paused progress if available. + */ + start: () => void; + /** + * Stop autoplay. Preserves current progress for resuming later. + */ + stop: () => void; + /** + * Toggle autoplay on/off. + */ + toggle: () => void; + /** + * Reset the autoplay timer (e.g., after manual navigation). + */ + reset: () => void; + /** + * Temporarily pause autoplay (e.g., on hover/touch). Does not change isStopped state. + * Progress is preserved and will resume from where it left off. + */ + pause: () => void; + /** + * Resume autoplay after interaction pause. Only resumes if not user-stopped. + */ + resume: () => void; + /** + * Get the current remaining time. Useful for calculating progress in platform-native animations. + */ + getRemainingTime: () => number; + /** + * Add a listener to be called when the autoplay timer completes. + * Returns an unsubscribe function. + */ + addCompletionListener: (callback: () => void) => () => void; +}; + +/** + * Combined state and API returned by useCarouselAutoplay. + */ +export type CarouselAutoplay = CarouselAutoplayState & CarouselAutoplayApi; + +/** + * A hook for managing carousel autoplay state and timing. + * Provides controls for starting, stopping, and resetting autoplay. + */ +export const useCarouselAutoplay = ({ + enabled, + interval, + onStart, + onStop, +}: CarouselAutoplayOptions): CarouselAutoplay => { + const timer = useTimer(); + const [isStopped, setIsStopped] = useState(false); + const [isPaused, setIsPaused] = useState(false); + + // Use refs for synchronous checks to avoid stale closure issues + const isPlayingRef = useRef(false); + const isPausedRef = useRef(false); + const isStoppedRef = useRef(false); + + // Listeners for timer completion + const listenersRef = useRef void>>(new Set()); + + const notifyListeners = useCallback(() => { + // Snapshot listeners to avoid issues when Set is modified during iteration + const listeners = [...listenersRef.current]; + listeners.forEach((listener) => listener()); + }, []); + + const addCompletionListener = useCallback((callback: () => void) => { + listenersRef.current.add(callback); + return () => { + listenersRef.current.delete(callback); + }; + }, []); + + const isPlaying = enabled && !isStopped && !isPaused; + + const getRemainingTime = useCallback(() => { + return timer.getRemainingTime(); + }, [timer]); + + const startAutoplay = useCallback( + (fromPausedProgress: boolean) => { + if (!enabled || isStoppedRef.current || isPausedRef.current) return; + + const advance = () => { + if (!isPlayingRef.current) return; + notifyListeners(); + }; + + if (fromPausedProgress) { + timer.resume(); + } else { + timer.start(advance, interval); + } + + if (!isPlayingRef.current) { + isPlayingRef.current = true; + onStart?.(); + } + }, + [enabled, interval, timer, onStart, notifyListeners], + ); + + const start = useCallback(() => { + isStoppedRef.current = false; + setIsStopped(false); + // Start timer synchronously if not paused + if (!isPausedRef.current && enabled) { + startAutoplay(false); + } + }, [enabled, startAutoplay]); + + const stop = useCallback(() => { + timer.pause(); + isStoppedRef.current = true; + setIsStopped(true); + if (isPlayingRef.current) { + isPlayingRef.current = false; + onStop?.(); + } + }, [timer, onStop]); + + const toggle = useCallback(() => { + if (isStoppedRef.current) { + start(); + } else { + stop(); + } + }, [start, stop]); + + const reset = useCallback(() => { + timer.reset(); + + // Start a fresh timer with the full interval + const advance = () => { + if (!isPlayingRef.current) return; + notifyListeners(); + }; + timer.start(advance, interval); + + // If paused, immediately pause the timer so getRemainingTime() returns the full interval + if (isPausedRef.current) { + timer.pause(); + } + }, [timer, interval, notifyListeners]); + + const pause = useCallback(() => { + if (!isPlayingRef.current) return; + timer.pause(); + isPausedRef.current = true; + setIsPaused(true); + }, [timer]); + + const resume = useCallback(() => { + if (isStoppedRef.current) return; + // Update ref synchronously BEFORE starting timer + isPausedRef.current = false; + setIsPaused(false); + // Start timer synchronously so getRemainingTime() returns correct value + if (enabled) { + const hasRemainingTime = timer.getRemainingTime() > 0; + startAutoplay(hasRemainingTime); + } + }, [enabled, timer, startAutoplay]); + + // Handle initial mount and enabled changes + // This runs on mount when enabled=true to start autoplay initially + useEffect(() => { + if (enabled && !isStoppedRef.current && !isPausedRef.current) { + // Only start if not already playing (avoid double-start) + if (!isPlayingRef.current) { + startAutoplay(false); + } + } + // Keep isPlayingRef in sync with derived state + isPlayingRef.current = isPlaying; + }, [enabled, isPlaying, startAutoplay]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + timer.clear(); + }; + }, [timer]); + + return useMemo( + () => ({ + isPlaying, + isStopped, + isPaused, + start, + stop, + toggle, + reset, + pause, + resume, + getRemainingTime, + addCompletionListener, + }), + [ + isPlaying, + isStopped, + isPaused, + start, + stop, + toggle, + reset, + pause, + resume, + getRemainingTime, + addCompletionListener, + ], + ); +}; diff --git a/packages/common/src/core/theme.ts b/packages/common/src/core/theme.ts index c332f87f05..03dcab5b72 100644 --- a/packages/common/src/core/theme.ts +++ b/packages/common/src/core/theme.ts @@ -5,7 +5,7 @@ /* eslint-disable no-restricted-syntax, @typescript-eslint/no-empty-object-type */ /** - * This utility type makes the final intellisense into human-redable literal values. + * This utility type makes the final intellisense into human-readable literal values. */ type Prettify = { [K in keyof T]: T[K]; diff --git a/packages/common/src/hooks/useGroupToggler.ts b/packages/common/src/hooks/useGroupToggler.ts index e35717cb11..61b91f4ae9 100644 --- a/packages/common/src/hooks/useGroupToggler.ts +++ b/packages/common/src/hooks/useGroupToggler.ts @@ -23,7 +23,10 @@ export type GroupToggleState = { ] */ -/** @deprecated Do not use this. */ +/** + * @deprecated Do not use this. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ export const useGroupToggler = ( values: T[], initialState?: T[], diff --git a/packages/common/src/hooks/useSubBrandLogo.ts b/packages/common/src/hooks/useSubBrandLogo.ts index 07c0373f0f..4cecfc34de 100644 --- a/packages/common/src/hooks/useSubBrandLogo.ts +++ b/packages/common/src/hooks/useSubBrandLogo.ts @@ -28,6 +28,7 @@ type SubBrandLogoDataByType = { exchange: SubBrandLogoData; one: SubBrandLogoData; business: SubBrandLogoData; + tokenManager: SubBrandLogoData; }; type SubBrandLogoMarkData = SubBrandLogoDataByType & { base: SubBrandLogoData }; @@ -183,6 +184,13 @@ const subBrandLogoData: { wordmark: SubBrandLogoWordmarkData; mark: SubBrandLogo typePath: 'M203.933 19C200.618 19 198.599 17.2667 198.542 14.8476H200.58C200.656 16.4667 201.913 17.3619 203.952 17.3619C205.952 17.3619 207.152 16.4095 207.152 15.0381C207.152 13.7619 206.275 13.2667 204.656 13L202.79 12.7143C200.504 12.3714 198.866 11.3048 198.866 8.94286C198.866 6.77143 200.694 5 203.933 5C207.247 5 208.923 6.80952 208.999 8.86667H206.961C206.885 7.57143 205.913 6.6381 203.913 6.6381C201.894 6.6381 200.904 7.60952 200.904 8.80952C200.904 10.0857 201.856 10.6 203.342 10.8476L205.19 11.1333C207.533 11.4952 209.209 12.5048 209.209 14.9048C209.209 17.3619 207.094 19 203.933 19ZM191.786 19C188.472 19 186.453 17.2667 186.396 14.8476H188.434C188.51 16.4667 189.767 17.3619 191.805 17.3619C193.805 17.3619 195.005 16.4095 195.005 15.0381C195.005 13.7619 194.129 13.2667 192.51 13L190.643 12.7143C188.357 12.3714 186.719 11.3048 186.719 8.94286C186.719 6.77143 188.548 5 191.786 5C195.1 5 196.776 6.80952 196.853 8.86667H194.815C194.738 7.57143 193.767 6.6381 191.767 6.6381C189.748 6.6381 188.757 7.60952 188.757 8.80952C188.757 10.0857 189.71 10.6 191.196 10.8476L193.043 11.1333C195.386 11.4952 197.062 12.5048 197.062 14.9048C197.062 17.3619 194.948 19 191.786 19ZM175.501 5.30469H184.701V7.05707H177.52V10.9428H184.129V12.6571H177.52V16.9618H184.701V18.7142H175.501V5.30469ZM161.178 18.7142V5.30469H163.597L170.111 15.819H170.13V5.30469H172.073V18.7142H169.768L163.159 7.95231H163.14V18.7142H161.178ZM155.728 5.30469H157.747V18.7142H155.728V5.30469ZM147.943 19C144.629 19 142.61 17.2667 142.552 14.8476H144.591C144.667 16.4667 145.924 17.3619 147.962 17.3619C149.962 17.3619 151.162 16.4095 151.162 15.0381C151.162 13.7619 150.286 13.2667 148.667 13L146.8 12.7143C144.514 12.3714 142.876 11.3048 142.876 8.94286C142.876 6.77143 144.705 5 147.943 5C151.257 5 152.933 6.80952 153.01 8.86667H150.972C150.895 7.57143 149.924 6.6381 147.924 6.6381C145.905 6.6381 144.914 7.60952 144.914 8.80952C144.914 10.0857 145.867 10.6 147.352 10.8476L149.2 11.1333C151.543 11.4952 153.219 12.5048 153.219 14.9048C153.219 17.3619 151.105 19 147.943 19ZM135.106 18.9999C131.754 18.9999 129.849 16.9047 129.849 14.0285V5.30469H131.868V13.9714C131.868 15.9904 133.049 17.2475 135.106 17.2475C137.163 17.2475 138.325 15.9904 138.325 13.9714V5.30469H140.344V14.0285C140.344 16.9047 138.439 18.9999 135.106 18.9999ZM117 18.7142V5.30469H123.019C125.362 5.30469 127.019 6.65707 127.019 8.7904C127.019 10.2761 126.2 11.2856 124.867 11.6856V11.7047C126.41 12.1047 127.419 13.2094 127.419 14.9428C127.419 17.2856 125.667 18.7142 123.229 18.7142H117ZM125 9.07612V8.88564C125 7.74278 124.181 6.99993 122.829 6.99993H119V10.9809H122.829C124.181 10.9809 125 10.238 125 9.07612ZM125.4 14.9047V14.7142C125.4 13.3999 124.486 12.5999 122.962 12.5999H119V16.9999H122.981C124.524 16.9999 125.4 16.1618 125.4 14.9047Z', }, + tokenManager: { + viewBox: '0 0 278 19', + logoPath: + 'M7.47223 8.13478C9.11007 8.13478 10.4088 9.12992 10.8995 10.6067H14.1969C13.598 7.44415 10.95 5.30159 7.49383 5.30159C3.57592 5.30513 0.509399 8.23747 0.509399 12.1614C0.509399 16.0854 3.49655 18.9965 7.49746 18.9965C10.8742 18.9965 13.5764 16.8539 14.1716 13.6666H10.8995C10.4305 15.1469 9.13173 16.1668 7.49746 16.1668C5.23905 16.1668 3.65168 14.4598 3.65168 12.165C3.65168 9.84175 5.21019 8.13478 7.47223 8.13478ZM59.8375 5.30513C58.0988 5.30513 56.6409 6.02049 55.6019 7.21751V0H52.4851V18.7415H55.5514V17.0062C56.5909 18.2563 58.0696 18.9965 59.8375 18.9965C63.5789 18.9965 66.4106 16.0889 66.4106 12.1614C66.4106 8.23391 63.5245 5.30513 59.8375 5.30513ZM59.3686 16.1668C57.1357 16.1668 55.4975 14.4598 55.4975 12.165C55.4975 9.87008 57.1604 8.13478 59.3939 8.13478C61.6522 8.13478 63.2398 9.84175 63.2398 12.165C63.2398 14.4563 61.6016 16.1668 59.3686 16.1668ZM33.7072 0.127493C32.5635 0.127493 31.7085 0.94203 31.7085 2.06468C31.7085 3.18733 32.5672 4.00187 33.7072 4.00187C34.8472 4.00187 35.7058 3.18733 35.7058 2.06468C35.7058 0.94203 34.8472 0.127493 33.7072 0.127493ZM45.0317 5.30513C43.0041 5.30513 41.6801 6.11967 40.9009 7.2671V5.56013H37.8091V18.7415H40.9261V11.5771C40.9261 9.56201 42.2249 8.13478 44.1478 8.13478C45.9408 8.13478 47.0556 9.3849 47.0556 11.1946V18.7415H50.1726V10.9644C50.1726 7.64959 48.4337 5.30513 45.0317 5.30513ZM30.2005 8.28706H32.1487V18.7415H35.2657V5.56013H30.2005V8.28706ZM89.4241 10.8865L87.1367 10.5536C86.0472 10.4013 85.2682 10.0436 85.2682 9.20077C85.2682 8.2835 86.2819 7.82314 87.6562 7.82314C89.1641 7.82314 90.1239 8.46056 90.3333 9.50534H93.3458C93.0066 6.85277 90.9287 5.29805 87.7359 5.29805C84.4381 5.29805 82.2557 6.95546 82.2557 9.2999C82.2557 11.5452 83.6845 12.8449 86.5666 13.2522L88.854 13.5851C89.9722 13.7374 90.5928 14.173 90.5928 14.9875C90.5928 16.0323 89.5033 16.4679 87.9954 16.4679C86.1522 16.4679 85.1126 15.7277 84.9576 14.6051H81.8913C82.176 17.1797 84.2292 18.9894 87.9701 18.9894C91.3723 18.9894 93.6344 17.4595 93.6344 14.8317C93.6306 12.4943 91.9929 11.269 89.4241 10.8865ZM22.3033 5.30513C18.3818 5.30513 15.3153 8.23747 15.3153 12.165C15.3153 16.0924 18.3025 19 22.3033 19C26.3043 19 29.3419 16.0429 29.3419 12.1402C29.3419 8.26224 26.3548 5.30513 22.3033 5.30513ZM22.3286 16.1668C20.0954 16.1668 18.4576 14.4598 18.4576 12.165C18.4576 9.84531 20.0666 8.13478 22.3033 8.13478C24.5617 8.13478 26.1996 9.87008 26.1996 12.165C26.1996 14.4598 24.5617 16.1668 22.3286 16.1668ZM79.4489 10.072C79.4489 7.21751 77.6809 5.30513 73.9434 5.30513C70.4114 5.30513 68.4385 7.06522 68.0487 9.76739H71.1404C71.2954 8.72266 72.1288 7.85498 73.8928 7.85498C75.4766 7.85498 76.2561 8.54204 76.2561 9.3849C76.2561 10.4828 74.8273 10.7625 73.0594 10.9396C70.6714 11.1946 67.7096 12.0091 67.7096 15.069C67.7096 17.4418 69.5027 18.9717 72.3596 18.9717C74.5932 18.9717 75.996 18.0545 76.6964 16.5989C76.8008 17.8986 77.7859 18.7415 79.1641 18.7415H80.982V16.0145H79.4489V10.072ZM76.3825 13.3868C76.3825 15.1469 74.824 16.4466 72.9264 16.4466C71.7571 16.4466 70.7688 15.9614 70.7688 14.9415C70.7688 13.6418 72.3525 13.2841 73.8065 13.1318C75.21 13.0043 75.9889 12.6997 76.3787 12.1118V13.3868H76.3825ZM101.918 5.30513C97.9167 5.30513 94.9834 8.26224 94.9834 12.165C94.9834 16.2696 98.1256 19 101.971 19C105.219 19 107.766 17.1124 108.415 14.4351H105.168C104.699 15.6073 103.559 16.2696 102.026 16.2696C100.027 16.2696 98.5192 15.0442 98.1801 12.9051H108.491V11.7329C108.487 7.95416 105.684 5.30513 101.918 5.30513ZM98.3565 10.7094C98.8507 8.87494 100.254 7.98249 101.863 7.98249C103.631 7.98249 104.98 8.97763 105.291 10.7094H98.3565Z', + typePath: + 'M266.984 18.7351V5.40771H272.664C275.503 5.40771 277.074 6.96005 277.074 9.28855C277.074 11.617 275.522 13.0937 272.739 13.0937H271.263L277.642 18.7351H275.049L268.802 13.0937V18.7351H266.984ZM275.238 9.43999V9.19389C275.238 7.81194 274.367 6.94112 272.607 6.94112H268.802V11.6928H272.607C274.367 11.6928 275.238 10.803 275.238 9.43999Z M255.171 5.40771H264.22V6.97898H256.988V11.087H263.652V12.6204H256.988V17.1638H264.22V18.7351H255.171V5.40771Z M250.786 18.7351V16.0469H250.767C250.332 17.7317 248.855 19.0001 246.565 19.0001C242.646 19.0001 240.658 15.8387 240.658 12.0714C240.658 8.28524 242.779 5.12378 246.773 5.12378C249.726 5.12378 251.657 6.67611 252.187 9.08033H250.218C249.897 7.73624 248.799 6.69504 246.811 6.69504C244.444 6.69504 242.495 8.47455 242.495 11.6549V12.4879C242.495 15.6872 244.407 17.4667 246.754 17.4667C249.348 17.4667 250.521 15.479 250.521 13.3209H246.47V11.8442H252.282V18.7351H250.786Z M238.37 18.7351L237.102 15.4032H231.025L229.794 18.7351H227.901L233.05 5.40771H235.095L240.301 18.7351H238.37ZM234.016 7.31973L231.593 13.8509H236.515L234.054 7.31973H234.016Z M215.368 18.7351V5.40771H217.564L224.266 16.1983H224.284V5.40771H226.026V18.7351H223.925L217.166 7.73621H217.147V18.7351H215.368Z M211.564 18.7351L210.295 15.4032H204.218L202.988 18.7351H201.095L206.244 5.40771H208.288L213.494 18.7351H211.564ZM207.209 7.31973L204.786 13.8509H209.708L207.247 7.31973H207.209Z M185.752 18.7351V5.40771H188.402L192.51 16.1226H192.529L196.58 5.40771H199.23V18.7351H197.47V7.39546H197.451L193.191 18.7351H191.772L187.474 7.45225H187.455V18.7351H185.752Z M167.338 18.7351V5.40771H169.534L176.236 16.1983H176.255V5.40771H177.996V18.7351H175.895L169.137 7.73621H169.118V18.7351H167.338Z M155.525 5.40771H164.574V6.97898H157.342V11.087H164.006V12.6204H157.342V17.1638H164.574V18.7351H155.525V5.40771Z M145.066 5.40771V18.7351H143.268V5.40771H145.066ZM151.408 18.7351L145.085 11.9199L151.143 5.40771H153.396L147.3 11.8442L153.756 18.7351H151.408Z M134.5 19.0001C130.449 19.0001 128.404 15.8576 128.404 12.0714C128.404 8.30417 130.449 5.12378 134.5 5.12378C138.532 5.12378 140.577 8.30417 140.577 12.0714C140.577 15.8576 138.532 19.0001 134.5 19.0001ZM134.5 17.4289C136.828 17.4289 138.74 15.6494 138.74 12.469V11.6549C138.74 8.47455 136.828 6.69504 134.5 6.69504C132.152 6.69504 130.24 8.47455 130.24 11.6549V12.469C130.24 15.6494 132.152 17.4289 134.5 17.4289Z M121.468 6.97898H117V5.40771H127.734V6.97898H123.266V18.7351H121.468V6.97898Z', + }, }, mark: { analytics: { @@ -311,6 +319,13 @@ const subBrandLogoData: { wordmark: SubBrandLogoWordmarkData; mark: SubBrandLogo typePath: 'M148.561 24.4999C144.536 24.4999 142.084 22.3952 142.015 19.4578H144.49C144.582 21.4238 146.109 22.5108 148.584 22.5108C151.012 22.5108 152.469 21.3544 152.469 19.6891C152.469 18.1394 151.406 17.5381 149.44 17.2143L147.173 16.8673C144.397 16.451 142.408 15.1558 142.408 12.2877C142.408 9.65101 144.629 7.5 148.561 7.5C152.585 7.5 154.62 9.69727 154.713 12.1952H152.238C152.146 10.6224 150.966 9.48911 148.538 9.48911C146.086 9.48911 144.883 10.6687 144.883 12.1258C144.883 13.6755 146.04 14.3 147.844 14.6007L150.087 14.9476C152.932 15.3871 154.967 16.6129 154.967 19.5272C154.967 22.5108 152.4 24.4999 148.561 24.4999ZM133.811 24.4999C129.787 24.4999 127.335 22.3952 127.266 19.4578H129.741C129.833 21.4238 131.36 22.5108 133.834 22.5108C136.263 22.5108 137.72 21.3544 137.72 19.6891C137.72 18.1394 136.656 17.5381 134.69 17.2143L132.424 16.8673C129.648 16.451 127.659 15.1558 127.659 12.2877C127.659 9.65101 129.879 7.5 133.811 7.5C137.836 7.5 139.871 9.69727 139.964 12.1952H137.489C137.396 10.6224 136.217 9.48911 133.788 9.48911C131.336 9.48911 130.134 10.6687 130.134 12.1258C130.134 13.6755 131.29 14.3 133.094 14.6007L135.338 14.9476C138.183 15.3871 140.218 16.6129 140.218 19.5272C140.218 22.5108 137.651 24.4999 133.811 24.4999M114.036 7.87012H125.208V9.998H116.488V14.7164H124.514V16.798H116.488V22.0252H125.208V24.1531H114.036V7.87012ZM96.6443 24.1531V7.87012H99.5817L107.492 20.6374H107.515V7.87012H109.874V24.1531H107.076L99.0498 11.0851H99.0266V24.1531H96.6443ZM90.0263 7.87012H92.478V24.1531H90.0263V7.87012ZM80.5735 24.4999C76.549 24.4999 74.0973 22.3952 74.028 19.4578H76.5028C76.5953 21.4238 78.1218 22.5108 80.5966 22.5108C83.0252 22.5108 84.4824 21.3544 84.4824 19.6891C84.4824 18.1394 83.4184 17.5381 81.4524 17.2143L79.1858 16.8673C76.4103 16.451 74.4212 15.1558 74.4212 12.2877C74.4212 9.65101 76.6416 7.5 80.5735 7.5C84.598 7.5 86.6334 9.69727 86.7259 12.1952H84.2511C84.1585 10.6224 82.979 9.48911 80.5504 9.48911C78.0987 9.48911 76.896 10.6687 76.896 12.1258C76.896 13.6755 78.0524 14.3 79.8565 14.6007L82.1 14.9476C84.9449 15.3871 86.9803 16.6129 86.9803 19.5272C86.9803 22.5108 84.413 24.4999 80.5735 24.4999ZM64.9859 24.5C60.9152 24.5 58.6022 21.9558 58.6022 18.4633V7.87012H61.0539V18.3939C61.0539 20.8456 62.488 22.3721 64.9859 22.3721C67.4839 22.3721 68.8947 20.8456 68.8947 18.3939V7.87012H71.3464V18.4633C71.3464 21.9558 69.0335 24.5 64.9859 24.5ZM43 24.1531V7.87012H50.3088C53.1537 7.87012 55.1659 9.51229 55.1659 12.1028C55.1659 13.9068 54.1714 15.1327 52.5523 15.6184V15.6415C54.4258 16.1272 55.6516 17.4687 55.6516 19.5735C55.6516 22.4184 53.5238 24.1531 50.5632 24.1531H43ZM52.7142 12.4497V12.2184C52.7142 10.8307 51.7197 9.92862 50.0775 9.92862H45.4285V14.7626H50.0775C51.7197 14.7626 52.7142 13.8606 52.7142 12.4497ZM53.2 19.5272V19.2959C53.2 17.7 52.0898 16.7286 50.2394 16.7286H45.4285V22.0714H50.2625C52.136 22.0714 53.2 21.0538 53.2 19.5272Z', }, + tokenManager: { + viewBox: '0 0 230 32', + logoPath: + 'M16.0301 24C11.6018 24 8.01503 20.42 8.01503 16C8.01503 11.58 11.6018 8 16.0301 8C19.9975 8 23.2903 10.8867 23.9249 14.6667H32C31.3187 6.45333 24.4325 0 16.0301 0C7.18013 0 0 7.16667 0 16C0 24.8333 7.18013 32 16.0301 32C24.4325 32 31.3187 25.5467 32 17.3333H23.9249C23.2903 21.1133 19.9975 24 16.0301 24Z', + typePath: + 'M217.3 23.7551V8.26709H223.9C227.2 8.26709 229.026 10.0711 229.026 12.7771C229.026 15.4831 227.222 17.1991 223.988 17.1991H222.272L229.686 23.7551H226.672L219.412 17.1991V23.7551H217.3ZM226.892 12.9531V12.6671C226.892 11.0611 225.88 10.0491 223.834 10.0491H219.412V15.5711H223.834C225.88 15.5711 226.892 14.5371 226.892 12.9531ZM203.571 8.26709H214.087V10.0931H205.683V14.8671H213.427V16.6491H205.683V21.9291H214.087V23.7551H203.571V8.26709Z M198.476 23.755V20.631H198.454C197.948 22.589 196.232 24.063 193.57 24.063C189.016 24.063 186.706 20.389 186.706 16.011C186.706 11.611 189.17 7.93701 193.812 7.93701C197.244 7.93701 199.488 9.74101 200.104 12.535H197.816C197.442 10.973 196.166 9.76301 193.856 9.76301C191.106 9.76301 188.84 11.831 188.84 15.527V16.495C188.84 20.213 191.062 22.281 193.79 22.281C196.804 22.281 198.168 19.971 198.168 17.463H193.46V15.747H200.214V23.755H198.476Z M184.047 23.7551L182.573 19.8831H175.511L174.081 23.7551H171.881L177.865 8.26709H180.241L186.291 23.7551H184.047ZM178.987 10.4891L176.171 18.0791H181.891L179.031 10.4891H178.987Z M157.316 23.7551V8.26709H159.868L167.656 20.8071H167.678V8.26709H169.702V23.7551H167.26L159.406 10.9731H159.384V23.7551H157.316Z M152.894 23.7551L151.42 19.8831H144.358L142.928 23.7551H140.728L146.712 8.26709H149.088L155.138 23.7551H152.894ZM147.834 10.4891L145.018 18.0791H150.738L147.878 10.4891H147.834Z M122.898 23.7551V8.26709H125.978L130.752 20.7191H130.774L135.482 8.26709H138.562V23.7551H136.516V10.5771H136.494L131.544 23.7551H129.894L124.9 10.6431H124.878V23.7551H122.898Z M101.499 23.7551V8.26709H104.051L111.839 20.8071H111.861V8.26709H113.885V23.7551H111.443L103.589 10.9731H103.567V23.7551H101.499Z M87.7707 8.26709H98.2867V10.0931H89.8827V14.8671H97.6267V16.6491H89.8827V21.9291H98.2867V23.7551H87.7707V8.26709Z M75.6165 8.26709V23.7551H73.5266V8.26709H75.6165ZM82.9866 23.7551L75.6386 15.8351L82.6785 8.26709H85.2966L78.2125 15.7471L85.7146 23.7551H82.9866Z M63.3369 24.063C58.6289 24.063 56.2529 20.411 56.2529 16.011C56.2529 11.633 58.6289 7.93701 63.3369 7.93701C68.0229 7.93701 70.3989 11.633 70.3989 16.011C70.3989 20.411 68.0229 24.063 63.3369 24.063ZM63.3369 22.237C66.0429 22.237 68.2649 20.169 68.2649 16.473V15.527C68.2649 11.831 66.0429 9.76301 63.3369 9.76301C60.6089 9.76301 58.3869 11.831 58.3869 15.527V16.473C58.3869 20.169 60.6089 22.237 63.3369 22.237Z M48.192 10.0931H43V8.26709H55.474V10.0931H50.282V23.7551H48.192V10.0931Z', + }, }, }; diff --git a/packages/common/src/hooks/useTimer.ts b/packages/common/src/hooks/useTimer.ts index 7d4cdadae4..a6723720f1 100644 --- a/packages/common/src/hooks/useTimer.ts +++ b/packages/common/src/hooks/useTimer.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -// timer for single execution +// Timer for single execution export const useTimer = () => { const timerRef = useRef>(); const startTimeRef = useRef(0); @@ -12,13 +12,13 @@ export const useTimer = () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = undefined; - isPausedRef.current = false; } + isPausedRef.current = false; }, []); const start = useCallback( (callback: () => void, duration: number) => { - // clear existing timer + // Clear existing timer clear(); timerRef.current = setTimeout(callback, duration); @@ -46,6 +46,24 @@ export const useTimer = () => { } }, [start]); + const getRemainingTime = useCallback(() => { + if (isPausedRef.current) { + return remainingTimeRef.current; + } + if (!timerRef.current) { + return 0; + } + const elapsed = Date.now() - startTimeRef.current; + return Math.max(0, remainingTimeRef.current - elapsed); + }, []); + + const reset = useCallback(() => { + clear(); + remainingTimeRef.current = 0; + callbackRef.current = undefined; + isPausedRef.current = false; + }, [clear]); + useEffect(() => { return () => { clear(); @@ -58,7 +76,9 @@ export const useTimer = () => { clear, pause, resume, + getRemainingTime, + reset, }), - [start, clear, pause, resume], + [start, clear, pause, resume, getRemainingTime, reset], ); }; diff --git a/packages/common/src/hooks/useToggler.ts b/packages/common/src/hooks/useToggler.ts index 6e8b28442a..a18801c89a 100644 --- a/packages/common/src/hooks/useToggler.ts +++ b/packages/common/src/hooks/useToggler.ts @@ -1,6 +1,9 @@ import { useCallback, useMemo, useState } from 'react'; -/** @deprecated Use React.useState instead. */ +/** + * @deprecated Use React.useState instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ export function useToggler(initial = false): [ boolean, { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 5eb47839f6..7e718e7afd 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,6 +1,8 @@ +export * from './carousel'; export * from './core/theme'; export * from './hooks/useToggler'; export * from './lottie/lottieUtils'; +export * from './lottie/statusToAccessibilityLabel'; export * from './lottie/useStatusAnimationPoller'; export * from './types'; export * from './utils/getWidthInEm'; diff --git a/packages/common/src/internal/data/asset.ts b/packages/common/src/internal/data/asset.ts index f1737dc4b0..8e4fdb0a22 100644 --- a/packages/common/src/internal/data/asset.ts +++ b/packages/common/src/internal/data/asset.ts @@ -7829,7 +7829,7 @@ export const asset = { timestamp: '2017-04-21T00:00:00Z', }, { - price: '0.06', + price: '1173.74', timestamp: '2017-04-15T00:00:00Z', }, { diff --git a/packages/common/src/lottie/statusToAccessibilityLabel.ts b/packages/common/src/lottie/statusToAccessibilityLabel.ts new file mode 100644 index 0000000000..3c70f84057 --- /dev/null +++ b/packages/common/src/lottie/statusToAccessibilityLabel.ts @@ -0,0 +1,9 @@ +import type { LottieStatus } from '../types/LottieStatus'; + +export const lottieStatusToAccessibilityLabel: Record = { + loading: 'Loading', + success: 'Success', + cardSuccess: 'Success', + failure: 'Failed', + pending: 'Pending', +}; diff --git a/packages/common/src/overlays/useAlert.ts b/packages/common/src/overlays/useAlert.ts index 6ee64e1de9..8b04b00111 100644 --- a/packages/common/src/overlays/useAlert.ts +++ b/packages/common/src/overlays/useAlert.ts @@ -1,7 +1,8 @@ import { useOverlay } from './useOverlay'; /** - * @deprecated Use the visible and onRequestClose props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started + * @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 const useAlert = () => { return useOverlay('alert_'); diff --git a/packages/common/src/overlays/useModal.ts b/packages/common/src/overlays/useModal.ts index cf8ae9f0d5..08c84656af 100644 --- a/packages/common/src/overlays/useModal.ts +++ b/packages/common/src/overlays/useModal.ts @@ -3,7 +3,8 @@ import { useMemo } from 'react'; import { useOverlay } from './useOverlay'; /** - * @deprecated Use the visible and onRequestClose props as outlined in the docs here https://cds.coinbase.com/components/modal#get-started + * @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 const useModal = () => { const { open, close } = useOverlay('modal_'); diff --git a/packages/common/src/select/useMultiSelect.ts b/packages/common/src/select/useMultiSelect.ts index b48a837167..7696c73cec 100644 --- a/packages/common/src/select/useMultiSelect.ts +++ b/packages/common/src/select/useMultiSelect.ts @@ -3,17 +3,17 @@ import { useCallback, useMemo, useState } from 'react'; /** * Options for configuring the useMultiSelect hook */ -export type MultiSelectOptions = { +export type MultiSelectOptions = { /** Initial array of selected values */ - initialValue: string[] | null; + initialValue: SelectValue[] | null; }; /** * API returned by the useMultiSelect hook for managing multi-select state */ -export type MultiSelectApi = { +export type MultiSelectApi = { /** Current array of selected values */ - value: T[]; + value: SelectValue[]; /** Handler for toggling selection of one or more values. * When a single value is passed, it will be added to the selection if it is not already selected. * If the value is already selected, it will be removed from the selection. @@ -34,9 +34,9 @@ export type MultiSelectApi = { * @param options - Configuration options including initial value * @returns API object for managing multi-select state */ -export const useMultiSelect = ({ +export const useMultiSelect = ({ initialValue, -}: MultiSelectOptions): MultiSelectApi => { +}: MultiSelectOptions): MultiSelectApi => { const [value, setValue] = useState(initialValue ?? []); const onChange = useCallback((value: string | string[] | null) => { @@ -86,5 +86,5 @@ export const useMultiSelect = ({ [value, onChange, addSelection, removeSelection, resetSelection], ); - return api as MultiSelectApi; + return api as MultiSelectApi; }; diff --git a/packages/common/src/tabs/TabsContext.ts b/packages/common/src/tabs/TabsContext.ts index d6db14bd64..ae6e0e261a 100644 --- a/packages/common/src/tabs/TabsContext.ts +++ b/packages/common/src/tabs/TabsContext.ts @@ -1,13 +1,19 @@ import { createContext, useContext } from 'react'; -import { type TabsApi } from './useTabs'; +import { type TabsApi, type TabValue } from './useTabs'; -export type TabsContextValue = TabsApi; +export type TabsContextValue< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = TabsApi; export const TabsContext = createContext(undefined); -export const useTabsContext = (): TabsContextValue => { - const context = useContext(TabsContext) as TabsContextValue | undefined; +export const useTabsContext = < + TabId extends string, + TTab extends TabValue = TabValue, +>(): TabsContextValue => { + const context = useContext(TabsContext) as TabsContextValue | undefined; if (!context) throw Error('useTabsContext must be used within a TabsContext.Provider'); return context; }; diff --git a/packages/common/src/tabs/useTabs.ts b/packages/common/src/tabs/useTabs.ts index 2183c9a010..c5f4ce11b4 100644 --- a/packages/common/src/tabs/useTabs.ts +++ b/packages/common/src/tabs/useTabs.ts @@ -1,28 +1,34 @@ import { useCallback, useMemo } from 'react'; -export type TabValue = { +export type TabValue = { /** The tab id. */ - id: T; + id: TabId; /** The tab label. */ label?: React.ReactNode; /** Disable interactions on the tab. */ disabled?: boolean; }; -export type TabsOptions = { +export type TabsOptions< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = { /** The array of tabs data. */ - tabs: TabValue[]; + tabs: TTab[]; /** React state for the currently active tab. Setting it to `null` results in no active tab. */ - activeTab: TabValue | null; + activeTab: TTab | null; /** Callback that is fired when the active tab changes. Use this callback to update the `activeTab` state. */ - onChange: (activeTab: TabValue | null) => void; + onChange: (activeTab: TTab | null) => void; /** Disable interactions on all the tabs. */ disabled?: boolean; }; -export type TabsApi = Omit, 'onChange'> & { +export type TabsApi< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit, 'onChange'> & { /** Update the currently active tab to the tab with `tabId`. */ - updateActiveTab: (tabId: T | null) => void; + updateActiveTab: (tabId: TabId | null) => void; /** Update the currently active tab to the next enabled tab in the tabs array. Does nothing if the last tab is already active. */ goNextTab: () => void; /** Update the currently active tab to the previous enabled tab in the tabs array. Does nothing if the first tab is already active. */ @@ -30,16 +36,16 @@ export type TabsApi = Omit, 'onChange' }; /** A controlled hook for managing tabs state, such as the currently active tab. */ -export const useTabs = ({ +export const useTabs = = TabValue>({ tabs, activeTab, disabled, onChange, -}: TabsOptions): TabsApi => { +}: TabsOptions): TabsApi => { const updateActiveTab = useCallback( - (tabId: T | null) => { - let newActiveTab: TabValue | null = null; - if (typeof tabId === 'string') { + (tabId: TabId | null) => { + let newActiveTab: TTab | null = null; + if (typeof tabId === 'string' && tabId !== '') { newActiveTab = tabs.find((tab) => tab.id === tabId) ?? tabs[0]; } if (newActiveTab !== activeTab) onChange(newActiveTab); @@ -48,7 +54,7 @@ export const useTabs = ({ ); const goNextTab = useCallback(() => { - if (!activeTab || activeTab === tabs.at(-1)) return; + if (!activeTab || activeTab === tabs[tabs.length - 1]) return; const activeTabIndex = tabs.indexOf(activeTab); // Find next tab that isn't disabled for (let i = activeTabIndex + 1; i < tabs.length; i++) { diff --git a/packages/common/src/tokens/overlays.ts b/packages/common/src/tokens/overlays.ts index dac3f4fc14..493166c031 100644 --- a/packages/common/src/tokens/overlays.ts +++ b/packages/common/src/tokens/overlays.ts @@ -1 +1,2 @@ -export const FOCUSABLE_ELEMENTS = 'a[href], button:not([disabled]), textarea, input, select'; +export const FOCUSABLE_ELEMENTS = + 'a[href], button:not([disabled]), textarea, input, select, [contenteditable]:not([contenteditable="false"])'; diff --git a/packages/common/src/tokens/tags.ts b/packages/common/src/tokens/tags.ts index 37d52910b6..866a9221f9 100644 --- a/packages/common/src/tokens/tags.ts +++ b/packages/common/src/tokens/tags.ts @@ -93,7 +93,8 @@ type TagColorMap = Record< >; /** - * @deprecated Use tagEmphasisColorMap instead + * @deprecated Use tagEmphasisColorMap instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 */ export const tagColorMap: TagColorMap = { informational: tagEmphasisColorMap.low, diff --git a/packages/common/src/tour/TourContext.ts b/packages/common/src/tour/TourContext.ts index 4c8af97ed9..cafd16e11b 100644 --- a/packages/common/src/tour/TourContext.ts +++ b/packages/common/src/tour/TourContext.ts @@ -1,14 +1,15 @@ import { type Context, createContext, useContext } from 'react'; -import type { View } from 'react-native'; import type { TourApi } from './useTour'; -export type TourContextValue = TourApi; +export type TourContextValue = TourApi; export const TourContext = createContext(undefined); -export const useTourContext = (): TourContextValue => { - const context = useContext(TourContext as unknown as Context>); +export const useTourContext = < + TourStepId extends string = string, +>(): TourContextValue => { + const context = useContext(TourContext as unknown as Context>); if (!context) throw Error('useTourContext must be called inside a Tour'); return context; }; diff --git a/packages/common/src/tour/useTour.ts b/packages/common/src/tour/useTour.ts index 7611ba8937..978dc7715e 100644 --- a/packages/common/src/tour/useTour.ts +++ b/packages/common/src/tour/useTour.ts @@ -3,6 +3,10 @@ import type React from 'react'; import type { View } from 'react-native'; import { type Coords, type Placement } from '@floating-ui/react-dom'; +/** + * @deprecated Import from `@coinbase/cds-web` or `@coinbase/cds-mobile` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type TourStepArrowComponentProps = { /* The `@floating-ui` `arrow` coordinates and offsets https://floating-ui.com/docs/arrow#data */ arrow?: Partial & { @@ -15,7 +19,8 @@ export type TourStepArrowComponentProps = { }; /** - * The TourStepArrowComponent must forwardRef onto the underlying element for `@floating-ui` to correctly position the element. + * @deprecated Import from `@coinbase/cds-web` or `@coinbase/cds-mobile` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ export type TourStepArrowComponent = React.ForwardRefExoticComponent< TourStepArrowComponentProps & { ref?: React.Ref } @@ -23,20 +28,17 @@ export type TourStepArrowComponent = React.ForwardRefExoticComponent< export type TourStepComponent = React.FC>; -/** - * Web only. - */ export type TourScrollOptions = { behavior?: ScrollBehavior; marginX?: number; marginY?: number; }; -export type TourStepValue = { +export type TourStepValue = { /** * The tour step id. */ - id: T; + id: TourStepId; /** * The Component to render for this tour step. */ @@ -78,7 +80,9 @@ export type TourStepValue = { */ tourMaskBorderRadius?: string | number; /** - * Add styles to the TourStepArrowComponent for this tour step. + * Add styles to the TourStepArrowComponent for this tour step. Use `styles.arrow` instead. + * @deprecated Use Tour's `styles.stepArrow` prop instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 */ arrowStyle?: Record; /** @@ -91,24 +95,27 @@ export type TourStepValue = { scrollOptions?: TourScrollOptions; }; -export type TourOptions = { +export type TourOptions = { /* The array of tour steps data. */ - steps: TourStepValue[]; + steps: TourStepValue[]; /* The value of the currently active tour step. */ - activeTourStep: TourStepValue | null; + activeTourStep: TourStepValue | null; /* Set the value of the currently active tour step. */ - onChange: (tourStep: TourStepValue | null) => void; + onChange: (tourStep: TourStepValue | null) => void; }; -export type TourApi = Omit, 'onChange'> & { +export type TourApi = Omit< + TourOptions, + 'onChange' +> & { /* The target element of the currently active tour step. */ activeTourStepTarget: HTMLElement | View | null; /* Set the target element of the currently active tour step. */ setActiveTourStepTarget: (target: HTMLElement | View | null) => void; /* Jumps to a specified step of the tour. */ - setActiveTourStep: (tourStepId: T | null) => void; + setActiveTourStep: (tourStepId: TourStepId | null) => void; /* Starts the tour; can optionally start at a specified step ID. */ - startTour: (tourStepId?: T) => void; + startTour: (tourStepId?: TourStepId) => void; /* Stops the tour. */ stopTour: () => void; /* Moves to the next step in the tour. */ @@ -117,15 +124,18 @@ export type TourApi = Omit, 'onChange' goPreviousTourStep: () => void; }; -/** A controlled hook for managing tour state, such as the currently active tour step. */ -export const useTour = ({ +/** + * A controlled hook for managing tour state, such as the currently active tour step. + * @see {@link https://linear.app/coinbase/issue/CDS-1878 CDS-1878} for planned refactor to make this API platform agnostic. + */ +export const useTour = ({ steps, activeTourStep, onChange, -}: TourOptions): TourApi => { +}: TourOptions): TourApi => { const [activeTourStepTarget, setActiveTourStepTarget] = useState(null); const startTour = useCallback( - async (tourStepId?: T | null) => { + async (tourStepId?: TourStepId | null) => { if (typeof tourStepId === 'undefined') return onChange(steps[0]); let newActiveTourStep = null; if (typeof tourStepId === 'string') { @@ -145,7 +155,7 @@ export const useTour = ({ ); const setActiveTourStep = useCallback( - async (tourStepId: T | null) => startTour(tourStepId), + async (tourStepId: TourStepId | null) => startTour(tourStepId), [startTour], ); @@ -158,7 +168,8 @@ export const useTour = ({ const goNextTourStep = useCallback(async () => { // If no active step, or active step is the last step, or there are 0 - 1 steps, do nothing - if (!activeTourStep || activeTourStep.id === steps.at(-1)?.id || steps.length < 2) return; + if (!activeTourStep || activeTourStep.id === steps[steps.length - 1]?.id || steps.length < 2) + return; const activeStepIndex = steps.findIndex((step) => step.id === activeTourStep.id); // Find next step that isn't disabled for (let i = activeStepIndex + 1; i < steps.length; i++) { diff --git a/packages/common/src/types/CardHeaderProps.ts b/packages/common/src/types/CardHeaderProps.ts index 924564b48b..892be22370 100644 --- a/packages/common/src/types/CardHeaderProps.ts +++ b/packages/common/src/types/CardHeaderProps.ts @@ -1,5 +1,9 @@ import type { SharedProps } from './SharedProps'; +/** + * @deprecated Use ContentCardHeaderProps instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CardHeaderProps = { /** Absolute or Relative path to Avatar */ avatar?: string; diff --git a/packages/common/src/types/CardMediaProps.ts b/packages/common/src/types/CardMediaProps.ts index aec5461eb1..2352881153 100644 --- a/packages/common/src/types/CardMediaProps.ts +++ b/packages/common/src/types/CardMediaProps.ts @@ -15,6 +15,10 @@ export type CardMediaImageSizeObject = aspectRatio: AspectRatio; }; +/** + * @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 = { /** Informs how to auto-magically size the media. */ placement: CardMediaPlacement; diff --git a/packages/common/src/types/LottieStatus.ts b/packages/common/src/types/LottieStatus.ts new file mode 100644 index 0000000000..25ab3332ef --- /dev/null +++ b/packages/common/src/types/LottieStatus.ts @@ -0,0 +1 @@ +export type LottieStatus = 'loading' | 'success' | 'cardSuccess' | 'failure' | 'pending'; diff --git a/packages/common/src/types/LottieStatusAnimationProps.ts b/packages/common/src/types/LottieStatusAnimationProps.ts index ae687fdb9e..8aad233d5a 100644 --- a/packages/common/src/types/LottieStatusAnimationProps.ts +++ b/packages/common/src/types/LottieStatusAnimationProps.ts @@ -1,19 +1,27 @@ import type { DimensionValue } from './DimensionStyles'; +import type { LottieStatus } from './LottieStatus'; import type { SharedProps } from './SharedProps'; -export type LottieStatusAnimationType = - | 'loading' - | 'success' - | 'cardSuccess' - | 'failure' - | 'pending'; +/** + * @deprecated Use LottieStatus directly from @coinbase/cds-common/types/LottieStatus instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ +export type { LottieStatus as LottieStatusAnimationType }; +/** + * @deprecated Use LottieStatusAnimationBaseProps from cds-web or cds-mobile instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ type BaseStatusAnimationProps = { - status?: LottieStatusAnimationType; + status?: LottieStatus; onFinish?: () => void; }; -type StatusAnimationWithWidth = { +/** + * @deprecated Use LottieStatusAnimationPropsWithWidth from cds-web or cds-mobile instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ +type LottieStatusAnimationPropsWithWidth = { /** * We use aspect ratio to calculate the unset dimension based on the set dimension and a given aspect ratio. * Only width or height is allowed, but not both. @@ -21,7 +29,11 @@ type StatusAnimationWithWidth = { width: DimensionValue; } & BaseStatusAnimationProps; -type StatusAnimationWithHeight = { +/** + * @deprecated Use LottieStatusAnimationPropsWithHeight from cds-web or cds-mobile instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ +type LottieStatusAnimationPropsWithHeight = { /** * We use aspect ratio to calculate the unset dimension based on the set dimension and a given aspect ratio. * Only width or height is allowed, but not both. @@ -29,5 +41,12 @@ type StatusAnimationWithHeight = { height: DimensionValue; } & BaseStatusAnimationProps; -export type LottieStatusAnimationProps = (StatusAnimationWithWidth | StatusAnimationWithHeight) & +/** + * @deprecated Use LottieStatusAnimationProps from cds-web or cds-mobile instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ +export type LottieStatusAnimationProps = ( + | LottieStatusAnimationPropsWithWidth + | LottieStatusAnimationPropsWithHeight +) & SharedProps; diff --git a/packages/common/src/types/StickyFooterProps.ts b/packages/common/src/types/StickyFooterProps.ts index d6f422531f..76b8a59fb4 100644 --- a/packages/common/src/types/StickyFooterProps.ts +++ b/packages/common/src/types/StickyFooterProps.ts @@ -3,7 +3,8 @@ import type { SharedProps } from './SharedProps'; import type { PaddingProps } from './SpacingProps'; /** - * @deprecated Use StickyFooterProps from @coinbase/cds-mobile instead. + * @deprecated Use StickyFooterProps from @coinbase/cds-mobile instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 */ export type StickyFooterProps = { /** diff --git a/packages/common/src/types/index.ts b/packages/common/src/types/index.ts index 3f9e394628..824e13f007 100644 --- a/packages/common/src/types/index.ts +++ b/packages/common/src/types/index.ts @@ -29,6 +29,7 @@ export * from './IllustrationProps'; export * from './InputBaseProps'; export * from './LottiePlayer'; export * from './LottieSource'; +export * from './LottieStatus'; export * from './LottieStatusAnimationProps'; export * from './Motion'; export * from './OverlayLifecycleProps'; diff --git a/packages/common/src/visualizations/getProgressSize.ts b/packages/common/src/visualizations/getProgressSize.ts new file mode 100644 index 0000000000..f3bccb9833 --- /dev/null +++ b/packages/common/src/visualizations/getProgressSize.ts @@ -0,0 +1,14 @@ +import type { Weight } from '../types/Weight'; + +export const getProgressSize = (weight: Weight) => { + switch (weight) { + case 'semiheavy': + return 8; + case 'heavy': + return 12; + case 'thin': + return 2; + default: + return 4; + } +}; diff --git a/packages/common/src/visualizations/useProgressSize.ts b/packages/common/src/visualizations/useProgressSize.ts index f5c290f3e6..aa79e8dca8 100644 --- a/packages/common/src/visualizations/useProgressSize.ts +++ b/packages/common/src/visualizations/useProgressSize.ts @@ -2,6 +2,9 @@ import { useMemo } from 'react'; import type { Weight } from '../types/Weight'; +/** @deprecated Use getProgressSize instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export const useProgressSize = (weight: Weight) => { return useMemo(() => { switch (weight) { diff --git a/packages/eslint-plugin-cds/CHANGELOG.md b/packages/eslint-plugin-cds/CHANGELOG.md index cf9ea664ff..d23cc6c9e5 100644 --- a/packages/eslint-plugin-cds/CHANGELOG.md +++ b/packages/eslint-plugin-cds/CHANGELOG.md @@ -8,6 +8,24 @@ All notable changes to this project will be documented in this file. +## 3.4.0 (3/27/2026 PST) + +#### 🚀 Updates + +- Add more docs, new script, and new LLM skill for rule creation. [[#549](https://github.com/coinbase/cds/pull/549)] + +## 3.3.0 (3/27/2026 PST) + +#### 🚀 Updates + +- Add new lint a11y rules for charts and tooltip, extend a11y label rules. [[#528](https://github.com/coinbase/cds/pull/528)] + +## 3.2.2 (3/18/2026 PST) + +#### 🐞 Fixes + +- Update a11y rules for DatePicker open/close calendar labels. [[#139](https://github.com/coinbase/cds/pull/139)] + ## 3.2.1 (10/1/2025 PST) #### 🐞 Fixes diff --git a/packages/eslint-plugin-cds/README.md b/packages/eslint-plugin-cds/README.md index 1bc4cdf534..a804325af5 100644 --- a/packages/eslint-plugin-cds/README.md +++ b/packages/eslint-plugin-cds/README.md @@ -1,21 +1,15 @@ -# @coinbase/eslint-plugin-cds +# Overview -## Overview - -The CDS ESLint Plugin targets gaps in existing accessibility linting and CDS Best Practices that were identified in our CDS A11y linting rules audit. +The CDS ESLint Plugin targets CDS best practices to ensure components are being used in accordance with our guidelines and remain accessible. The CDS Eslint Plugin is integrated into the internal Coinbase eslint plugin and is utilized in two of its configurations: - 🌐 React: Used in web repositories. Extends `airbnb/rules/react-a11y` which includes the [`jsx-a11y`](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/main) plugin. - 📱 React Native: Used in React Native repositories and includes the `react-native-a11y` plugin. -In both react and react-native configurations there is a gap in the a11y ruleset that cannot target specific CDS components. - -🎯 Our goal with the `eslint-plugin-cds` package is to create new rules to address these gaps in accessibility and to enforce CDS Best Practices. +# Setup -## Setup - -### EsLint 9 Flat Config +## EsLint 9 Flat Config Eslint v9 introduced the modern _[Flat Config](https://eslint.org/docs/latest/use/configure/migration-guide)_ format for configuration files. @@ -34,7 +28,7 @@ export default tseslint.config({ }); ``` -### Legacy _eslintrc_ Config +## Legacy _eslintrc_ Config In order to use the CDS plugin in legacy `.eslintrc` configuration files, you will need to use the _legacy_ configurations. @@ -47,9 +41,9 @@ module.exports = { }; ``` -## Development +# Development -### Building Locally +## Building Locally To build locally, run @@ -57,20 +51,48 @@ To build locally, run yarn nx run eslint-plugin-cds:build ``` -### Creating New Rule +## Creating New Rules + +You can scaffold a new rule using the generator script: + +``` +yarn node packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs +``` + +This creates the rule source file and a matching test file with boilerplate already in place. -To create a new ESLint rule, you can add your rule from the `packages/eslint-plugin-cds/src/rules/` directory. +To create a rule manually, follow the steps below. -We have two configs: +### Step-by-step checklist -- mobile: config containing rules targeting mobile / react-native -- web: config containing rules targeting web / react +1. **Create the rule file** in `src/rules/`. Use `src/templates/custom-rule.ts` as a starting point, or copy an existing rule. +2. **Register the rule** by adding an import and entry in `src/rules.ts`. +3. **Add the rule to a config** in `src/configs/`. Choose the config that matches the rule's platform: + - `web` -- rules targeting web / React codebases + - `mobile` -- rules targeting mobile / React Native codebases +4. **Write tests** in `tests/`. Each rule should have a corresponding `.test.ts` file using `@typescript-eslint/rule-tester`. +5. **Document the rule** in this README under the appropriate category in the [CDS Rules](#cds-rules) section. +6. **Update the rules summary table** at the top of the [CDS Rules](#cds-rules) section. -After creating a rule, be sure to add it to the appropriate config. +### Authoring patterns -Note: Use [AST Explorer](https://astexplorer.net/) with parser set to `@typescript-eslint/parser` to determine AST node types. +There are two patterns for defining a rule: -### Testing on Consumer Repos Locally +- **`TSESLint.RuleModule`** -- a plain rule module, good for simple rules (see `no-v7-imports.ts` for an example). +- **`ESLintUtils.RuleCreator`** -- a factory that generates documentation URLs from the rule name. Preferred for rules that benefit from linked documentation (see `control-has-associated-label-extended.ts` for an example). + +### Useful resources + +- [ESLint custom rule tutorial](https://eslint.org/docs/latest/extend/custom-rule-tutorial) +- [AST Explorer](https://astexplorer.net/) (set parser to `@typescript-eslint/parser`) +- [ESLint Explorer](https://explorer.eslint.org/) + +### Available configs + +- `web`: rules targeting web / React codebases (includes CDS web rules + `jsx-a11y`) +- `mobile`: rules targeting mobile / React Native codebases (includes CDS mobile rules + `react-native-a11y`) + +## Testing on External Repos Locally To test on consumer repos locally, you will need to build your `eslint-plugin-cds` package, add your package to the `package.json` and modify `eslintrc`. @@ -98,156 +120,69 @@ To test on consumer repos locally, you will need to build your `eslint-plugin-cd 5. Run `yarn nx run :lint` or `npx eslint .` in root directory or `workspace`. - 💡 Tip: Run `npx eslint . > eslint_output.txt` to be able to see all the output. -## CDS Rules +# CDS Rules + +## Rules Overview -### ♿ Accessibility Rules +| Rule | Category | Platform | Included in Config | +| --------------------------------------------------------------------------------- | ------------- | -------- | ------------------ | +| [`controlHasAssociatedLabelExtended`](#-controlhasassociatedlabelextended-web) | Accessibility | Web | `web` | +| [`hasValidA11yDescriptorsExtended`](#-hasvalida11ydescriptorsextended-mobile) | Accessibility | Mobile | `mobile` | +| [`webChartScrubbingAccessibility`](#-webchartscrubbingaccessibility-web) | Accessibility | Web | `web` | +| [`mobileChartScrubbingAccessibility`](#-mobilechartscrubbingaccessibility-mobile) | Accessibility | Mobile | `mobile` | +| [`webTooltipInteractiveContent`](#-webtooltipinteractivecontent-web) | Accessibility | Web | `web` | +| [`noV7Imports`](#-nov7imports-web) | Migration | Web | `web` | -We currently have two additional accessibility rules: +## Accessibility Rules -#### 🔍 controlHasAssociatedLabelExtended (Web) +### 🔍 controlHasAssociatedLabelExtended (Web) **Rule Description**: -The `controlHasAssociatedLabelExtended` rule checks for the presence of an `accessibilityLabel` or other specific a11yLabel props on designated Web CDS components. +The `controlHasAssociatedLabelExtended` rule checks for the presence of an `accessibilityLabel` or other specific a11yLabel props on designated web CDS components. The `accessibilityLabel` is required for components listed under `componentsRequiringAccessibilityLabel`. The rule enforces that these components must have an `accessibilityLabel` attribute unless: - They contain inner text, or - They have props spread which might implicitly handle accessibility. -**Targeted Components** This rule specifically targets components such as: - -- `Button` -- `Checkbox` -- `InputChip` -- `IconButton` -- `IconCounterButton` -- `Pressable` -- `Switch` -- `TextInput` -- `FeedCard` -- `ProgressBar` -- `Select` -- `NavigationBar` -- `Sidebar` -- `Popover` - -**Extended A11y Lint Coverage**: - -This rule also checks for other required a11y labels that need to be enforced outside of `accessibilityLabel`. - -For components listed under `collapsibleCheckForControlledElementAccessibilityProps`, this rule ensures that `controlledElementAccessibilityProps` are provided to manage their accessibility state dynamically. - -**Extended Targeted Components** - -- `Collapsible`, `Dropdown` - - Checks for presence of `controlledElementAccessibilityProps` -- `TextInput`, `SelectStack` - - Checks for presence of `helperTextErrorIconAccessibilityLabel` -- `DatePicker` - - Checks for presence of `calendarIconButtonAccessibilityLabel` -- `DatePicker`, `Calendar`, `TabNavigation` - - Checks for presence of `nextArrowAccessibilityLabel` and `previousArrowAccessibilityLabel` -- `NudgeCard`, `UpsellCard` - - Checks for presence of `accessibilityLabel` when `onDismissPress` is present -- `SearchInput` - - Checks for presence of `startIconAccessibilityLabel` and `clearIconAccessibilityLabel` - -#### 🔍 hasValidA11yDescriptorsExtended (mobile) +### 🔍 hasValidA11yDescriptorsExtended (Mobile) **Rule Description**: -The `hasValidA11yDescriptorsExtended` rule verifies that mobile CDS components such as buttons and switches have an `accessibilityLabel` or other specific a11yLabel props on designated Mobile CDS components. It does not flag components if: +The `hasValidA11yDescriptorsExtended` rule verifies that mobile CDS components such as buttons and switches have an `accessibilityLabel` or other specific a11yLabel props on designated mobile CDS components. It does not flag components if: - They contain inner text that serves as an implicit label. - They have properties spread that can implicitly provide accessibility attributes. -**Targeted Components** This rule specifically targets components such as: - -- `Button` -- `Checkbox` -- `InputChip` -- `IconButton` -- `IconCounterButton` -- `Pressable` -- `Switch` -- `TextInput` -- `FeedCard` -- `StickyFooter` -- `ProgressBar` -- `Select` -- `NavigationBar` -- `Sidebar` -- `Popover` +### 🔍 webChartScrubbingAccessibility (web) -**Extended A11y Lint Coverage**: +**Rule Description**: -This rule also checks for other required a11y labels that need to be enforced outside of `accessibilityLabel`. +The `webChartScrubbingAccessibility` rule enforces chart accessibility descriptors when web chart scrubbing is enabled with the `enableScrubbing` prop. **Extended Targeted Components** -- `Drawer`, `SelectChip`, `Tray` - - Checks for presence of `handleBarAccessibilityLabel` -- `TextInput` - - Checks for presence of `helperTextErrorIconAccessibilityLabel` -- `DatePicker` - - Checks for presence of `calendarIconButtonAccessibilityLabel` -- `NudgeCard`, `UpsellCard` - - Checks for presence of `accessibilityLabel` when `onDismissPress` is present -- `SearchInput` - - Checks for presence of `startIconAccessibilityLabel` and `clearIconAccessibilityLabel` - -### Current CDS Best Practices Rules +- `LineChart`, `BarChart`, `CartesianChart`, `AreaChart` + - Checks for chart-level accessible naming via `accessibilityLabel` or `aria-labelledby` + - Checks for scrubber-level labels via either: + - `getScrubberAccessibilityLabel`, or + - `` child -TBD +### 🔍 mobileChartScrubbingAccessibility (mobile) -## Development - -### Building Locally - -To build locally, run - -``` -yarn nx run eslint-plugin-cds:build -``` - -### Creating New Rule - -To create a new ESLint rule, you can add your rule from the `packages/eslint-plugin-cds/src/rules/` directory. - -We have two configs: - -- mobile: config containing rules targeting mobile / react-native -- web: config containing rules targeting web / react - -After creating a rule, be sure to add it to the appropriate config. - -Note: Use [AST Explorer](https://astexplorer.net/) with parser set to `@typescript-eslint/parser` to determine AST node types. +**Rule Description**: -### Testing on Consumer Repos Locally +The `mobileChartScrubbingAccessibility` rule enforces chart accessibility descriptors when mobile chart scrubbing is enabled with the prop `enableScrubbing`. -To test on consumer repos locally, you will need to build your `eslint-plugin-cds` package, add your package to the `package.json` and modify `eslintrc`. - -1. Build your local package and pack it. +**Extended Targeted Components** - ``` - yarn nx run eslint-plugin-cds:build - cd packages/eslint-plugin-cds - yarn pack - ``` +- `LineChart`, `BarChart`, `CartesianChart`, `AreaChart` + - Checks for chart-level accessible naming via `accessibilityLabel` or `aria-labelledby` + - Checks for per-point labels via `getScrubberAccessibilityLabel` -2. Add your package as a `devDependency` in the consumer's `package.json`. Use the path in your local directory. - ``` - "@coinbase/eslint-plugin-cds": "file:../cds/packages/eslint-plugin-cds/package.tgz", - ``` -3. Add the plugin and extend a specific config in the `.eslintrc.js`/`eslint.confg.js` file. +### 🔍 webTooltipInteractiveContent (web) - 📝 Note: There are differences between `extends` and `plugins`: - - `extends`: Allows you to use and build upon an existing set of ESLint rules defined in another configuration. Useful for adhering to standardized coding styles like Airbnb or Google. - - By using the extends keyword, you're not just making rules available, but you are actively applying a set of predefined rules from another configuration. This means that the rules defined in the extended configurations are automatically enforced in your project, unless explicitly overridden. - - `plugins`: Introduces new rules or environments to ESLint that extend its core capabilities, tailored for specific frameworks or libraries. - - When you use plugins, you make a set of additional rules available to your configuration. However, simply including a plugin does not apply those rules. You must explicitly enable the rules provided by the plugin in your configuration file to enforce them in your project. Essentially, plugins expand the rule set that you can choose from, but they don't enforce any rules by default. +**Rule Description**: -4. Run `yarn` in root directory or `workspace`. -5. Run `yarn nx run :lint` or `npx eslint .` in root directory or `workspace`. - - 💡 Tip: Run `npx eslint . > eslint_output.txt` to be able to see all the output. +The `webTooltipInteractiveContent` rule requires `hasInteractiveContent` when tooltip `content` includes interactive elements (for example buttons or links), matching CDS tooltip accessibility guidance. diff --git a/packages/eslint-plugin-cds/package.json b/packages/eslint-plugin-cds/package.json index 3d3e190e8d..aba919aca0 100644 --- a/packages/eslint-plugin-cds/package.json +++ b/packages/eslint-plugin-cds/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/eslint-plugin-cds", - "version": "3.2.1", + "version": "3.4.0", "description": "ESLint plugin for CDS", "repository": { "type": "git", diff --git a/packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs b/packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs new file mode 100644 index 0000000000..fa3feb4c0c --- /dev/null +++ b/packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs @@ -0,0 +1,143 @@ +#!/usr/bin/env node + +/** + * Scaffolds a new ESLint rule with a matching test file. + * + * Usage: + * yarn node packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs + * + * Example: + * yarn node packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs no-deprecated-tokens + * + * This will create: + * - src/rules/no-deprecated-tokens.ts + * - tests/no-deprecated-tokens.test.ts + * - An import + registration entry in src/rules.ts + */ + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PACKAGE_ROOT = resolve(__dirname, '..'); + +function toCamelCase(kebab) { + return kebab.replace(/-([a-z])/g, (_, char) => char.toUpperCase()); +} + +function createRule(ruleName) { + if (!ruleName) { + console.error('Usage: create-rule.mjs '); + console.error('Example: create-rule.mjs no-deprecated-tokens'); + process.exit(1); + } + + if (!/^[a-z][a-z0-9-]*$/.test(ruleName)) { + console.error( + `Invalid rule name "${ruleName}". Use lowercase kebab-case (e.g. "no-deprecated-tokens").`, + ); + process.exit(1); + } + + const exportName = toCamelCase(ruleName); + const ruleFile = resolve(PACKAGE_ROOT, 'src', 'rules', `${ruleName}.ts`); + const testFile = resolve(PACKAGE_ROOT, 'tests', `${ruleName}.test.ts`); + const rulesRegistryFile = resolve(PACKAGE_ROOT, 'src', 'rules.ts'); + + if (existsSync(ruleFile)) { + console.error(`Rule file already exists: ${ruleFile}`); + process.exit(1); + } + + if (existsSync(testFile)) { + console.error(`Test file already exists: ${testFile}`); + process.exit(1); + } + + const ruleContent = `import type { TSESLint } from '@typescript-eslint/utils'; + +type MessageIds = 'TODO_REPLACE_ME'; + +export const ${exportName}: TSESLint.RuleModule = { + defaultOptions: [], + meta: { + type: 'problem', + docs: { + description: 'TODO: describe what this rule enforces', + }, + messages: { + TODO_REPLACE_ME: 'TODO: error message shown to the user', + }, + schema: [], + }, + create(context) { + return { + // TODO: add AST visitor methods + // See https://astexplorer.net/ (set parser to @typescript-eslint/parser) + }; + }, +}; +`; + + const testContent = `import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { ${exportName} } from '../src/rules/${ruleName}'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +// @ts-expect-error - not sure why the rule type is not matching up with the rule tester +ruleTester.run('${ruleName}', ${exportName}, { + valid: [ + // TODO: add valid test cases + ], + invalid: [ + // TODO: add invalid test cases + ], +}); +`; + + writeFileSync(ruleFile, ruleContent); + console.log(`Created rule: src/rules/${ruleName}.ts`); + + writeFileSync(testFile, testContent); + console.log(`Created test: tests/${ruleName}.test.ts`); + + const registryContent = readFileSync(rulesRegistryFile, 'utf-8'); + + const importLine = `import { ${exportName} } from './rules/${ruleName}';`; + const registryEntry = ` '${ruleName}': ${exportName},`; + + const lastImportIndex = registryContent.lastIndexOf('import '); + const endOfLastImport = registryContent.indexOf('\n', lastImportIndex); + const withImport = + registryContent.slice(0, endOfLastImport + 1) + + importLine + + '\n' + + registryContent.slice(endOfLastImport + 1); + + const closingBrace = withImport.lastIndexOf('} as const'); + const withEntry = + withImport.slice(0, closingBrace) + registryEntry + '\n' + withImport.slice(closingBrace); + + writeFileSync(rulesRegistryFile, withEntry); + console.log(`Registered in: src/rules.ts`); + + console.log(` +Next steps: + 1. Implement your rule logic in src/rules/${ruleName}.ts + 2. Write test cases in tests/${ruleName}.test.ts + 3. Add the rule to the appropriate config in src/configs/ + 4. Document the rule in README.md + 5. Run: yarn nx run eslint-plugin-cds:test`); +} + +createRule(process.argv[2]); diff --git a/packages/eslint-plugin-cds/src/configs/mobile.ts b/packages/eslint-plugin-cds/src/configs/mobile.ts index e09ef3e05c..d9d33a1e02 100644 --- a/packages/eslint-plugin-cds/src/configs/mobile.ts +++ b/packages/eslint-plugin-cds/src/configs/mobile.ts @@ -23,6 +23,7 @@ export function buildMobileConfig(plugin: Record) { rules: { 'react-native-a11y/has-accessibility-hint': 'off', '@coinbase/cds/has-valid-accessibility-descriptors-extended': 'warn', + '@coinbase/cds/mobile-chart-scrubbing-accessibility': 'warn', }, }; } @@ -32,5 +33,6 @@ export const legacyMobileConfig = { rules: { 'react-native-a11y/has-accessibility-hint': 'off', '@coinbase/cds/has-valid-accessibility-descriptors-extended': 'warn', + '@coinbase/cds/mobile-chart-scrubbing-accessibility': 'warn', }, }; diff --git a/packages/eslint-plugin-cds/src/configs/web.ts b/packages/eslint-plugin-cds/src/configs/web.ts index 411facf7b6..3fee17536c 100644 --- a/packages/eslint-plugin-cds/src/configs/web.ts +++ b/packages/eslint-plugin-cds/src/configs/web.ts @@ -23,6 +23,8 @@ export function buildWebConfig(plugin: Record) { rules: { '@coinbase/cds/control-has-associated-label-extended': 'warn', '@coinbase/cds/no-v7-imports': 'warn', + '@coinbase/cds/web-chart-scrubbing-accessibility': 'warn', + '@coinbase/cds/web-tooltip-interactive-content': 'warn', 'jsx-a11y/control-has-associated-label': [ 'warn', { @@ -49,6 +51,8 @@ export const legacyWebConfig = { rules: { '@coinbase/cds/control-has-associated-label-extended': 'warn', '@coinbase/cds/no-v7-imports': 'warn', + '@coinbase/cds/web-chart-scrubbing-accessibility': 'warn', + '@coinbase/cds/web-tooltip-interactive-content': 'warn', }, overrides: [ { diff --git a/packages/eslint-plugin-cds/src/rules.ts b/packages/eslint-plugin-cds/src/rules.ts index 68cc384151..31e17b62a3 100644 --- a/packages/eslint-plugin-cds/src/rules.ts +++ b/packages/eslint-plugin-cds/src/rules.ts @@ -2,12 +2,18 @@ import type { TSESLint } from '@typescript-eslint/utils'; import { controlHasAssociatedLabelExtended } from './rules/control-has-associated-label-extended'; import { hasValidA11yDescriptorsExtended } from './rules/has-valid-accessibility-descriptors-extended'; +import { mobileChartScrubbingAccessibility } from './rules/mobile-chart-scrubbing-accessibility'; import { noV7Imports } from './rules/no-v7-imports'; +import { webChartScrubbingAccessibility } from './rules/web-chart-scrubbing-accessibility'; +import { webTooltipInteractiveContent } from './rules/web-tooltip-interactive-content'; export const rules = { 'control-has-associated-label-extended': controlHasAssociatedLabelExtended, 'has-valid-accessibility-descriptors-extended': hasValidA11yDescriptorsExtended, + 'mobile-chart-scrubbing-accessibility': mobileChartScrubbingAccessibility, 'no-v7-imports': noV7Imports, + 'web-chart-scrubbing-accessibility': webChartScrubbingAccessibility, + 'web-tooltip-interactive-content': webTooltipInteractiveContent, } as const satisfies { [key: string]: TSESLint.RuleModule; }; diff --git a/packages/eslint-plugin-cds/src/rules/control-has-associated-label-extended.ts b/packages/eslint-plugin-cds/src/rules/control-has-associated-label-extended.ts index a4c401c6c1..67a1c4bc83 100644 --- a/packages/eslint-plugin-cds/src/rules/control-has-associated-label-extended.ts +++ b/packages/eslint-plugin-cds/src/rules/control-has-associated-label-extended.ts @@ -11,7 +11,8 @@ * ensures that `controlledElementAccessibilityProps` are provided. */ -import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; import { extractA11yAttributesState } from '../utils/extractA11yAttributesState'; import { getSimpleNameFromJSX } from '../utils/getSimpleNameFromJSX'; @@ -24,10 +25,17 @@ const ruleCreator = ESLintUtils.RuleCreator( type MessageIds = | 'missingAccessibilityLabel' | 'missingAccessibilityLabelSuggestion' + | 'missingAccessibleName' + | 'missingControlAccessibilityLabel' + | 'missingRemoveSelectedOptionAccessibilityLabel' + | 'missingHiddenSelectedOptionsLabel' + | 'missingCloseAccessibilityLabel' + | 'missingBackAccessibilityLabel' + | 'missingTableAccessibleName' | 'missingControlledElementAccessibilityProps' | 'missingControlledElementAccessibilityPropsDropdown' | 'missingHelperTextErrorIconAccessibilityLabel' - | 'missingCalendarIconButtonAccessibilityLabel' + | 'missingCalendarOpenCloseAccessibilityLabels' | 'missingNextArrowAccessibilityLabel' | 'missingPreviousArrowAccessibilityLabel' | 'missingCardDismissAccessibilityLabel' @@ -57,11 +65,19 @@ const config = { 'NavigationBar', 'Sidebar', 'Popover', + 'SegmentedTabs', ], + checkForInteractiveAccessibilityLabelProps: ['Chip', 'MediaChip', 'ListCell'], collapsibleCheckForControlledElementAccessibilityProps: ['Collapsible'], dropdownCheckForControlledElementAccessibilityProps: ['Dropdown'], + checkForComboboxAccessibilityLabelProps: ['Combobox'], + checkForComboboxControlAccessibilityLabelProps: ['Combobox'], + checkForComboboxMultiSelectionAccessibilityLabelProps: ['Combobox'], + checkForModalHeaderActionAccessibilityLabelProps: ['ModalHeader'], + checkForAccessibleNameProps: ['Tray'], + checkForTableAccessibleNameProps: ['Table'], checkForHelperTextErrorIconAccessibilityLabelProps: ['TextInput', 'SelectStack'], - checkForCalendarIconButtonAccessibilityLabelProps: ['DatePicker'], + checkForCalendarOpenCloseAccessibilityLabelProps: ['DatePicker'], checkForArrowAccessibilityProps: ['DatePicker', 'Calendar', 'TabNavigation'], checkForCardDismissAccessibilityLabelProps: ['NudgeCard', 'UpsellCard'], checkForSearchInputAccessibilityLabelProps: ['SearchInput'], @@ -81,10 +97,17 @@ export const controlHasAssociatedLabelExtended = ruleCreator({ messages: { missingAccessibilityLabel: `Missing 'accessibilityLabel' on <{{componentName}}>.`, missingAccessibilityLabelSuggestion: `Add missing accessibility label`, + missingAccessibleName: `Missing an accessible name on <{{componentName}}>. Add 'accessibilityLabel' or 'accessibilityLabelledBy'.`, + missingControlAccessibilityLabel: `Missing 'controlAccessibilityLabel' on <{{componentName}}>.`, + missingRemoveSelectedOptionAccessibilityLabel: `Missing 'removeSelectedOptionAccessibilityLabel' on <{{componentName}}> when type='multi'.`, + missingHiddenSelectedOptionsLabel: `Missing 'hiddenSelectedOptionsLabel' on <{{componentName}}> when type='multi'.`, + missingCloseAccessibilityLabel: `Missing 'closeAccessibilityLabel' on <{{componentName}}>.`, + missingBackAccessibilityLabel: `Missing 'backAccessibilityLabel' on <{{componentName}}> when back action is provided.`, + missingTableAccessibleName: `Missing an accessible table name on <{{componentName}}>. Add as a child, or use 'accessibilityLabel' / 'accessibilityLabelledBy'.`, missingControlledElementAccessibilityProps: `Missing 'controlledElementAccessibilityProps' on <{{componentName}}>. More info: https://cds.coinbase.com/components/collapsible#[object%20Object],Accessibility%20tip%20(web)`, missingControlledElementAccessibilityPropsDropdown: `Missing 'controlledElementAccessibilityProps' on <{{componentName}}>. More info: https://cds.coinbase.com/components/dropdown#page=implementation`, missingHelperTextErrorIconAccessibilityLabel: `Missing 'helperTextErrorIconAccessibilityLabel' on <{{componentName}}>.`, - missingCalendarIconButtonAccessibilityLabel: `Missing 'calendarIconButtonAccessibilityLabel' on <{{componentName}}>.`, + missingCalendarOpenCloseAccessibilityLabels: `Missing calendar open/close accessibility label on <{{componentName}}>. Provide both 'openCalendarAccessibilityLabel' and 'closeCalendarAccessibilityLabel' (or deprecated 'calendarIconButtonAccessibilityLabel').`, missingNextArrowAccessibilityLabel: `Missing 'nextArrowAccessibilityLabel' on <{{componentName}}>.`, missingPreviousArrowAccessibilityLabel: `Missing 'previousArrowAccessibilityLabel' on <{{componentName}}>.`, missingCardDismissAccessibilityLabel: `Missing 'accessibilityLabel' on <{{componentName}}> for dismiss button.`, @@ -125,12 +148,22 @@ export const controlHasAssociatedLabelExtended = ruleCreator({ const { hasLabel, hasAccessibilityLabel, + hasAccessibilityLabelledBy, + hasControlAccessibilityLabel, + hasRemoveSelectedOptionAccessibilityLabel, + hasHiddenSelectedOptionsLabel, + hasBackAccessibilityLabel, + hasCloseAccessibilityLabel, + hasOnBackButtonClickProp, hasControlledElementAccessibilityProps, + hasOnClickProp, hasSpreadProps, componentName, hasInnerText, hasHelperTextErrorIconAccessibilityLabel, - hasCalendarIconButtonAccessibilityLabel, + hasOpenCalendarAccessibilityLabel, + hasCloseCalendarAccessibilityLabel, + hasDeprecatedCalendarIconButtonAccessibilityLabel, hasMissingNextArrowAccessibilityLabel, hasMissingPreviousArrowAccessibilityLabel, hasOnDismissPressProp, @@ -156,10 +189,39 @@ export const controlHasAssociatedLabelExtended = ruleCreator({ isTextInputWithNegativeVariant = false; } + let isComboboxWithMultiType = false; + if (getSimpleNameFromJSX(node.openingElement) === 'Combobox') { + const attributes = node.openingElement.attributes as TSESTree.JSXAttribute[]; + const typeAttribute = attributes.find((attr) => attr.name?.name === 'type'); + if (typeAttribute) { + const typeValue = typeAttribute.value; + if (typeValue && typeValue.type === AST_NODE_TYPES.Literal) { + isComboboxWithMultiType = typeValue.value === 'multi'; + } + } + } + + const hasTableCaptionChild = node.children.some((child) => { + if (child.type !== AST_NODE_TYPES.JSXElement) { + return false; + } + const childName = getSimpleNameFromJSX(child.openingElement); + return childName === 'TableCaption'; + }); + const conditionalChecks: ConditionalCheckType[] = [ { configArray: config.componentsRequiringAccessibilityLabel, - condition: !hasAccessibilityLabel && !(hasSpreadProps || hasInnerText || hasLabel), + condition: + !hasAccessibilityLabel && + !hasAccessibilityLabelledBy && + !(hasSpreadProps || hasInnerText || hasLabel), + messageId: 'missingAccessibilityLabel', + suggestedPropToAdd: 'accessibilityLabel', + }, + { + configArray: config.checkForInteractiveAccessibilityLabelProps, + condition: hasOnClickProp && !hasAccessibilityLabel && !hasAccessibilityLabelledBy, messageId: 'missingAccessibilityLabel', suggestedPropToAdd: 'accessibilityLabel', }, @@ -168,6 +230,53 @@ export const controlHasAssociatedLabelExtended = ruleCreator({ condition: !hasControlledElementAccessibilityProps, messageId: 'missingControlledElementAccessibilityPropsDropdown', }, + { + configArray: config.checkForComboboxAccessibilityLabelProps, + condition: !hasAccessibilityLabel && !hasAccessibilityLabelledBy, + messageId: 'missingAccessibleName', + suggestedPropToAdd: 'accessibilityLabel', + }, + { + configArray: config.checkForComboboxControlAccessibilityLabelProps, + condition: !hasControlAccessibilityLabel, + messageId: 'missingControlAccessibilityLabel', + suggestedPropToAdd: 'controlAccessibilityLabel', + }, + { + configArray: config.checkForComboboxMultiSelectionAccessibilityLabelProps, + condition: isComboboxWithMultiType && !hasRemoveSelectedOptionAccessibilityLabel, + messageId: 'missingRemoveSelectedOptionAccessibilityLabel', + suggestedPropToAdd: 'removeSelectedOptionAccessibilityLabel', + }, + { + configArray: config.checkForComboboxMultiSelectionAccessibilityLabelProps, + condition: isComboboxWithMultiType && !hasHiddenSelectedOptionsLabel, + messageId: 'missingHiddenSelectedOptionsLabel', + suggestedPropToAdd: 'hiddenSelectedOptionsLabel', + }, + { + configArray: config.checkForModalHeaderActionAccessibilityLabelProps, + condition: !hasCloseAccessibilityLabel, + messageId: 'missingCloseAccessibilityLabel', + suggestedPropToAdd: 'closeAccessibilityLabel', + }, + { + configArray: config.checkForModalHeaderActionAccessibilityLabelProps, + condition: hasOnBackButtonClickProp && !hasBackAccessibilityLabel, + messageId: 'missingBackAccessibilityLabel', + suggestedPropToAdd: 'backAccessibilityLabel', + }, + { + configArray: config.checkForAccessibleNameProps, + condition: !hasAccessibilityLabel && !hasAccessibilityLabelledBy, + messageId: 'missingAccessibleName', + }, + { + configArray: config.checkForTableAccessibleNameProps, + condition: + !hasAccessibilityLabel && !hasAccessibilityLabelledBy && !hasTableCaptionChild, + messageId: 'missingTableAccessibleName', + }, { configArray: config.checkForHelperTextErrorIconAccessibilityLabelProps, condition: !hasHelperTextErrorIconAccessibilityLabel && isTextInputWithNegativeVariant, @@ -175,10 +284,13 @@ export const controlHasAssociatedLabelExtended = ruleCreator({ suggestedPropToAdd: 'helperTextErrorIconAccessibilityLabel', }, { - configArray: config.checkForCalendarIconButtonAccessibilityLabelProps, - condition: !hasCalendarIconButtonAccessibilityLabel, - messageId: 'missingCalendarIconButtonAccessibilityLabel', - suggestedPropToAdd: 'calendarIconButtonAccessibilityLabel', + configArray: config.checkForCalendarOpenCloseAccessibilityLabelProps, + condition: !( + (hasOpenCalendarAccessibilityLabel && hasCloseCalendarAccessibilityLabel) || + hasDeprecatedCalendarIconButtonAccessibilityLabel + ), + messageId: 'missingCalendarOpenCloseAccessibilityLabels', + suggestedPropToAdd: 'openCalendarAccessibilityLabel', }, { configArray: config.checkForArrowAccessibilityProps, diff --git a/packages/eslint-plugin-cds/src/rules/custom-rule.ts b/packages/eslint-plugin-cds/src/rules/custom-rule.ts deleted file mode 100644 index b1ab1b99c1..0000000000 --- a/packages/eslint-plugin-cds/src/rules/custom-rule.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { type TSESLint } from '@typescript-eslint/utils'; - -type MessageIds = 'messageOne' | 'messageTwo'; - -export const customRule: TSESLint.RuleModule = { - defaultOptions: [], - meta: { - type: 'problem', - docs: { - description: 'My custom rule description', - }, - messages: { - messageOne: 'Message one', - messageTwo: 'Message two', - }, - fixable: 'code', - schema: [], - }, - create(context) { - return { - Identifier(node) { - if (node.name === 'foo') { - context.report({ - node, - messageId: 'messageOne', - data: { nodeName: node.name }, - }); - } else if (node.name === 'bar') { - context.report({ - node, - messageId: 'messageTwo', - data: { nodeName: node.name }, - }); - } - }, - }; - }, -}; diff --git a/packages/eslint-plugin-cds/src/rules/has-valid-accessibility-descriptors-extended.ts b/packages/eslint-plugin-cds/src/rules/has-valid-accessibility-descriptors-extended.ts index bd8cf8a5aa..39c85c3607 100644 --- a/packages/eslint-plugin-cds/src/rules/has-valid-accessibility-descriptors-extended.ts +++ b/packages/eslint-plugin-cds/src/rules/has-valid-accessibility-descriptors-extended.ts @@ -7,7 +7,8 @@ * - have props spread. */ -import { AST_NODE_TYPES, ESLintUtils, TSESTree } from '@typescript-eslint/utils'; +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; import { extractA11yAttributesState } from '../utils/extractA11yAttributesState'; import { getSimpleNameFromJSX } from '../utils/getSimpleNameFromJSX'; @@ -20,9 +21,12 @@ const ruleCreator = ESLintUtils.RuleCreator( type MessageIds = | 'missingAccessibilityLabel' | 'missingAccessibilityLabelSuggestion' + | 'missingAccessibleName' + | 'missingAccessibilityHint' + | 'missingHiddenSelectedOptionsLabel' | 'missingHandleBarAccessibilityLabel' | 'missingHelperTextErrorIconAccessibilityLabel' - | 'missingCalendarIconButtonAccessibilityLabel' + | 'missingCalendarOpenCloseAccessibilityLabels' | 'missingCardDismissAccessibilityLabel' | 'missingStartIconAccessibilityLabel' | 'missingClearIconAccessibilityLabel'; @@ -51,10 +55,16 @@ const config = { 'NavigationBar', 'Sidebar', 'Popover', + 'SegmentedTabs', ], + checkForInteractiveAccessibilityLabelProps: ['Chip', 'MediaChip', 'ListCell'], + checkForComboboxAccessibilityLabelProps: ['Combobox'], + checkForComboboxMultiSelectionAccessibilityLabelProps: ['Combobox'], + checkForComboboxAccessibilityHintProps: ['Combobox'], + checkForAccessibleNameProps: ['Tray'], checkForMissingHandleBarAccessibilityLabel: ['Drawer', 'SelectChip', 'Tray'], checkForHelperTextErrorIconAccessibilityLabelProps: ['TextInput'], - checkForCalendarIconButtonAccessibilityLabelProps: ['DatePicker'], + checkForCalendarOpenCloseAccessibilityLabelProps: ['DatePicker'], checkForCardDismissAccessibilityLabelProps: ['NudgeCard', 'UpsellCard'], checkForSearchInputAccessibilityLabelProps: ['SearchInput'], @@ -77,9 +87,12 @@ export const hasValidA11yDescriptorsExtended = ruleCreator({ messages: { missingAccessibilityLabel: `Missing 'accessibilityLabel' on <{{componentName}}>.`, missingAccessibilityLabelSuggestion: `Add missing accessibility label`, + missingAccessibleName: `Missing an accessible name on <{{componentName}}>. Add 'accessibilityLabel' or 'accessibilityLabelledBy'.`, + missingAccessibilityHint: `Missing 'accessibilityHint' on <{{componentName}}>.`, + missingHiddenSelectedOptionsLabel: `Missing 'hiddenSelectedOptionsLabel' on <{{componentName}}> when type='multi'.`, missingHandleBarAccessibilityLabel: `Missing 'handleBarAccessibilityLabel' on <{{componentName}}>.`, missingHelperTextErrorIconAccessibilityLabel: `Missing 'helperTextErrorIconAccessibilityLabel' on <{{componentName}}>.`, - missingCalendarIconButtonAccessibilityLabel: `Missing 'calendarIconButtonAccessibilityLabel' on <{{componentName}}>.`, + missingCalendarOpenCloseAccessibilityLabels: `Missing calendar open/close accessibility label on <{{componentName}}>. Provide both 'openCalendarAccessibilityLabel' and 'closeCalendarAccessibilityLabel' (or deprecated 'calendarIconButtonAccessibilityLabel').`, missingCardDismissAccessibilityLabel: `Missing 'accessibilityLabel' on <{{componentName}}> for dismiss button.`, missingStartIconAccessibilityLabel: `Missing 'startIconAccessibilityLabel' on <{{componentName}}>.`, missingClearIconAccessibilityLabel: `Missing 'clearIconAccessibilityLabel' on <{{componentName}}>.`, @@ -118,13 +131,20 @@ export const hasValidA11yDescriptorsExtended = ruleCreator({ const { hasLabel, hasAccessibilityLabel, + hasAccessibilityLabelledBy, + hasHiddenSelectedOptionsLabel, + hasAccessibilityHint, hasSpreadProps, componentName, hasInnerText, hasHandleBarAccessibilityLabelProps, hasHelperTextErrorIconAccessibilityLabel, - hasCalendarIconButtonAccessibilityLabel, + hasOpenCalendarAccessibilityLabel, + hasCloseCalendarAccessibilityLabel, + hasDeprecatedCalendarIconButtonAccessibilityLabel, hasOnDismissPressProp, + hasOnClickProp, + hasOnPressProp, hasMissingStartIconAccessibilityLabel, hasMissingClearIconAccessibilityLabel, } = extractA11yAttributesState(node, node.openingElement); @@ -148,13 +168,60 @@ export const hasValidA11yDescriptorsExtended = ruleCreator({ isTextInputWithNegativeVariant = false; } + let isComboboxWithMultiType = false; + if (getSimpleNameFromJSX(node.openingElement) === 'Combobox') { + const attributes = node.openingElement.attributes as TSESTree.JSXAttribute[]; + const typeAttribute = attributes.find((attr) => attr.name?.name === 'type'); + if (typeAttribute) { + const typeValue = typeAttribute.value; + if (typeValue && typeValue.type === AST_NODE_TYPES.Literal) { + isComboboxWithMultiType = typeValue.value === 'multi'; + } + } + } + const conditionalChecks: ConditionalCheckType[] = [ { configArray: config.componentsRequiringAccessibilityLabel, - condition: !hasAccessibilityLabel && !(hasSpreadProps || hasInnerText || hasLabel), + condition: + !hasAccessibilityLabel && + !hasAccessibilityLabelledBy && + !(hasSpreadProps || hasInnerText || hasLabel), + messageId: 'missingAccessibilityLabel', + suggestedPropToAdd: 'accessibilityLabel', + }, + { + configArray: config.checkForInteractiveAccessibilityLabelProps, + condition: + (hasOnClickProp || hasOnPressProp) && + !hasAccessibilityLabel && + !hasAccessibilityLabelledBy, messageId: 'missingAccessibilityLabel', suggestedPropToAdd: 'accessibilityLabel', }, + { + configArray: config.checkForComboboxAccessibilityLabelProps, + condition: !hasAccessibilityLabel && !hasAccessibilityLabelledBy, + messageId: 'missingAccessibleName', + suggestedPropToAdd: 'accessibilityLabel', + }, + { + configArray: config.checkForComboboxAccessibilityHintProps, + condition: !hasAccessibilityHint, + messageId: 'missingAccessibilityHint', + suggestedPropToAdd: 'accessibilityHint', + }, + { + configArray: config.checkForComboboxMultiSelectionAccessibilityLabelProps, + condition: isComboboxWithMultiType && !hasHiddenSelectedOptionsLabel, + messageId: 'missingHiddenSelectedOptionsLabel', + suggestedPropToAdd: 'hiddenSelectedOptionsLabel', + }, + { + configArray: config.checkForAccessibleNameProps, + condition: !hasAccessibilityLabel && !hasAccessibilityLabelledBy, + messageId: 'missingAccessibleName', + }, { configArray: config.checkForMissingHandleBarAccessibilityLabel, condition: !hasHandleBarAccessibilityLabelProps, @@ -167,9 +234,12 @@ export const hasValidA11yDescriptorsExtended = ruleCreator({ suggestedPropToAdd: 'helperTextErrorIconAccessibilityLabel', }, { - configArray: config.checkForCalendarIconButtonAccessibilityLabelProps, - condition: !hasCalendarIconButtonAccessibilityLabel, - messageId: 'missingCalendarIconButtonAccessibilityLabel', + configArray: config.checkForCalendarOpenCloseAccessibilityLabelProps, + condition: !( + (hasOpenCalendarAccessibilityLabel && hasCloseCalendarAccessibilityLabel) || + hasDeprecatedCalendarIconButtonAccessibilityLabel + ), + messageId: 'missingCalendarOpenCloseAccessibilityLabels', }, { // Check for presence of onDismissPress prop and absence of accessibilityLabel diff --git a/packages/eslint-plugin-cds/src/rules/mobile-chart-scrubbing-accessibility.ts b/packages/eslint-plugin-cds/src/rules/mobile-chart-scrubbing-accessibility.ts new file mode 100644 index 0000000000..77c50ae56a --- /dev/null +++ b/packages/eslint-plugin-cds/src/rules/mobile-chart-scrubbing-accessibility.ts @@ -0,0 +1,99 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { ESLintUtils } from '@typescript-eslint/utils'; + +import { getAttribute } from '../utils/getAttribute'; +import { getSimpleNameFromJSX } from '../utils/getSimpleNameFromJSX'; +import { isTruthyJSXBooleanAttribute } from '../utils/isTruthyJSXBooleanAttribute'; + +const ruleCreator = ESLintUtils.RuleCreator( + (name) => + `https://github.com/coinbase/cds/blob/master/packages/eslint-plugin-cds/README.md#${name}`, +); + +type MessageIds = 'missingChartAccessibleName' | 'missingGetScrubberAccessibilityLabel'; + +const config = { + allowedPackages: [ + '@coinbase/cds-common', + '@coinbase/cds-mobile', + '@coinbase/cds-mobile-visualization', + ], + chartComponents: ['LineChart', 'BarChart', 'CartesianChart', 'AreaChart'], +}; + +export const mobileChartScrubbingAccessibility = ruleCreator({ + name: 'mobile-chart-scrubbing-accessibility', + defaultOptions: [], + meta: { + type: 'problem', + docs: { + description: + 'Requires chart and scrubber accessibility labels when chart scrubbing is enabled on mobile charts.', + }, + messages: { + missingChartAccessibleName: + "Missing chart accessible name on <{{componentName}}>. Add 'accessibilityLabel' or 'aria-labelledby'.", + missingGetScrubberAccessibilityLabel: + "Missing 'getScrubberAccessibilityLabel' on <{{componentName}}> when scrubbing is enabled.", + }, + schema: [], + }, + create(context) { + const importedComponents: Record = {}; + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const packageName = node.source.value; + + if ( + typeof packageName === 'string' && + config.allowedPackages.some( + (pkg) => packageName === pkg || packageName.startsWith(`${pkg}/`), + ) + ) { + node.specifiers.forEach((specifier) => { + importedComponents[specifier.local.name] = packageName; + }); + } + }, + JSXElement(node) { + const componentName = getSimpleNameFromJSX(node.openingElement); + if (!componentName || !config.chartComponents.includes(componentName)) { + return; + } + + if (!importedComponents[componentName]) { + return; + } + + const attributes = node.openingElement.attributes; + const enableScrubbingAttribute = getAttribute(attributes, 'enableScrubbing'); + if (!enableScrubbingAttribute || !isTruthyJSXBooleanAttribute(enableScrubbingAttribute)) { + return; + } + + const hasAccessibilityLabel = Boolean(getAttribute(attributes, 'accessibilityLabel')); + const hasAriaLabelledBy = Boolean(getAttribute(attributes, 'aria-labelledby')); + if (!hasAccessibilityLabel && !hasAriaLabelledBy) { + context.report({ + node, + messageId: 'missingChartAccessibleName', + data: { componentName }, + }); + } + + const hasGetScrubberAccessibilityLabel = Boolean( + getAttribute(attributes, 'getScrubberAccessibilityLabel'), + ); + + if (!hasGetScrubberAccessibilityLabel) { + context.report({ + node, + messageId: 'missingGetScrubberAccessibilityLabel', + data: { componentName }, + }); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin-cds/src/rules/web-chart-scrubbing-accessibility.ts b/packages/eslint-plugin-cds/src/rules/web-chart-scrubbing-accessibility.ts new file mode 100644 index 0000000000..4b2a8c409d --- /dev/null +++ b/packages/eslint-plugin-cds/src/rules/web-chart-scrubbing-accessibility.ts @@ -0,0 +1,116 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +import { getAttribute } from '../utils/getAttribute'; +import { getSimpleNameFromJSX } from '../utils/getSimpleNameFromJSX'; +import { isTruthyJSXBooleanAttribute } from '../utils/isTruthyJSXBooleanAttribute'; + +const ruleCreator = ESLintUtils.RuleCreator( + (name) => + `https://github.com/coinbase/cds/blob/master/packages/eslint-plugin-cds/README.md#${name}`, +); + +type MessageIds = 'missingChartAccessibleName' | 'missingScrubberAccessibilityLabel'; + +const config = { + allowedPackages: ['@coinbase/cds-common', '@coinbase/cds-web-visualization', '@coinbase/cds-web'], + chartComponents: ['LineChart', 'BarChart', 'CartesianChart', 'AreaChart'], +}; + +const hasScrubberWithAccessibilityLabel = (node: TSESTree.JSXElement) => { + return node.children.some((child) => { + if (child.type !== AST_NODE_TYPES.JSXElement) { + return false; + } + + const childComponentName = getSimpleNameFromJSX(child.openingElement); + if (childComponentName !== 'Scrubber') { + return false; + } + + const scrubberAccessibilityLabelAttribute = getAttribute( + child.openingElement.attributes, + 'accessibilityLabel', + ); + + return Boolean(scrubberAccessibilityLabelAttribute); + }); +}; + +export const webChartScrubbingAccessibility = ruleCreator({ + name: 'web-chart-scrubbing-accessibility', + defaultOptions: [], + meta: { + type: 'problem', + docs: { + description: + 'Requires chart and scrubber accessibility labels when chart scrubbing is enabled on web charts.', + }, + messages: { + missingChartAccessibleName: + "Missing chart accessible name on <{{componentName}}>. Add 'accessibilityLabel' or 'aria-labelledby'.", + missingScrubberAccessibilityLabel: + "Missing scrubber accessibility label on <{{componentName}}>. Add 'getScrubberAccessibilityLabel' or a child.", + }, + schema: [], + }, + create(context) { + const importedComponents: Record = {}; + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const packageName = node.source.value; + + if ( + typeof packageName === 'string' && + config.allowedPackages.some( + (pkg) => packageName === pkg || packageName.startsWith(`${pkg}/`), + ) + ) { + node.specifiers.forEach((specifier) => { + importedComponents[specifier.local.name] = packageName; + }); + } + }, + JSXElement(node) { + const componentName = getSimpleNameFromJSX(node.openingElement); + if (!componentName || !config.chartComponents.includes(componentName)) { + return; + } + + if (!importedComponents[componentName]) { + return; + } + + const attributes = node.openingElement.attributes; + const enableScrubbingAttribute = getAttribute(attributes, 'enableScrubbing'); + if (!enableScrubbingAttribute || !isTruthyJSXBooleanAttribute(enableScrubbingAttribute)) { + return; + } + + const hasAccessibilityLabel = Boolean(getAttribute(attributes, 'accessibilityLabel')); + const hasAriaLabelledBy = Boolean(getAttribute(attributes, 'aria-labelledby')); + if (!hasAccessibilityLabel && !hasAriaLabelledBy) { + context.report({ + node, + messageId: 'missingChartAccessibleName', + data: { componentName }, + }); + } + + const hasGetScrubberAccessibilityLabel = Boolean( + getAttribute(attributes, 'getScrubberAccessibilityLabel'), + ); + + const hasChildScrubberAccessibilityLabel = hasScrubberWithAccessibilityLabel(node); + if (!hasGetScrubberAccessibilityLabel && !hasChildScrubberAccessibilityLabel) { + context.report({ + node, + messageId: 'missingScrubberAccessibilityLabel', + data: { componentName }, + }); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin-cds/src/rules/web-tooltip-interactive-content.ts b/packages/eslint-plugin-cds/src/rules/web-tooltip-interactive-content.ts new file mode 100644 index 0000000000..096bfffd6f --- /dev/null +++ b/packages/eslint-plugin-cds/src/rules/web-tooltip-interactive-content.ts @@ -0,0 +1,187 @@ +import type { TSESTree } from '@typescript-eslint/utils'; +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'; + +import { getSimpleNameFromJSX } from '../utils/getSimpleNameFromJSX'; +import { isTruthyJSXBooleanAttribute } from '../utils/isTruthyJSXBooleanAttribute'; + +const ruleCreator = ESLintUtils.RuleCreator( + (name) => + `https://github.com/coinbase/cds/blob/master/packages/eslint-plugin-cds/README.md#${name}`, +); + +type MessageIds = 'missingHasInteractiveContent'; + +const config = { + allowedPackages: ['@coinbase/cds-common', '@coinbase/cds-web'], + tooltipComponents: ['Tooltip'], + interactiveElementNames: [ + 'a', + 'button', + 'input', + 'select', + 'textarea', + 'Button', + 'IconButton', + 'Pressable', + 'Link', + 'Text', + ], +}; + +const hasInteractiveAttributes = (attributes: TSESTree.JSXOpeningElement['attributes']) => { + return attributes.some((attribute) => { + if (attribute.type !== AST_NODE_TYPES.JSXAttribute) { + return false; + } + + const attributeName = attribute.name.name; + return attributeName === 'onClick' || attributeName === 'onPress' || attributeName === 'href'; + }); +}; + +const isInteractiveJSXNode = ( + node: TSESTree.JSXElement | TSESTree.JSXFragment | TSESTree.Expression, +): boolean => { + if (node.type === AST_NODE_TYPES.JSXElement) { + const elementName = getSimpleNameFromJSX(node.openingElement); + if (elementName && config.interactiveElementNames.includes(elementName)) { + return true; + } + + if (hasInteractiveAttributes(node.openingElement.attributes)) { + return true; + } + + return node.children.some((child) => { + if (child.type === AST_NODE_TYPES.JSXElement || child.type === AST_NODE_TYPES.JSXFragment) { + return isInteractiveJSXNode(child); + } + if (child.type === AST_NODE_TYPES.JSXExpressionContainer) { + const childExpression = child.expression; + if ( + childExpression.type === AST_NODE_TYPES.JSXElement || + childExpression.type === AST_NODE_TYPES.JSXFragment + ) { + return isInteractiveJSXNode(childExpression); + } + } + return false; + }); + } + + if (node.type === AST_NODE_TYPES.JSXFragment) { + return node.children.some((child) => { + if (child.type === AST_NODE_TYPES.JSXElement || child.type === AST_NODE_TYPES.JSXFragment) { + return isInteractiveJSXNode(child); + } + if (child.type === AST_NODE_TYPES.JSXExpressionContainer) { + const childExpression = child.expression; + if ( + childExpression.type === AST_NODE_TYPES.JSXElement || + childExpression.type === AST_NODE_TYPES.JSXFragment + ) { + return isInteractiveJSXNode(childExpression); + } + } + return false; + }); + } + + return false; +}; + +export const webTooltipInteractiveContent = ruleCreator({ + name: 'web-tooltip-interactive-content', + defaultOptions: [], + meta: { + type: 'problem', + docs: { + description: + 'Requires hasInteractiveContent when Tooltip content contains interactive elements.', + }, + messages: { + missingHasInteractiveContent: `Missing 'hasInteractiveContent' on <{{componentName}}> when tooltip content is interactive.`, + }, + schema: [], + }, + create(context) { + const importedComponents: Record = {}; + + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + const packageName = node.source.value; + + if ( + typeof packageName === 'string' && + config.allowedPackages.some( + (pkg) => packageName === pkg || packageName.startsWith(`${pkg}/`), + ) + ) { + node.specifiers.forEach((specifier) => { + importedComponents[specifier.local.name] = packageName; + }); + } + }, + JSXElement(node) { + const componentName = getSimpleNameFromJSX(node.openingElement); + if (!componentName || !config.tooltipComponents.includes(componentName)) { + return; + } + + if (!importedComponents[componentName]) { + return; + } + + const attributes = node.openingElement.attributes; + const contentAttribute = attributes.find( + (attribute): attribute is TSESTree.JSXAttribute => + attribute.type === AST_NODE_TYPES.JSXAttribute && attribute.name.name === 'content', + ); + + if (!contentAttribute || !contentAttribute.value) { + return; + } + + const hasInteractiveContentAttribute = attributes.some((attribute) => { + if ( + attribute.type !== AST_NODE_TYPES.JSXAttribute || + attribute.name.name !== 'hasInteractiveContent' + ) { + return false; + } + return isTruthyJSXBooleanAttribute(attribute); + }); + + if (hasInteractiveContentAttribute) { + return; + } + + if (contentAttribute.value.type === AST_NODE_TYPES.Literal) { + return; + } + + if (contentAttribute.value.type === AST_NODE_TYPES.JSXExpressionContainer) { + const expression = contentAttribute.value.expression; + + if ( + expression.type !== AST_NODE_TYPES.JSXElement && + expression.type !== AST_NODE_TYPES.JSXFragment + ) { + return; + } + + const isInteractive = isInteractiveJSXNode(expression); + if (!isInteractive) { + return; + } + + context.report({ + node, + messageId: 'missingHasInteractiveContent', + data: { componentName }, + }); + } + }, + }; + }, +}); diff --git a/packages/eslint-plugin-cds/src/templates/custom-rule.test.ts b/packages/eslint-plugin-cds/src/templates/custom-rule.test.ts new file mode 100644 index 0000000000..d75a3481c0 --- /dev/null +++ b/packages/eslint-plugin-cds/src/templates/custom-rule.test.ts @@ -0,0 +1,43 @@ +/** + * Template for testing an ESLint rule. + * + * To use this template: + * 1. Copy this file and rename it to match your rule (e.g. `no-foo-bar.test.ts`). + * 2. Update the import to point to your rule. + * 3. Add valid and invalid test cases. + * + * Valid cases: code that should NOT trigger the rule. + * Invalid cases: code that SHOULD trigger the rule, with expected error messageIds. + */ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { customRule } from './custom-rule'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +// @ts-expect-error - not sure why the rule type is not matching up with the rule tester +ruleTester.run('custom-rule', customRule, { + valid: [ + { + code: 'const baz = 1;', + }, + ], + invalid: [ + { + code: 'const foo = 1;', + errors: [{ messageId: 'messageOne' }], + }, + { + code: 'const bar = 1;', + errors: [{ messageId: 'messageTwo' }], + }, + ], +}); diff --git a/packages/eslint-plugin-cds/src/templates/custom-rule.ts b/packages/eslint-plugin-cds/src/templates/custom-rule.ts new file mode 100644 index 0000000000..37d8dca5c5 --- /dev/null +++ b/packages/eslint-plugin-cds/src/templates/custom-rule.ts @@ -0,0 +1,78 @@ +/** + * Template for creating a new ESLint rule. + * + * To use this template: + * 1. Copy this file and rename it to match your rule (e.g. `no-foo-bar.ts`). + * 2. Update the exported constant name, MessageIds, and rule logic. + * 3. Register the rule in `src/rules.ts`. + * 4. Add the rule to the appropriate config in `src/configs/`. + * 5. Write tests in `tests/.test.ts` (see `tests/custom-rule.test.ts` for a template). + * 6. Document the rule in the README. + * + * Alternatively, run the generator script: + * yarn node packages/eslint-plugin-cds/scripts/scaffold-new-rule.mjs + */ +import { type TSESLint } from '@typescript-eslint/utils'; + +/** + * Union type of all message IDs this rule can report. + * Each key must have a corresponding entry in `meta.messages`. + */ +type MessageIds = 'messageOne' | 'messageTwo'; + +export const customRule: TSESLint.RuleModule = { + defaultOptions: [], + meta: { + /** + * Rule type: + * - 'problem': the rule identifies code that will cause an error or unexpected behavior + * - 'suggestion': the rule identifies code that could be improved but won't cause errors + * - 'layout': the rule enforces stylistic conventions (whitespace, semicolons, etc.) + */ + type: 'problem', + docs: { + description: 'A short description of what this rule enforces', + }, + messages: { + messageOne: 'First error message shown to the user', + messageTwo: 'Second error message shown to the user', + }, + /** + * Set to 'code' if the rule provides automatic fixes via `context.report({ fix })`. + * Set to undefined/remove if the rule does not provide fixes. + */ + fixable: 'code', + schema: [], + }, + create(context) { + return { + /** + * AST visitor methods. The method name corresponds to an AST node type. + * Use https://astexplorer.net/ (with @typescript-eslint/parser) to find + * the right node types for the code patterns you want to lint. + * + * Common node types: + * - ImportDeclaration: import statements + * - JSXOpeningElement: JSX tags like
    + Accounts table + +
    + ); + } +`; + const valid = [ validButtonWithInnerText, validButtonWithCorrectLabel, validButtonWithNestedInnerText, validButtonWithNestedExpression, + validModalHeaderWithTitle, + validComboboxWithRequiredA11yProps, + validTableWithCaption, ]; // @ts-expect-error - not sure why the rule type is not matching up with the rule tester @@ -74,7 +113,7 @@ ruleTester.run('control-has-associated-label-extended', rule, { `, errors: [ { - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -103,7 +142,7 @@ ruleTester.run('control-has-associated-label-extended', rule, { `, errors: [ { - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -135,7 +174,7 @@ ruleTester.run('control-has-associated-label-extended', rule, { errors: [ { // error on Button element - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -154,7 +193,7 @@ ruleTester.run('control-has-associated-label-extended', rule, { }, { // error on IconButton element - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -173,5 +212,117 @@ ruleTester.run('control-has-associated-label-extended', rule, { }, ], }, + // SegmentedTabs requires accessibilityLabel + { + code: normalizeIndent` + import { SegmentedTabs } from '@coinbase/cds-web'; + const tabs = [{ id: 'buy', label: 'Buy' }]; + const Component = () => { + return {}} tabs={tabs} />; + } + `, + errors: [ + { + messageId: 'missingAccessibilityLabel', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { SegmentedTabs } from '@coinbase/cds-web'; + const tabs = [{ id: 'buy', label: 'Buy' }]; + const Component = () => { + return {}} tabs={tabs} />; + } + `, + }, + ], + }, + ], + }, + // ModalHeader with back action requires backAccessibilityLabel + { + code: normalizeIndent` + import { ModalHeader } from '@coinbase/cds-web'; + const Component = () => { + return ( + {}} title="Title" /> + ); + } +`, + errors: [ + { + messageId: 'missingBackAccessibilityLabel', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { ModalHeader } from '@coinbase/cds-web'; + const Component = () => { + return ( + {}} title="Title" /> + ); + } + `, + }, + ], + }, + ], + }, + // Combobox requires accessible name and controlAccessibilityLabel + { + code: normalizeIndent` + import { Combobox } from '@coinbase/cds-web'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return {}} options={options} />; + } + `, + errors: [ + { + messageId: 'missingAccessibleName', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { Combobox } from '@coinbase/cds-web'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return {}} options={options} />; + } + `, + }, + ], + }, + { + messageId: 'missingControlAccessibilityLabel', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { Combobox } from '@coinbase/cds-web'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return {}} options={options} />; + } + `, + }, + ], + }, + ], + }, + // Table requires caption or accessible name props + { + code: normalizeIndent` + import { Table } from '@coinbase/cds-web'; + const Component = () => { + return ( + + +
    + ); + } + `, + errors: [{ messageId: 'missingTableAccessibleName' }], + }, ], }); diff --git a/packages/eslint-plugin-cds/tests/has-valid-accessibility-descriptors-extended.test.ts b/packages/eslint-plugin-cds/tests/has-valid-accessibility-descriptors-extended.test.ts index 8175c41b5c..a5b3a0a4e3 100644 --- a/packages/eslint-plugin-cds/tests/has-valid-accessibility-descriptors-extended.test.ts +++ b/packages/eslint-plugin-cds/tests/has-valid-accessibility-descriptors-extended.test.ts @@ -50,11 +50,35 @@ const validButtonWithNestedExpression = ` } `; +const validComboboxWithRequiredA11yProps = ` + import { Combobox } from '@coinbase/cds-mobile'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return ( + {}} + options={options} + /> + ); + } +`; + +const validTrayWithA11yProps = ` + import { Tray } from '@coinbase/cds-mobile'; + const Component = () => { + return ; + }; +`; + const valid = [ validButtonWithInnerText, validButtonWithCorrectLabel, validButtonWithNestedInnerText, validButtonWithNestedExpression, + validComboboxWithRequiredA11yProps, + validTrayWithA11yProps, ]; // @ts-expect-error - not sure why the rule type is not matching up with the rule tester @@ -73,7 +97,7 @@ ruleTester.run('has-valid-accessibility-descriptors-extended', rule, { `, errors: [ { - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -102,7 +126,7 @@ ruleTester.run('has-valid-accessibility-descriptors-extended', rule, { `, errors: [ { - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -134,7 +158,7 @@ ruleTester.run('has-valid-accessibility-descriptors-extended', rule, { errors: [ { // error on Button element - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -153,7 +177,7 @@ ruleTester.run('has-valid-accessibility-descriptors-extended', rule, { }, { // error on IconButton element - messageId: 'missingAccessibilityLabel' as const, + messageId: 'missingAccessibilityLabel', suggestions: [ { messageId: 'missingAccessibilityLabelSuggestion', @@ -172,5 +196,101 @@ ruleTester.run('has-valid-accessibility-descriptors-extended', rule, { }, ], }, + // Chip with onPress requires accessibilityLabel + { + code: normalizeIndent` + import { Chip } from '@coinbase/cds-mobile'; + const Component = () => { + return {}}>BTC; + } + `, + errors: [ + { + messageId: 'missingAccessibilityLabel', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { Chip } from '@coinbase/cds-mobile'; + const Component = () => { + return {}}>BTC; + } + `, + }, + ], + }, + ], + }, + // SegmentedTabs requires accessibilityLabel + { + code: normalizeIndent` + import { SegmentedTabs } from '@coinbase/cds-mobile'; + const tabs = [{ id: 'buy', label: 'Buy' }]; + const Component = () => { + return {}} tabs={tabs} />; + } + `, + errors: [ + { + messageId: 'missingAccessibilityLabel', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { SegmentedTabs } from '@coinbase/cds-mobile'; + const tabs = [{ id: 'buy', label: 'Buy' }]; + const Component = () => { + return {}} tabs={tabs} />; + } + `, + }, + ], + }, + ], + }, + // Combobox requires accessibilityHint + { + code: normalizeIndent` + import { Combobox } from '@coinbase/cds-mobile'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return ( + {}} options={options} /> + ); + } + `, + errors: [ + { + messageId: 'missingAccessibilityHint', + suggestions: [ + { + messageId: 'missingAccessibilityLabelSuggestion', + output: normalizeIndent` + import { Combobox } from '@coinbase/cds-mobile'; + const options = [{ value: 'a', label: 'A' }]; + const Component = () => { + return ( + {}} options={options} /> + ); + } + `, + }, + ], + }, + ], + }, + // Tray requires accessible name + { + code: normalizeIndent` + import { Tray } from '@coinbase/cds-mobile'; + const Component = () => { + return ; + } + `, + errors: [ + { messageId: 'missingAccessibleName' }, + { messageId: 'missingHandleBarAccessibilityLabel' }, + ], + }, ], }); diff --git a/packages/eslint-plugin-cds/tests/mobile-chart-scrubbing-accessibility.test.ts b/packages/eslint-plugin-cds/tests/mobile-chart-scrubbing-accessibility.test.ts new file mode 100644 index 0000000000..f1f1979bc4 --- /dev/null +++ b/packages/eslint-plugin-cds/tests/mobile-chart-scrubbing-accessibility.test.ts @@ -0,0 +1,84 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { mobileChartScrubbingAccessibility as rule } from '../src/rules/mobile-chart-scrubbing-accessibility'; + +import { normalizeIndent } from './normalizeIndent'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +// @ts-expect-error RuleTester types mismatch in current setup +ruleTester.run('mobile-chart-scrubbing-accessibility', rule, { + valid: [ + normalizeIndent` + import { LineChart } from '@coinbase/cds-mobile-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + ); + } + `, + normalizeIndent` + import { LineChart } from '@coinbase/cds-mobile-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + ); + } + `, + normalizeIndent` + import { LineChart } from '@coinbase/cds-mobile-visualization'; + const Component = () => { + return ; + } + `, + ], + invalid: [ + { + code: normalizeIndent` + import { LineChart } from '@coinbase/cds-mobile-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + ); + } + `, + errors: [{ messageId: 'missingChartAccessibleName' as const }], + }, + { + code: normalizeIndent` + import { LineChart } from '@coinbase/cds-mobile-visualization'; + const Component = () => { + return ( + + ); + } + `, + errors: [{ messageId: 'missingGetScrubberAccessibilityLabel' as const }], + }, + ], +}); diff --git a/packages/eslint-plugin-cds/tests/web-chart-scrubbing-accessibility.test.ts b/packages/eslint-plugin-cds/tests/web-chart-scrubbing-accessibility.test.ts new file mode 100644 index 0000000000..d338796838 --- /dev/null +++ b/packages/eslint-plugin-cds/tests/web-chart-scrubbing-accessibility.test.ts @@ -0,0 +1,82 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { webChartScrubbingAccessibility as rule } from '../src/rules/web-chart-scrubbing-accessibility'; + +import { normalizeIndent } from './normalizeIndent'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +// @ts-expect-error RuleTester types mismatch in current setup +ruleTester.run('web-chart-scrubbing-accessibility', rule, { + valid: [ + normalizeIndent` + import { LineChart } from '@coinbase/cds-web-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + ); + } + `, + normalizeIndent` + import { LineChart, Scrubber } from '@coinbase/cds-web-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + + + ); + } + `, + normalizeIndent` + import { LineChart } from '@coinbase/cds-web-visualization'; + const Component = () => { + return ; + } + `, + ], + invalid: [ + { + code: normalizeIndent` + import { LineChart } from '@coinbase/cds-web-visualization'; + const getScrubberAccessibilityLabel = (index) => String(index); + const Component = () => { + return ( + + ); + } + `, + errors: [{ messageId: 'missingChartAccessibleName' as const }], + }, + { + code: normalizeIndent` + import { LineChart } from '@coinbase/cds-web-visualization'; + const Component = () => { + return ( + + ); + } + `, + errors: [{ messageId: 'missingScrubberAccessibilityLabel' as const }], + }, + ], +}); diff --git a/packages/eslint-plugin-cds/tests/web-tooltip-interactive-content.test.ts b/packages/eslint-plugin-cds/tests/web-tooltip-interactive-content.test.ts new file mode 100644 index 0000000000..2cbee2dd73 --- /dev/null +++ b/packages/eslint-plugin-cds/tests/web-tooltip-interactive-content.test.ts @@ -0,0 +1,59 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { webTooltipInteractiveContent as rule } from '../src/rules/web-tooltip-interactive-content'; + +import { normalizeIndent } from './normalizeIndent'; + +const ruleTester = new RuleTester({ + languageOptions: { + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +// @ts-expect-error RuleTester types mismatch in current setup +ruleTester.run('web-tooltip-interactive-content', rule, { + valid: [ + normalizeIndent` + import { Tooltip, Icon } from '@coinbase/cds-web'; + const Component = () => { + return ( + + + + ); + } + `, + normalizeIndent` + import { Tooltip, Icon, Button } from '@coinbase/cds-web'; + const Component = () => { + return ( + {}}>Action} + hasInteractiveContent + > + + + ); + } + `, + ], + invalid: [ + { + code: normalizeIndent` + import { Tooltip, Icon, Button } from '@coinbase/cds-web'; + const Component = () => { + return ( + {}}>Action}> + + + ); + } + `, + errors: [{ messageId: 'missingHasInteractiveContent' as const }], + }, + ], +}); diff --git a/packages/icons/CHANGELOG.md b/packages/icons/CHANGELOG.md index a18b66493e..703645463a 100644 --- a/packages/icons/CHANGELOG.md +++ b/packages/icons/CHANGELOG.md @@ -8,6 +8,80 @@ All notable changes to this project will be documented in this file. +## 5.15.0 (4/16/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026-04-16. [[#619](https://github.com/coinbase/cds/pull/619)] + +##### ⭐️ Added (1) + +- baseLock + +## 5.14.0 (4/8/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026-04-08. [[#596](https://github.com/coinbase/cds/pull/596)] + +##### ⭐️ Added (3) + +- overPredictions +- column +- underPredictions + +##### ⭐️ Updated (1) + +- usdc + +## 5.13.0 (3/11/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026-03-11. [[#496](https://github.com/coinbase/cds/pull/496)] + +##### ⭐️ Updated (1) + +- ideal + +## 5.12.0 (3/2/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026-03-03. + +##### ⭐️ Added (3) + +- usdc +- filterLineStack +- pieChartWithArrow + +## 5.11.0 (2/5/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026/02/025. [[#367](https://github.com/coinbase/cds/pull/367)] + +##### ⭐️ Added (2) + +- autoCar +- webhooks + +## 5.10.0 (1/29/2026 PST) + +#### 🚀 Updates + +- Feat: Publish icons 2026-01-29. [[#342](https://github.com/coinbase/cds/pull/342)] + +##### ⭐️ Added (1) + +- birthcertificate + +##### ⭐️ Updated (1) + +- smartContract +- pencil + ## 5.9.0 (12/22/2025 PST) #### 🚀 Updates diff --git a/packages/icons/README.md b/packages/icons/README.md index ccd9ee89b0..4cd6c52de4 100644 --- a/packages/icons/README.md +++ b/packages/icons/README.md @@ -8,48 +8,6 @@ CDS icons used in @coinbase/cds-web and @coinbase/cds-mobile. yarn add @coinbase/cds-icons ``` -## Contributing +## Icons -### Figma Links - -- [CDS Icon Figma components](https://www.figma.com/file/1J3XC4iA2xRzlnC3y0pl1N/%E2%9D%8C-Icons?node-id=513%3A3971&t=ctB9WBiSSu6wOe3o-0) - -### Releasing Icons - -1. Sync the latest Figma icon components - -```shell -yarn nx run icons:release -``` - -- **IMPORTANT:** If any icons are renamed or deleted, this update will be a breaking change for consumers. Please ensure that you publish a migration guide and a migrator script along with this release to aid consumers with migration. - -2. Commit the changes with a message in the following format: `feat: Publish icons mm/dd/yyyy` - -3. Open a PR with the changes - -4. Bump the package version and update the changelog - -```shell -yarn changelog icons -``` - -- When prompted, do the following: - - Type of change?: "Update" or "Breaking" - - Changelog message?: Copy/paste your PR title (just the part after `feat:`) - - PR number?: Copy/paste your PR number - - Skip the rest (press enter to use defaults) - -5. Run `yarn release` to generate website changelogs. - -6. Commit and push the changes to your existing PR - -7. When the Percy diffs are ready, share them with the Icons DRI for approval. Merge your PR once the DRI has signed off. - - - -9. After the deploy has succeeded, verify that the new package was published at the [production Coinbase NPM registry](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master). It usually takes about 10 min or so for the package to be uploaded. Look for the version number at the bottom of the artifact list in the [package directory](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master/@coinbase/cds-icons/-/@coinbase/cds-icons-1.0.0.tgz). - -### Gotchas - -- You may see the task complete without any changes and the message: "There are no changes since the last update on XX/XX/XXXX". Verify this is expected with design. +Browse all available icons at [cds.coinbase.com/components/media/Icon/#icons](https://cds.coinbase.com/components/media/Icon/#icons). diff --git a/packages/icons/manifest.json b/packages/icons/manifest.json index 7470130807..8a66df81ed 100644 --- a/packages/icons/manifest.json +++ b/packages/icons/manifest.json @@ -1,6 +1,6 @@ { - "lastUpdated": "2025-12-22T20:44:30.548Z", - "lastUnicode": 986214, + "lastUpdated": "2026-04-16T18:23:36.872Z", + "lastUnicode": 986274, "iconSets": [ { "nodeId": "4:39245", @@ -2216,16 +2216,10 @@ "name": "pencil", "description": "navigation, specialty, pencil, pen, edit, correct, check, copyedit, improve, ✏️, 📝, ✍️, 🖊, 🖋, ✒️", "assetsHash": "33x4KS/CbHB1G9/nh9WCsjS9eiDx6XikagJfGd08Fkg=", - "nameHash": "cZWJTLy+wvgZcFzpEu4vQKzCopOGXLrk9GZzEEjrk3g=", + "nameHash": "h5300mFrD63IQI560kgN/K3ArwbutOv0WZXF4KP1EBg=", "createdAt": "2022-08-24T18:51:39.775Z", - "lastUpdated": "2025-03-27T17:57:32.300Z", + "lastUpdated": "2026-01-08T15:01:50.673Z", "svgs": [ - { - "name": "pencil-24-active", - "active": true, - "size": 24, - "unicode": 984677 - }, { "name": "pencil-24-inactive", "active": false, @@ -2233,10 +2227,10 @@ "unicode": 984678 }, { - "name": "pencil-16-active", + "name": "pencil-24-active", "active": true, - "size": 16, - "unicode": 984675 + "size": 24, + "unicode": 984677 }, { "name": "pencil-16-inactive", @@ -2245,16 +2239,22 @@ "unicode": 984676 }, { - "name": "pencil-12-active", + "name": "pencil-16-active", "active": true, - "size": 12, - "unicode": 984673 + "size": 16, + "unicode": 984675 }, { "name": "pencil-12-inactive", "active": false, "size": 12, "unicode": 984674 + }, + { + "name": "pencil-12-active", + "active": true, + "size": 12, + "unicode": 984673 } ] }, @@ -5223,10 +5223,10 @@ "nodeId": "2:3416", "name": "smartContract", "description": "smart, contract, rules, policy, list, document, agreement, commitment, arrangement, settlement, 📄, 📃, 📜, 📑", - "assetsHash": "jMypW2xEkVpePL5CmFCv19dg3qV4wdJ5bI4GGi083tM=", + "assetsHash": "RbNA2z+isVBAJ06GjHE/+bYNR7bG5DJ9LM3+ISg1Aew=", "nameHash": "P7jrllgJaoAiUZc5ndGehVd8Znkn8amnUgeIXO1Ykiw=", "createdAt": "2023-02-01T23:25:12.874Z", - "lastUpdated": "2025-03-27T17:57:57.641Z", + "lastUpdated": "2026-01-08T14:56:51.147Z", "svgs": [ { "name": "smartContract-24-inactive", @@ -12414,10 +12414,10 @@ "nodeId": "2:10870", "name": "ideal", "description": "ideal, payment, brand, 💳, 🏦", - "assetsHash": "9dJCtJmYS7OGkJURgZ7c/2tiEhmMpK5fcgCHvTyqdjQ=", + "assetsHash": "eMaVLzhVddY2Ts+3iYYqjZJXajPA8wtvz08Jpq6K2UQ=", "nameHash": "8P2wMnQgCJ/+aALN46L2CYaff9t7gNRyC4vE1gJ9ATU=", "createdAt": "2023-02-01T23:26:07.623Z", - "lastUpdated": "2025-03-27T17:59:18.118Z", + "lastUpdated": "2026-03-11T15:15:59.036Z", "svgs": [ { "name": "ideal-24-inactive", @@ -24864,6 +24864,476 @@ "unicode": 985651 } ] + }, + { + "nodeId": "6557:39", + "name": "birthcertificate", + "description": "birthcertificate, birth, certificate, doc, document", + "assetsHash": "pSSAYL6SMRBoZRG/apZhIGV2E2NLe2sMH/p7NRC1jIk=", + "nameHash": "0k8lRyDUS6pbSp+cGFHOCg+5FH49CM1wGWEcxUoH8vg=", + "createdAt": "2026-01-29T17:18:54.491Z", + "lastUpdated": "2026-01-29T17:18:54.491Z", + "svgs": [ + { + "name": "birthcertificate-24-inactive", + "active": false, + "size": 24, + "unicode": 986220 + }, + { + "name": "birthcertificate-24-active", + "active": true, + "size": 24, + "unicode": 986219 + }, + { + "name": "birthcertificate-16-inactive", + "active": false, + "size": 16, + "unicode": 986218 + }, + { + "name": "birthcertificate-16-active", + "active": true, + "size": 16, + "unicode": 986217 + }, + { + "name": "birthcertificate-12-inactive", + "active": false, + "size": 12, + "unicode": 986216 + }, + { + "name": "birthcertificate-12-active", + "active": true, + "size": 12, + "unicode": 986215 + } + ] + }, + { + "nodeId": "6526:53", + "name": "autoCar", + "description": "auto, car,", + "assetsHash": "bMfbTdJUwXnpiiqMvIjYpyCw7QxbMWNyBK+B3bqwA9Y=", + "nameHash": "BwOSLRmxDhJuS9NZl1Me074ujmdKxSaKr6o3evL/nrI=", + "createdAt": "2026-01-29T17:18:54.501Z", + "lastUpdated": "2026-01-29T18:56:45.027Z", + "svgs": [ + { + "name": "autoCar-24-inactive", + "active": false, + "size": 24, + "unicode": 986226 + }, + { + "name": "autoCar-24-active", + "active": true, + "size": 24, + "unicode": 986225 + }, + { + "name": "autoCar-16-inactive", + "active": false, + "size": 16, + "unicode": 986224 + }, + { + "name": "autoCar-16-active", + "active": true, + "size": 16, + "unicode": 986223 + }, + { + "name": "autoCar-12-inactive", + "active": false, + "size": 12, + "unicode": 986222 + }, + { + "name": "autoCar-12-active", + "active": true, + "size": 12, + "unicode": 986221 + } + ] + }, + { + "nodeId": "6644:128", + "name": "webhooks", + "description": "webhooks, web, hooks", + "assetsHash": "NJdDS1+xfmexx73z1PkkfqCaQdc6ovbSoOTtoJ5I+ek=", + "nameHash": "iS+bX5hbIyYVUtDPD7Zj5ON7x4JYHOMRIrio70PzH1I=", + "createdAt": "2026-02-04T19:51:39.861Z", + "lastUpdated": "2026-02-04T19:51:39.861Z", + "svgs": [ + { + "name": "webhooks-24-inactive", + "active": false, + "size": 24, + "unicode": 986232 + }, + { + "name": "webhooks-24-active", + "active": true, + "size": 24, + "unicode": 986231 + }, + { + "name": "webhooks-16-inactive", + "active": false, + "size": 16, + "unicode": 986230 + }, + { + "name": "webhooks-16-active", + "active": true, + "size": 16, + "unicode": 986229 + }, + { + "name": "webhooks-12-inactive", + "active": false, + "size": 12, + "unicode": 986228 + }, + { + "name": "webhooks-12-active", + "active": true, + "size": 12, + "unicode": 986227 + } + ] + }, + { + "nodeId": "6796:434", + "name": "usdc", + "description": "usdc, fixed, cost, coin", + "assetsHash": "ti/zDelLoRMg0aG8aheAaautuXCqd4+35cej4sBL2pg=", + "nameHash": "unyR+X6Yjg/+8ELuMq3RljOPpo+LRmiVMNevUxTlCeE=", + "createdAt": "2026-03-02T18:06:44.854Z", + "lastUpdated": "2026-04-06T17:50:41.493Z", + "svgs": [ + { + "name": "usdc-24-inactive", + "active": false, + "size": 24, + "unicode": 986250 + }, + { + "name": "usdc-24-active", + "active": true, + "size": 24, + "unicode": 986249 + }, + { + "name": "usdc-16-inactive", + "active": false, + "size": 16, + "unicode": 986248 + }, + { + "name": "usdc-16-active", + "active": true, + "size": 16, + "unicode": 986247 + }, + { + "name": "usdc-12-inactive", + "active": false, + "size": 12, + "unicode": 986246 + }, + { + "name": "usdc-12-active", + "active": true, + "size": 12, + "unicode": 986245 + } + ] + }, + { + "nodeId": "6796:408", + "name": "pieChartWithArrow", + "description": "transferStocks, transfer, stocks, piechart", + "assetsHash": "kaoW4x2PNjcALMyBAvuCRnYT3hXxEcsgX1cTw7hV4No=", + "nameHash": "+gLfOLV99/xH8Hf/rjXynjMVR/2XY+EB/Mo0ncjnrrI=", + "createdAt": "2026-03-02T18:06:44.864Z", + "lastUpdated": "2026-03-03T01:34:26.828Z", + "svgs": [ + { + "name": "pieChartWithArrow-24-inactive", + "active": false, + "size": 24, + "unicode": 986244 + }, + { + "name": "pieChartWithArrow-24-active", + "active": true, + "size": 24, + "unicode": 986243 + }, + { + "name": "pieChartWithArrow-16-inactive", + "active": false, + "size": 16, + "unicode": 986242 + }, + { + "name": "pieChartWithArrow-16-active", + "active": true, + "size": 16, + "unicode": 986241 + }, + { + "name": "pieChartWithArrow-12-inactive", + "active": false, + "size": 12, + "unicode": 986240 + }, + { + "name": "pieChartWithArrow-12-active", + "active": true, + "size": 12, + "unicode": 986239 + } + ] + }, + { + "nodeId": "6796:421", + "name": "filterLineStack", + "description": "filter, stack, line", + "assetsHash": "qZ2pAAKMVeCQoWlc588TtPpVZUdZGWdy0cb9I32oz68=", + "nameHash": "Z7HqdZDmuR7Es9/jq1DDdP8rZX0ICKqtfSKFwMadWIc=", + "createdAt": "2026-03-02T18:06:44.869Z", + "lastUpdated": "2026-03-02T18:06:44.869Z", + "svgs": [ + { + "name": "filterLineStack-24-inactive", + "active": false, + "size": 24, + "unicode": 986238 + }, + { + "name": "filterLineStack-24-active", + "active": true, + "size": 24, + "unicode": 986237 + }, + { + "name": "filterLineStack-16-inactive", + "active": false, + "size": 16, + "unicode": 986236 + }, + { + "name": "filterLineStack-16-active", + "active": true, + "size": 16, + "unicode": 986235 + }, + { + "name": "filterLineStack-12-inactive", + "active": false, + "size": 12, + "unicode": 986234 + }, + { + "name": "filterLineStack-12-active", + "active": true, + "size": 12, + "unicode": 986233 + } + ] + }, + { + "nodeId": "7488:450", + "name": "overPredictions", + "description": "over, predictions, arrow", + "assetsHash": "6JXNTnCHexanTxBEw+Mhwism4EzKfiGt8JFQDSPsyUo=", + "nameHash": "WF0yddy9uaEjWsExIkMQz8tX97jw7ViRMh6WUsS2DO0=", + "createdAt": "2026-04-06T17:50:41.459Z", + "lastUpdated": "2026-04-06T17:50:41.459Z", + "svgs": [ + { + "name": "overPredictions-24-inactive", + "active": false, + "size": 24, + "unicode": 986262 + }, + { + "name": "overPredictions-24-active", + "active": true, + "size": 24, + "unicode": 986261 + }, + { + "name": "overPredictions-16-inactive", + "active": false, + "size": 16, + "unicode": 986260 + }, + { + "name": "overPredictions-16-active", + "active": true, + "size": 16, + "unicode": 986259 + }, + { + "name": "overPredictions-12-inactive", + "active": false, + "size": 12, + "unicode": 986258 + }, + { + "name": "overPredictions-12-active", + "active": true, + "size": 12, + "unicode": 986257 + } + ] + }, + { + "nodeId": "7480:86", + "name": "column", + "description": "column, editing,", + "assetsHash": "1CvGCEdw1Ox3Q7IklXQ/OleTrJmdY/6rHc16XC+Hi3k=", + "nameHash": "kyaXGumpcmxI0yVU70wFgV78BjbsNVDOj2xpHtm/74g=", + "createdAt": "2026-04-06T17:50:41.467Z", + "lastUpdated": "2026-04-06T17:50:41.467Z", + "svgs": [ + { + "name": "column-24-inactive", + "active": false, + "size": 24, + "unicode": 986256 + }, + { + "name": "column-24-active", + "active": true, + "size": 24, + "unicode": 986255 + }, + { + "name": "column-16-inactive", + "active": false, + "size": 16, + "unicode": 986254 + }, + { + "name": "column-16-active", + "active": true, + "size": 16, + "unicode": 986253 + }, + { + "name": "column-12-inactive", + "active": false, + "size": 12, + "unicode": 986252 + }, + { + "name": "column-12-active", + "active": true, + "size": 12, + "unicode": 986251 + } + ] + }, + { + "nodeId": "7488:425", + "name": "underPredictions", + "description": "under, predictions, arrow", + "assetsHash": "XcBreC1xuGfEkgKtr37zeD2a+3quGjTD9G0Ms+VT8Qw=", + "nameHash": "5pcAqvWVi0lZaF6WwGGwi1Evuxqha4tPYToU8Bg1M0E=", + "createdAt": "2026-04-06T17:50:41.471Z", + "lastUpdated": "2026-04-06T17:50:41.471Z", + "svgs": [ + { + "name": "underPredictions-24-inactive", + "active": false, + "size": 24, + "unicode": 986268 + }, + { + "name": "underPredictions-24-active", + "active": true, + "size": 24, + "unicode": 986267 + }, + { + "name": "underPredictions-16-inactive", + "active": false, + "size": 16, + "unicode": 986266 + }, + { + "name": "underPredictions-16-active", + "active": true, + "size": 16, + "unicode": 986265 + }, + { + "name": "underPredictions-12-inactive", + "active": false, + "size": 12, + "unicode": 986264 + }, + { + "name": "underPredictions-12-active", + "active": true, + "size": 12, + "unicode": 986263 + } + ] + }, + { + "nodeId": "7528:276", + "name": "baseLock", + "description": "lock, no access, latch, blocked, 🔒, 🔐, 🔑, 🗝", + "assetsHash": "E5JXsgGevW1u7IDIzj1UNa5a/ktGcAD65+e4weds7O8=", + "nameHash": "2dm2KfcTXzWGTd/LFCfwJ90DuAWwL0b77qSEZgNujUA=", + "createdAt": "2026-04-15T16:50:05.216Z", + "lastUpdated": "2026-04-15T16:50:05.216Z", + "svgs": [ + { + "name": "baseLock-24-inactive", + "active": false, + "size": 24, + "unicode": 986274 + }, + { + "name": "baseLock-24-active", + "active": true, + "size": 24, + "unicode": 986273 + }, + { + "name": "baseLock-16-inactive", + "active": false, + "size": 16, + "unicode": 986272 + }, + { + "name": "baseLock-16-active", + "active": true, + "size": 16, + "unicode": 986271 + }, + { + "name": "baseLock-12-inactive", + "active": false, + "size": 12, + "unicode": 986270 + }, + { + "name": "baseLock-12-active", + "active": true, + "size": 12, + "unicode": 986269 + } + ] } ] } \ No newline at end of file diff --git a/packages/icons/package.json b/packages/icons/package.json index aa771f6a35..568db799d7 100644 --- a/packages/icons/package.json +++ b/packages/icons/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-icons", - "version": "5.9.0", + "version": "5.15.0", "description": "CDS icons", "repository": { "type": "git", diff --git a/packages/icons/src/IconName.ts b/packages/icons/src/IconName.ts index 22e707a52e..eb065e9009 100644 --- a/packages/icons/src/IconName.ts +++ b/packages/icons/src/IconName.ts @@ -39,6 +39,7 @@ export type IconName = | 'atSign' | 'atomScience' | 'auto' + | 'autoCar' | 'avatar' | 'average' | 'backArrow' @@ -51,6 +52,7 @@ export type IconName = | 'base' | 'baseApps' | 'baseFeed' + | 'baseLock' | 'baseNotification' | 'baseQuickBuy' | 'baseSquare' @@ -64,6 +66,7 @@ export type IconName = | 'bell' | 'bellCheck' | 'bellPlus' + | 'birthcertificate' | 'block' | 'blockchain' | 'blog' @@ -152,6 +155,7 @@ export type IconName = | 'collapse' | 'collectibles' | 'collection' + | 'column' | 'comment' | 'commentPlus' | 'commerceProduct' @@ -233,6 +237,7 @@ export type IconName = | 'fib' | 'filmStrip' | 'filter' + | 'filterLineStack' | 'fingerprint' | 'flame' | 'folder' @@ -348,6 +353,7 @@ export type IconName = | 'orderBook' | 'orderHistory' | 'outline' + | 'overPredictions' | 'pFPS' | 'paperAirplane' | 'paperclip' @@ -369,6 +375,7 @@ export type IconName = | 'perpetualSwap' | 'phone' | 'pieChartData' + | 'pieChartWithArrow' | 'pillBottle' | 'pillCapsule' | 'pin' @@ -508,12 +515,14 @@ export type IconName = | 'twitterLogo' | 'ultility' | 'umbrella' + | 'underPredictions' | 'undo' | 'unfollowPeople' | 'unknown' | 'unlock' | 'upArrow' | 'upload' + | 'usdc' | 'venturesProduct' | 'verifiedBadge' | 'verifiedPools' @@ -525,6 +534,7 @@ export type IconName = | 'walletLogo' | 'walletProduct' | 'warning' + | 'webhooks' | 'wellness' | 'wifi' | 'wind' diff --git a/packages/icons/src/descriptionMap.ts b/packages/icons/src/descriptionMap.ts index 85c21e82f2..50faf33693 100644 --- a/packages/icons/src/descriptionMap.ts +++ b/packages/icons/src/descriptionMap.ts @@ -307,7 +307,8 @@ export const descriptionMap: Record = { 'smartContract', 'paperclip', 'list', - 'document' + 'document', + 'birthcertificate' ], '📄': [ 'taxes', @@ -706,7 +707,8 @@ export const descriptionMap: Record = { 'sortDownCenter', 'sortDown', 'more', - 'sortDoubleArrow' + 'sortDoubleArrow', + 'filterLineStack' ], '🍡': [ 'moreVertical' @@ -864,7 +866,9 @@ export const descriptionMap: Record = { 'socialShare', 'socialReshare', 'beginningArrow', - 'endArrow' + 'endArrow', + 'overPredictions', + 'underPredictions' ], 'reverse': [ 'undo', @@ -1016,7 +1020,9 @@ export const descriptionMap: Record = { 'briefcaseAlt', 'pillCapsule', 'singlecloud', - 'rain' + 'rain', + 'autoCar', + 'column' ], 'profile': [ 'account', @@ -1275,7 +1281,8 @@ export const descriptionMap: Record = { 'doubleChevronRight', 'directDepositIcon', 'sendReceive', - 'distribution' + 'distribution', + 'pieChartWithArrow' ], 'payment': [ 'directDeposit', @@ -1832,7 +1839,8 @@ export const descriptionMap: Record = { 'creatorCoin', 'distribution', 'baseQuickBuy', - 'stableCoin' + 'stableCoin', + 'usdc' ], 'defi': [ 'defi' @@ -2228,7 +2236,8 @@ export const descriptionMap: Record = { '🔐': [ 'setPinCode', 'lock', - 'unlock' + 'unlock', + 'baseLock' ], 'order': [ 'orderHistory', @@ -2446,7 +2455,8 @@ export const descriptionMap: Record = { 'horizontalLine', 'chartLine', 'chartLine', - 'lineChartCrypto' + 'lineChartCrypto', + 'filterLineStack' ], 'calendar': [ 'calendar', @@ -4564,32 +4574,39 @@ export const descriptionMap: Record = { ], 'lock': [ 'lock', - 'key' + 'key', + 'baseLock' ], 'no access': [ - 'lock' + 'lock', + 'baseLock' ], 'latch': [ 'lock', - 'unlock' + 'unlock', + 'baseLock' ], 'blocked': [ 'lock', - 'block' + 'block', + 'baseLock' ], '🔒': [ 'lock', - 'unlock' + 'unlock', + 'baseLock' ], '🔑': [ 'lock', 'unlock', 'key', - 'securityKey' + 'securityKey', + 'baseLock' ], '🗝': [ 'lock', - 'unlock' + 'unlock', + 'baseLock' ], 'peer to peer': [ 'blockchain' @@ -4994,7 +5011,8 @@ export const descriptionMap: Record = { 'gasFees' ], 'cost': [ - 'gasFees' + 'gasFees', + 'usdc' ], 'pump': [ 'gasFees', @@ -5008,7 +5026,8 @@ export const descriptionMap: Record = { ], 'car': [ 'gasFees', - 'car' + 'car', + 'autoCar' ], '⛽️': [ 'gasFees' @@ -5559,7 +5578,8 @@ export const descriptionMap: Record = { 'dataStack' ], 'stack': [ - 'dataStack' + 'dataStack', + 'filterLineStack' ], '✖️': [ 'plusMinus' @@ -5698,7 +5718,8 @@ export const descriptionMap: Record = { 'instantUnstakingClock' ], 'auto': [ - 'auto' + 'auto', + 'autoCar' ], 'creator': [ 'creatorCoin' @@ -5708,7 +5729,8 @@ export const descriptionMap: Record = { ], 'piechart': [ 'allocation', - 'pieChartData' + 'pieChartData', + 'pieChartWithArrow' ], 'Verification': [ 'baseVerification' @@ -6501,5 +6523,54 @@ export const descriptionMap: Record = { ], 'candidate': [ 'politicsCandidate' + ], + 'birthcertificate': [ + 'birthcertificate' + ], + 'birth': [ + 'birthcertificate' + ], + 'certificate': [ + 'birthcertificate' + ], + 'doc': [ + 'birthcertificate' + ], + 'webhooks': [ + 'webhooks' + ], + 'web': [ + 'webhooks' + ], + 'hooks': [ + 'webhooks' + ], + 'usdc': [ + 'usdc' + ], + 'fixed': [ + 'usdc' + ], + 'transferStocks': [ + 'pieChartWithArrow' + ], + 'stocks': [ + 'pieChartWithArrow' + ], + 'over': [ + 'overPredictions' + ], + 'predictions': [ + 'overPredictions', + 'underPredictions' + ], + 'column': [ + 'column' + ], + 'editing': [ + 'column' + ], + 'under': [ + 'underPredictions' ] }; diff --git a/packages/icons/src/fonts/native/CoinbaseIcons.ttf b/packages/icons/src/fonts/native/CoinbaseIcons.ttf index 7363bd4ab7..e559d2d2a1 100644 Binary files a/packages/icons/src/fonts/native/CoinbaseIcons.ttf and b/packages/icons/src/fonts/native/CoinbaseIcons.ttf differ diff --git a/packages/icons/src/fonts/web/CoinbaseIcons-6f867dfe695aa.woff2 b/packages/icons/src/fonts/web/CoinbaseIcons-6f867dfe695aa.woff2 deleted file mode 100644 index e94cbd5db3..0000000000 Binary files a/packages/icons/src/fonts/web/CoinbaseIcons-6f867dfe695aa.woff2 and /dev/null differ diff --git a/packages/icons/src/fonts/web/CoinbaseIcons-efb04c068c1d6.woff2 b/packages/icons/src/fonts/web/CoinbaseIcons-efb04c068c1d6.woff2 new file mode 100644 index 0000000000..c810ff7c6d Binary files /dev/null and b/packages/icons/src/fonts/web/CoinbaseIcons-efb04c068c1d6.woff2 differ diff --git a/packages/icons/src/fonts/web/icon-font.css b/packages/icons/src/fonts/web/icon-font.css index 181fd5faba..9c26b16000 100644 --- a/packages/icons/src/fonts/web/icon-font.css +++ b/packages/icons/src/fonts/web/icon-font.css @@ -3,5 +3,5 @@ font-style: normal; font-weight: 400; font-display: block; - src: url('./CoinbaseIcons-6f867dfe695aa.woff2') format('woff2'); + src: url('./CoinbaseIcons-efb04c068c1d6.woff2') format('woff2'); } \ No newline at end of file diff --git a/packages/icons/src/glyphMap.ts b/packages/icons/src/glyphMap.ts index 5e48fe3a1a..ac5c159a0d 100644 --- a/packages/icons/src/glyphMap.ts +++ b/packages/icons/src/glyphMap.ts @@ -3176,5 +3176,65 @@ export const glyphMap = { 'airdropParachute-16-active': '󰨵', 'airdropParachute-16-inactive': '󰨶', 'airdropParachute-24-active': '󰨷', - 'airdropParachute-24-inactive': '󰨸' + 'airdropParachute-24-inactive': '󰨸', + 'birthcertificate-12-active': '󰱧', + 'birthcertificate-12-inactive': '󰱨', + 'birthcertificate-16-active': '󰱩', + 'birthcertificate-16-inactive': '󰱪', + 'birthcertificate-24-active': '󰱫', + 'birthcertificate-24-inactive': '󰱬', + 'autoCar-12-active': '󰱭', + 'autoCar-12-inactive': '󰱮', + 'autoCar-16-active': '󰱯', + 'autoCar-16-inactive': '󰱰', + 'autoCar-24-active': '󰱱', + 'autoCar-24-inactive': '󰱲', + 'webhooks-12-active': '󰱳', + 'webhooks-12-inactive': '󰱴', + 'webhooks-16-active': '󰱵', + 'webhooks-16-inactive': '󰱶', + 'webhooks-24-active': '󰱷', + 'webhooks-24-inactive': '󰱸', + 'usdc-12-active': '󰲅', + 'usdc-12-inactive': '󰲆', + 'usdc-16-active': '󰲇', + 'usdc-16-inactive': '󰲈', + 'usdc-24-active': '󰲉', + 'usdc-24-inactive': '󰲊', + 'pieChartWithArrow-12-active': '󰱿', + 'pieChartWithArrow-12-inactive': '󰲀', + 'pieChartWithArrow-16-active': '󰲁', + 'pieChartWithArrow-16-inactive': '󰲂', + 'pieChartWithArrow-24-active': '󰲃', + 'pieChartWithArrow-24-inactive': '󰲄', + 'filterLineStack-12-active': '󰱹', + 'filterLineStack-12-inactive': '󰱺', + 'filterLineStack-16-active': '󰱻', + 'filterLineStack-16-inactive': '󰱼', + 'filterLineStack-24-active': '󰱽', + 'filterLineStack-24-inactive': '󰱾', + 'overPredictions-12-active': '󰲑', + 'overPredictions-12-inactive': '󰲒', + 'overPredictions-16-active': '󰲓', + 'overPredictions-16-inactive': '󰲔', + 'overPredictions-24-active': '󰲕', + 'overPredictions-24-inactive': '󰲖', + 'column-12-active': '󰲋', + 'column-12-inactive': '󰲌', + 'column-16-active': '󰲍', + 'column-16-inactive': '󰲎', + 'column-24-active': '󰲏', + 'column-24-inactive': '󰲐', + 'underPredictions-12-active': '󰲗', + 'underPredictions-12-inactive': '󰲘', + 'underPredictions-16-active': '󰲙', + 'underPredictions-16-inactive': '󰲚', + 'underPredictions-24-active': '󰲛', + 'underPredictions-24-inactive': '󰲜', + 'baseLock-12-active': '󰲝', + 'baseLock-12-inactive': '󰲞', + 'baseLock-16-active': '󰲟', + 'baseLock-16-inactive': '󰲠', + 'baseLock-24-active': '󰲡', + 'baseLock-24-inactive': '󰲢' }; diff --git a/packages/icons/src/names.ts b/packages/icons/src/names.ts index 8c8ce26d6c..bb448fbdbd 100644 --- a/packages/icons/src/names.ts +++ b/packages/icons/src/names.ts @@ -533,5 +533,15 @@ export const names: IconName[] = [ 'politicsCandidate', 'ballot', 'rain', - 'airdropParachute' + 'airdropParachute', + 'birthcertificate', + 'autoCar', + 'webhooks', + 'usdc', + 'pieChartWithArrow', + 'filterLineStack', + 'overPredictions', + 'column', + 'underPredictions', + 'baseLock' ]; diff --git a/packages/icons/src/svgs/autoCar-12-active.svg b/packages/icons/src/svgs/autoCar-12-active.svg new file mode 100644 index 0000000000..9f2f5537f4 --- /dev/null +++ b/packages/icons/src/svgs/autoCar-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/autoCar-12-inactive.svg b/packages/icons/src/svgs/autoCar-12-inactive.svg new file mode 100644 index 0000000000..29e02b5ede --- /dev/null +++ b/packages/icons/src/svgs/autoCar-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/autoCar-16-active.svg b/packages/icons/src/svgs/autoCar-16-active.svg new file mode 100644 index 0000000000..b8f0b1fd92 --- /dev/null +++ b/packages/icons/src/svgs/autoCar-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/autoCar-16-inactive.svg b/packages/icons/src/svgs/autoCar-16-inactive.svg new file mode 100644 index 0000000000..3e4adba78b --- /dev/null +++ b/packages/icons/src/svgs/autoCar-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/autoCar-24-active.svg b/packages/icons/src/svgs/autoCar-24-active.svg new file mode 100644 index 0000000000..90c717a998 --- /dev/null +++ b/packages/icons/src/svgs/autoCar-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/autoCar-24-inactive.svg b/packages/icons/src/svgs/autoCar-24-inactive.svg new file mode 100644 index 0000000000..8ae3dd4dd1 --- /dev/null +++ b/packages/icons/src/svgs/autoCar-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-12-active.svg b/packages/icons/src/svgs/baseLock-12-active.svg new file mode 100644 index 0000000000..9204c3450b --- /dev/null +++ b/packages/icons/src/svgs/baseLock-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-12-inactive.svg b/packages/icons/src/svgs/baseLock-12-inactive.svg new file mode 100644 index 0000000000..a7a8d73e85 --- /dev/null +++ b/packages/icons/src/svgs/baseLock-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-16-active.svg b/packages/icons/src/svgs/baseLock-16-active.svg new file mode 100644 index 0000000000..9348958404 --- /dev/null +++ b/packages/icons/src/svgs/baseLock-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-16-inactive.svg b/packages/icons/src/svgs/baseLock-16-inactive.svg new file mode 100644 index 0000000000..bd6b7d0dae --- /dev/null +++ b/packages/icons/src/svgs/baseLock-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-24-active.svg b/packages/icons/src/svgs/baseLock-24-active.svg new file mode 100644 index 0000000000..d805162671 --- /dev/null +++ b/packages/icons/src/svgs/baseLock-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/baseLock-24-inactive.svg b/packages/icons/src/svgs/baseLock-24-inactive.svg new file mode 100644 index 0000000000..77d835ebce --- /dev/null +++ b/packages/icons/src/svgs/baseLock-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-12-active.svg b/packages/icons/src/svgs/birthcertificate-12-active.svg new file mode 100644 index 0000000000..136c4bc1fb --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-12-inactive.svg b/packages/icons/src/svgs/birthcertificate-12-inactive.svg new file mode 100644 index 0000000000..e428dfb84a --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-16-active.svg b/packages/icons/src/svgs/birthcertificate-16-active.svg new file mode 100644 index 0000000000..adca470f18 --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-16-inactive.svg b/packages/icons/src/svgs/birthcertificate-16-inactive.svg new file mode 100644 index 0000000000..3d14f7d9f1 --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-24-active.svg b/packages/icons/src/svgs/birthcertificate-24-active.svg new file mode 100644 index 0000000000..bee2ec0810 --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/birthcertificate-24-inactive.svg b/packages/icons/src/svgs/birthcertificate-24-inactive.svg new file mode 100644 index 0000000000..85386d8ac9 --- /dev/null +++ b/packages/icons/src/svgs/birthcertificate-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-12-active.svg b/packages/icons/src/svgs/column-12-active.svg new file mode 100644 index 0000000000..7353a948a8 --- /dev/null +++ b/packages/icons/src/svgs/column-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-12-inactive.svg b/packages/icons/src/svgs/column-12-inactive.svg new file mode 100644 index 0000000000..6535eee9c8 --- /dev/null +++ b/packages/icons/src/svgs/column-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-16-active.svg b/packages/icons/src/svgs/column-16-active.svg new file mode 100644 index 0000000000..775afe33e3 --- /dev/null +++ b/packages/icons/src/svgs/column-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-16-inactive.svg b/packages/icons/src/svgs/column-16-inactive.svg new file mode 100644 index 0000000000..4a9832be39 --- /dev/null +++ b/packages/icons/src/svgs/column-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-24-active.svg b/packages/icons/src/svgs/column-24-active.svg new file mode 100644 index 0000000000..50203f493b --- /dev/null +++ b/packages/icons/src/svgs/column-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/column-24-inactive.svg b/packages/icons/src/svgs/column-24-inactive.svg new file mode 100644 index 0000000000..2ed8eb0690 --- /dev/null +++ b/packages/icons/src/svgs/column-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-12-active.svg b/packages/icons/src/svgs/filterLineStack-12-active.svg new file mode 100644 index 0000000000..1159c913b5 --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-12-inactive.svg b/packages/icons/src/svgs/filterLineStack-12-inactive.svg new file mode 100644 index 0000000000..adebc4bc0c --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-16-active.svg b/packages/icons/src/svgs/filterLineStack-16-active.svg new file mode 100644 index 0000000000..ffa345d3d9 --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-16-inactive.svg b/packages/icons/src/svgs/filterLineStack-16-inactive.svg new file mode 100644 index 0000000000..97a333f6a5 --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-24-active.svg b/packages/icons/src/svgs/filterLineStack-24-active.svg new file mode 100644 index 0000000000..be613e5ccb --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/filterLineStack-24-inactive.svg b/packages/icons/src/svgs/filterLineStack-24-inactive.svg new file mode 100644 index 0000000000..618e939ef5 --- /dev/null +++ b/packages/icons/src/svgs/filterLineStack-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-12-active.svg b/packages/icons/src/svgs/ideal-12-active.svg index 5825e2b846..c1dd49f1e8 100644 --- a/packages/icons/src/svgs/ideal-12-active.svg +++ b/packages/icons/src/svgs/ideal-12-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-12-inactive.svg b/packages/icons/src/svgs/ideal-12-inactive.svg index 5825e2b846..c1dd49f1e8 100644 --- a/packages/icons/src/svgs/ideal-12-inactive.svg +++ b/packages/icons/src/svgs/ideal-12-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-16-active.svg b/packages/icons/src/svgs/ideal-16-active.svg index ff23ba81ae..b3401b1a6c 100644 --- a/packages/icons/src/svgs/ideal-16-active.svg +++ b/packages/icons/src/svgs/ideal-16-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-16-inactive.svg b/packages/icons/src/svgs/ideal-16-inactive.svg index ff23ba81ae..b3401b1a6c 100644 --- a/packages/icons/src/svgs/ideal-16-inactive.svg +++ b/packages/icons/src/svgs/ideal-16-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-24-active.svg b/packages/icons/src/svgs/ideal-24-active.svg index 9b793f3d19..8ff3b61291 100644 --- a/packages/icons/src/svgs/ideal-24-active.svg +++ b/packages/icons/src/svgs/ideal-24-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/ideal-24-inactive.svg b/packages/icons/src/svgs/ideal-24-inactive.svg index 9b793f3d19..8ff3b61291 100644 --- a/packages/icons/src/svgs/ideal-24-inactive.svg +++ b/packages/icons/src/svgs/ideal-24-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-12-active.svg b/packages/icons/src/svgs/overPredictions-12-active.svg new file mode 100644 index 0000000000..87960b3f5c --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-12-inactive.svg b/packages/icons/src/svgs/overPredictions-12-inactive.svg new file mode 100644 index 0000000000..f484a4b4a9 --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-16-active.svg b/packages/icons/src/svgs/overPredictions-16-active.svg new file mode 100644 index 0000000000..8ad03bbd0a --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-16-inactive.svg b/packages/icons/src/svgs/overPredictions-16-inactive.svg new file mode 100644 index 0000000000..09aad0a088 --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-24-active.svg b/packages/icons/src/svgs/overPredictions-24-active.svg new file mode 100644 index 0000000000..1bdce7295d --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/overPredictions-24-inactive.svg b/packages/icons/src/svgs/overPredictions-24-inactive.svg new file mode 100644 index 0000000000..ff916b5a7f --- /dev/null +++ b/packages/icons/src/svgs/overPredictions-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-12-active.svg b/packages/icons/src/svgs/pencil-12-active.svg index 34f25faf93..eb6ad9d52a 100644 --- a/packages/icons/src/svgs/pencil-12-active.svg +++ b/packages/icons/src/svgs/pencil-12-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-12-inactive.svg b/packages/icons/src/svgs/pencil-12-inactive.svg index eb6ad9d52a..34f25faf93 100644 --- a/packages/icons/src/svgs/pencil-12-inactive.svg +++ b/packages/icons/src/svgs/pencil-12-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-16-active.svg b/packages/icons/src/svgs/pencil-16-active.svg index f38afbab3c..0885d3fc0f 100644 --- a/packages/icons/src/svgs/pencil-16-active.svg +++ b/packages/icons/src/svgs/pencil-16-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-16-inactive.svg b/packages/icons/src/svgs/pencil-16-inactive.svg index 0885d3fc0f..f38afbab3c 100644 --- a/packages/icons/src/svgs/pencil-16-inactive.svg +++ b/packages/icons/src/svgs/pencil-16-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-24-active.svg b/packages/icons/src/svgs/pencil-24-active.svg index 2bd3a267b1..868f2a990c 100644 --- a/packages/icons/src/svgs/pencil-24-active.svg +++ b/packages/icons/src/svgs/pencil-24-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pencil-24-inactive.svg b/packages/icons/src/svgs/pencil-24-inactive.svg index 868f2a990c..2bd3a267b1 100644 --- a/packages/icons/src/svgs/pencil-24-inactive.svg +++ b/packages/icons/src/svgs/pencil-24-inactive.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-12-active.svg b/packages/icons/src/svgs/pieChartWithArrow-12-active.svg new file mode 100644 index 0000000000..5a38aae256 --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-12-inactive.svg b/packages/icons/src/svgs/pieChartWithArrow-12-inactive.svg new file mode 100644 index 0000000000..d038c43802 --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-16-active.svg b/packages/icons/src/svgs/pieChartWithArrow-16-active.svg new file mode 100644 index 0000000000..04973fe10d --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-16-inactive.svg b/packages/icons/src/svgs/pieChartWithArrow-16-inactive.svg new file mode 100644 index 0000000000..378e80201d --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-24-active.svg b/packages/icons/src/svgs/pieChartWithArrow-24-active.svg new file mode 100644 index 0000000000..22b7421154 --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/pieChartWithArrow-24-inactive.svg b/packages/icons/src/svgs/pieChartWithArrow-24-inactive.svg new file mode 100644 index 0000000000..ac3e6c4b59 --- /dev/null +++ b/packages/icons/src/svgs/pieChartWithArrow-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/smartContract-16-active.svg b/packages/icons/src/svgs/smartContract-16-active.svg index 7994eb206d..606b26ed5b 100644 --- a/packages/icons/src/svgs/smartContract-16-active.svg +++ b/packages/icons/src/svgs/smartContract-16-active.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-12-active.svg b/packages/icons/src/svgs/underPredictions-12-active.svg new file mode 100644 index 0000000000..92813a9010 --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-12-inactive.svg b/packages/icons/src/svgs/underPredictions-12-inactive.svg new file mode 100644 index 0000000000..5f609843dc --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-16-active.svg b/packages/icons/src/svgs/underPredictions-16-active.svg new file mode 100644 index 0000000000..342c1678d8 --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-16-inactive.svg b/packages/icons/src/svgs/underPredictions-16-inactive.svg new file mode 100644 index 0000000000..148b16f5e6 --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-24-active.svg b/packages/icons/src/svgs/underPredictions-24-active.svg new file mode 100644 index 0000000000..8b5224e237 --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/underPredictions-24-inactive.svg b/packages/icons/src/svgs/underPredictions-24-inactive.svg new file mode 100644 index 0000000000..f2ffb2de31 --- /dev/null +++ b/packages/icons/src/svgs/underPredictions-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-12-active.svg b/packages/icons/src/svgs/usdc-12-active.svg new file mode 100644 index 0000000000..5f2274df64 --- /dev/null +++ b/packages/icons/src/svgs/usdc-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-12-inactive.svg b/packages/icons/src/svgs/usdc-12-inactive.svg new file mode 100644 index 0000000000..56e0ad3274 --- /dev/null +++ b/packages/icons/src/svgs/usdc-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-16-active.svg b/packages/icons/src/svgs/usdc-16-active.svg new file mode 100644 index 0000000000..4c868b359a --- /dev/null +++ b/packages/icons/src/svgs/usdc-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-16-inactive.svg b/packages/icons/src/svgs/usdc-16-inactive.svg new file mode 100644 index 0000000000..7b2c2ac925 --- /dev/null +++ b/packages/icons/src/svgs/usdc-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-24-active.svg b/packages/icons/src/svgs/usdc-24-active.svg new file mode 100644 index 0000000000..9b7b0131f1 --- /dev/null +++ b/packages/icons/src/svgs/usdc-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/usdc-24-inactive.svg b/packages/icons/src/svgs/usdc-24-inactive.svg new file mode 100644 index 0000000000..9ff25f8d20 --- /dev/null +++ b/packages/icons/src/svgs/usdc-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-12-active.svg b/packages/icons/src/svgs/webhooks-12-active.svg new file mode 100644 index 0000000000..498758de72 --- /dev/null +++ b/packages/icons/src/svgs/webhooks-12-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-12-inactive.svg b/packages/icons/src/svgs/webhooks-12-inactive.svg new file mode 100644 index 0000000000..698c12ca15 --- /dev/null +++ b/packages/icons/src/svgs/webhooks-12-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-16-active.svg b/packages/icons/src/svgs/webhooks-16-active.svg new file mode 100644 index 0000000000..ee6323e320 --- /dev/null +++ b/packages/icons/src/svgs/webhooks-16-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-16-inactive.svg b/packages/icons/src/svgs/webhooks-16-inactive.svg new file mode 100644 index 0000000000..149daccf74 --- /dev/null +++ b/packages/icons/src/svgs/webhooks-16-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-24-active.svg b/packages/icons/src/svgs/webhooks-24-active.svg new file mode 100644 index 0000000000..2460de4bc0 --- /dev/null +++ b/packages/icons/src/svgs/webhooks-24-active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/icons/src/svgs/webhooks-24-inactive.svg b/packages/icons/src/svgs/webhooks-24-inactive.svg new file mode 100644 index 0000000000..8ee6a88cff --- /dev/null +++ b/packages/icons/src/svgs/webhooks-24-inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/CHANGELOG.md b/packages/illustrations/CHANGELOG.md index 3accf37dd1..4014f0271d 100644 --- a/packages/illustrations/CHANGELOG.md +++ b/packages/illustrations/CHANGELOG.md @@ -8,6 +8,369 @@ All notable changes to this project will be documented in this file. +## 4.38.0 (4/16/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026-04-16. [[#620](https://github.com/coinbase/cds/pull/620)] + +##### ⭐️ Added (1) + +###### HeroSquare (1) + +- cbmega + +## 4.37.0 (4/8/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026-04-08. [[#597](https://github.com/coinbase/cds/pull/597)] + +##### ⭐️ Updated (24) + +###### Pictogram (3) + +- download +- instoRestaking +- instoApyInterest + +###### HeroSquare (8) + +- instoPrivateKey +- instoStakingMissedReturns +- instoCoinbaseOneProtectedCrypto +- instoDocumentSuccess +- instoOpenEmail +- instoRequestSent +- instoKeyGenerationComplete +- instoOnChain + +###### SpotIcon (5) + +- instoFast +- instoDelegate +- instoPrivateClientProduct +- instantAccess +- instoRecurringPurchases + +###### SpotRectangle (3) + +- instoApiKey +- instoEmptyTrading +- instoCryptoAndMore + +###### SpotSquare (5) + +- instoUbiKey +- instoAuthenticatorProgress +- instantUnstaking +- instoAuthenticatorProgress +- instoWaiting + +## 4.36.0 (3/27/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026-03-27. [[#552](https://github.com/coinbase/cds/pull/552)] + +##### ⭐️ Added (3) + +###### Pictogram (1) + +- inrTrade + +###### HeroSquare (1) + +- flipStable + +###### SpotSquare (1) + +- inrTrade + +## 4.35.0 (3/20/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026/03/20. [[#526](https://github.com/coinbase/cds/pull/526)] + +##### ⭐️ Added (52) + +###### Pictogram (7) + +- instoBorrowCoins +- instoGlobalConnections +- instoEasyToUse +- instoCoinFocus +- instoDecentralizedExchange +- instoSecuredAssets +- instoMonitoringPerformance + +###### HeroSquare (1) + +- instoOnChain + +###### SpotIcon (32) + +- instoProductWallet +- instoSignInProduct +- instoProductCompliance +- instoProductPro +- instoBusinessProduct +- instoIdVerification +- instoCloudProduct +- instoProductCoinbaseCard +- instoLayeredNetworks +- instoAssetHubProduct +- instoPrimeProduct +- instoDataMarketplace +- instoRewardsProduct +- instoCustodyProduct +- instoDelegate +- instoPaySDKProduct +- instoAdvancedTradeProduct +- instoPrivateClientProduct +- instoFast +- instoChat +- instoAuthenticator +- instoCoinbaseOneEarn +- instoWalletAsAServiceProduct +- instoHelpCenterProduct +- instoMultiCoin +- instoShield +- instoLearningRewardsProduct +- instoRecurringPurchases +- instoBorrowProduct +- instoCommerceProduct +- instoPieChart +- instoDerivativesProduct + +###### SpotRectangle (11) + +- instoDesignateSigner +- instoApiKey +- instoConsensusWaitingForApprovals +- instoKey +- instoRefreshKey +- instoSetupComplete +- instoQRCode +- instoSetupOnchain +- instoMargin +- instoAboutOnchain +- instoOnchainSetupInProgress + +###### SpotSquare (1) + +- instoAuthenticatorProgress + +##### ⭐️ Updated (12) + +###### Pictogram (5) + +- instoDecentralizedExchange +- instoDecentralizedWeb3 +- instoRestaking +- instoApyInterest +- instoKey + +###### HeroSquare (1) + +- instoOnChain + +###### SpotRectangle (2) + +- instoEthStakingMovement +- instoCryptoAndMore + +###### SpotSquare (4) + +- instoSecurityKey +- instoAuthenticatorProgress +- instoWaiting +- instoDappWallet + +## 4.34.0 (3/17/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026/03/17. [[#511](https://github.com/coinbase/cds/pull/511)] + +##### ⭐️ Added (79) + +###### Pictogram (29) + +- instoEarnCoins +- instoNftLibrary +- instoWalletWarning +- instoEthRewards +- instoAdvancedTradingRebates +- instoDecentralizationEverything +- instoRiskStaking +- instoGem +- instoStakingGraph +- instoKey +- instoDecentralizedExchange +- instoSelfCustodyWallet +- instoBorrowingLending +- instoAccount +- instoEthStakingChart +- instoEarnGraph +- instoPasswordWalletLocked +- instoRestaking +- instoAddressBook +- instoDelegate +- instoCrypto101 +- instoDecentralizedWeb3 +- instoApyInterest +- instoAuthenticatorProgress +- instoCoinbaseOneShield +- instoTrading +- instoFiat +- instoprimeMobileApp +- instoEth + +###### HeroSquare (26) + +- instoSecurityKeyAuth +- instoWalletSecurity +- instoGovernance +- instoEarnGlobe +- instoStakingMissedReturns +- instoRequestSent +- instoEthStakingUpsell +- instoWeb3MobileSetupStart +- tradingPerpetualsUsdc +- instoCoinbaseOneProtectedCrypto +- instoOnChain +- instoDocumentSuccess +- instoOnChain +- cryptoPortfolioUsdc +- instoWallet +- instoPhoneUnknown +- instoKeyGenerationPending +- instoAddBankAccount +- instoEthStakingRewards +- instoStaking +- instoPrimeStaking +- instoOpenEmail +- instoPrivateKey +- instoKeyGenerationComplete +- instoAdd2Fa +- instoEnableBiometrics + +###### SpotIcon (2) + +- instoStakingProduct +- instantAccess + +###### SpotRectangle (10) + +- instoSemiCustodial +- instoEmptyTrading +- instoCryptoAndMore +- instoPrimeStaking +- instoEthStakingMovement +- stakingUpgrade +- insto +- instoStaking +- instoGetStartedInMinutes +- instoCurrency + +###### SpotSquare (12) + +- instantUnstaking +- instoEthStakingRewards +- instoStaking +- instoPrimeStaking +- instoEthStaking +- instoAuthenticatorProgress +- instoSideChainSide +- instoUbiKey +- instoSecurityKey +- instoWaiting +- instoDappWallet +- instoPixDeposits + +##### ⭐️ Updated (1) + +###### Pictogram (1) + +- browserMultiPlatform + +## 4.33.0 (3/11/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026-03-11. [[#495](https://github.com/coinbase/cds/pull/495)] + +##### ⭐️ Added (1) + +###### Pictogram (1) + +- download + +## 4.32.0 (3/3/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026-03-03. [[#466](https://github.com/coinbase/cds/pull/466)] + +##### ⭐️ Added (4) + +###### Pictogram (2) + +- pieChartWithArrow +- pieChartWithArrowBlue + +###### SpotSquare (2) + +- pieChartWithArrow +- pieChartWithArrowBlue + +## 4.31.0 (2/3/2026 PST) + +#### 🚀 Updates + +- Feat: Publish illustrations 2026/02/03. [[#364](https://github.com/coinbase/cds/pull/364)] + +##### ⭐️ Added (1) + +###### Pictogram (1) + +- arrowsUpDown + +##### ⭐️ Updated (1) + +###### Pictogram (1) + +- baseCheckSmall + +## 4.30.1 (2/3/2026 PST) + +This is an artificial version bump with no new change. + +## 4.30.0 (1/29/2026 PST) + +##### ⭐️ Added (4) + +###### Pictogram (1) + +- commodities + +###### HeroSquare (2) + +- test +- borrowCoinsBtc + +###### SpotSquare (1) + +- goldSilverFutures + +##### ⭐️ Updated (1) + +###### SpotSquare (1) + +- cryptoEconomyArrows + ## 4.29.0 (12/5/2025 PST) #### 🚀 Updates diff --git a/packages/illustrations/README.md b/packages/illustrations/README.md index 58c5963b0f..8e7a6d1fc8 100644 --- a/packages/illustrations/README.md +++ b/packages/illustrations/README.md @@ -8,73 +8,12 @@ CDS illustrations used in @coinbase/cds-web and @coinbase/cds-mobile. yarn add @coinbase/cds-illustrations ``` -## Contributing +## Illustrations -### Figma Links +Browse all available illustrations on the docsite: -- [CDS Illustrations Figma components](https://www.figma.com/file/LmkJatvMRVzNgfiIkJDb99/%E2%9C%A8-Illustrations) -- [CDS Illustration Figma light styles](https://www.figma.com/file/skPXKFmI64GqqEkOaBSHcL/%F0%9F%8C%9E--Illustration-Light-Styles?t=NAycPBCIl5jp15ou-6) -- [CDS Illustration Figma dark styles](https://www.figma.com/file/etJaiHq7aFlJFICLKIrcK7/%F0%9F%8C%9A--Illustration-Dark-Styles?t=NAycPBCIl5jp15ou-6) - -### Summary - -The [CDS Illustrations Figma components](https://www.figma.com/file/LmkJatvMRVzNgfiIkJDb99/%E2%9C%A8-Illustrations) are composed of vectors which leverage the [CDS Figma Illustration light color styles](https://www.figma.com/file/skPXKFmI64GqqEkOaBSHcL/%F0%9F%8C%9E--Illustration-Light-Styles?t=NAycPBCIl5jp15ou-6) to power their color fills. This allows design to create a single light mode version, and engineering handles creating the dark mode version through the CDS Figma plugin or through code generation. - -In order for engineering to generate a dark mode version of an illustration asset, we need to pull information about the light and dark mode color styles from Figma (`yarn nx run figma-styles:sync`). The CDS Illustration color styles should not change often and can be run on an as-needed basis. - -### Releasing Illustrations - -1. (**optional**) Sync the latest illustration Figma color styles if they were changed. **You do not need to do a version bump for any changes**. - -```shell -yarn nx run figma-styles:sync-illustration-dark-styles -yarn nx run figma-styles:sync-illustration-light-styles -``` - -2. Sync the latest Figma illustration components. - -```shell -yarn nx run illustrations:release -``` - -- **IMPORTANT:** If any illustrations are renamed or deleted, this update will be a breaking change for consumers. Please ensure that you publish a migration guide and a migrator script along with this release to aid consumers with migration. - -2. Commit the changes with a message in the following format: `feat: Publish illustrations mm/dd/yyyy` - -3. Open a PR with the changes - -4. Bump the package version and update the changelog - -```shell -yarn changelog illustrations -``` - -- When prompted, do the following: - - Type of change?: "Update" or "Breaking" - - Changelog message?: Copy/paste your PR title (just the part after `feat:`) - - PR number?: Copy/paste your PR number - - Skip the rest (press enter to use defaults) - -5. Run `yarn release` to generate website changelogs. - -6. Commit and push the changes to your existing PR - -7. When the Percy diffs are ready, share them with the Illustrations DRI for approval. Merge your PR once the DRI has signed off. - - - -If not, manually trigger the builds if necessary and/or deploy the targets as needed when the build is complete. **_NOTE:_** If you're releasing both icons and illustrations at the same time, you only need to deploy to `production::cds-docs` once, so just pick whichever commit is the most recent and deploy from there. - -9. After the deploy has succeeded, verify that the new package was published at the [production Coinbase NPM registry](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master). It usually takes about 10 min or so for the package to be uploaded. Look for the version number at the bottom of the artifact list in the [package directory](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master/@coinbase/cds-illustrations/-/@coinbase/cds-illustrations-0.0.1.tgz). - -### Gotchas - -- It is important to note that if an illustration asset is referencing a color style which was _not_ present the last time the color syncs were run, then the `yarn nx run figma-styles:sync-*` commands will need to be run again before running `yarn nx run illustrations:release`. - -- The `illustrations:release` command calls `illustrations:sync` which requires a `lightModeManifestFile` and `darkModeManifestFile` as inputs in `project.json` when generating the svg assets on the fly. If those files are stale, the executor will fallback to the hex value of the color style used (which will always be a light mode fill since that is the only asset design provides), thus making the light and dark mode images the same. - -- The release script assumes that each light mode color style synced with `figma-styles:sync-illustration-light-styles` is assigned a unique hex value in Figma. If there are duplicate hex values (check the [light mode manifest](../figma-styles/__generated__/illustration/light/manifest.json)), dark mode variants generated during the `illustrations:release` task may not have the correct colors. - -- If seeing this error: "Cannot read properties of undefined ('styles')", you need to update your FIGMA token to the new value. - -- You may see the task complete without any changes and the message: "There are no changes since the last update on XX/XX/XXXX". Verify this is expected with design. +- [HeroSquare](https://cds.coinbase.com/components/media/HeroSquare/#illustrations) +- [Pictogram](https://cds.coinbase.com/components/media/Pictogram/#illustrations) +- [SpotIcon](https://cds.coinbase.com/components/media/SpotIcon/#illustrations) +- [SpotRectangle](https://cds.coinbase.com/components/media/SpotRectangle/#illustrations) +- [SpotSquare](https://cds.coinbase.com/components/media/SpotSquare/#illustrations) diff --git a/packages/illustrations/manifest.json b/packages/illustrations/manifest.json index a2006503c2..96854a3269 100644 --- a/packages/illustrations/manifest.json +++ b/packages/illustrations/manifest.json @@ -1,6 +1,372 @@ { "executor": "sync-illustrations", - "lastUpdated": "2025-12-05T21:01:02.785Z", + "lastUpdated": "2026-04-16T19:14:44.724Z", + "colors": { + "light": [ + { + "key": "592325b92a60f7eac42c181b82a1882b2830b46b", + "name": "gray-3", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#CED2DC" + }, + "cssVarSetter": "--illustration-gray-3", + "cssVarGetter": "var(--illustration-gray-3)" + }, + { + "key": "8cb121d50b28232dadda51db614569e92e8ab7bb", + "name": "positive", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#3CC28A" + }, + "cssVarSetter": "--illustration-positive", + "cssVarGetter": "var(--illustration-positive)" + }, + { + "key": "932552c58e590ee79409364aad163a6ae566f46f", + "name": "accent-4", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#73A2FF" + }, + "cssVarSetter": "--illustration-accent-4", + "cssVarGetter": "var(--illustration-accent-4)" + }, + { + "key": "c2ef06ba096398830f782147e47b9825cf016e6e", + "name": "gray-2", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0A0B0F" + }, + "cssVarSetter": "--illustration-gray-2", + "cssVarGetter": "var(--illustration-gray-2)" + }, + { + "key": "01d83da3e4f139c0249979b16b57bbfcd8800b42", + "name": "black", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0A0B0D" + }, + "cssVarSetter": "--illustration-black", + "cssVarGetter": "var(--illustration-black)" + }, + { + "key": "1ac216af08ff0ebd5e8da96c3e434984df58a3cf", + "name": "accent-2", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#5DE2F8" + }, + "cssVarSetter": "--illustration-accent-2", + "cssVarGetter": "var(--illustration-accent-2)" + }, + { + "key": "2712067c08bfd1c738125b79013b576ed4193c24", + "name": "invert", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0A0B0E" + }, + "cssVarSetter": "--illustration-invert", + "cssVarGetter": "var(--illustration-invert)" + }, + { + "key": "27a4cd1f761fca1eca513d75da0a2b90e02191bf", + "name": "gray", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#CED2DB" + }, + "cssVarSetter": "--illustration-gray", + "cssVarGetter": "var(--illustration-gray)" + }, + { + "key": "3d45bc2dca486cbc8a7780ca07372258524f9a6a", + "name": "accent-3", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#ED702F" + }, + "cssVarSetter": "--illustration-accent-3", + "cssVarGetter": "var(--illustration-accent-3)" + }, + { + "key": "4becda743977862f9f06956487546d564ff3fc45", + "name": "white", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFFFFF" + }, + "cssVarSetter": "--illustration-white", + "cssVarGetter": "var(--illustration-white)" + }, + { + "key": "6d603dc0c1e32aa67037676c713be658df0f8253", + "name": "invert-2", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFFFFE" + }, + "cssVarSetter": "--illustration-invert-2", + "cssVarGetter": "var(--illustration-invert-2)" + }, + { + "key": "8271abef0dea9d201283c417e6b6752edd928481", + "name": "base-gray", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#B1B7C3" + }, + "cssVarSetter": "--illustration-base-gray", + "cssVarGetter": "var(--illustration-base-gray)" + }, + { + "key": "8d049ee1f2731ffa0dd9c5711883d6b837ab58d9", + "name": "negative", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#E13947" + }, + "cssVarSetter": "--illustration-negative", + "cssVarGetter": "var(--illustration-negative)" + }, + { + "key": "974bc31241e76aea4443eb5efb21326e45ca8c8e", + "name": "accent-1", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFD200" + }, + "cssVarSetter": "--illustration-accent-1", + "cssVarGetter": "var(--illustration-accent-1)" + }, + { + "key": "f8181841e39e27aeb9974a05cf9b873b898e572f", + "name": "primary", + "type": "light", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0052FF" + }, + "cssVarSetter": "--illustration-primary", + "cssVarGetter": "var(--illustration-primary)" + } + ], + "dark": [ + { + "key": "592325b92a60f7eac42c181b82a1882b2830b46b", + "name": "gray-3", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFFFFF" + }, + "cssVarSetter": "--illustration-gray-3", + "cssVarGetter": "var(--illustration-gray-3)" + }, + { + "key": "8cb121d50b28232dadda51db614569e92e8ab7bb", + "name": "positive", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#44C28D" + }, + "cssVarSetter": "--illustration-positive", + "cssVarGetter": "var(--illustration-positive)" + }, + { + "key": "932552c58e590ee79409364aad163a6ae566f46f", + "name": "accent-4", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#84AAFD" + }, + "cssVarSetter": "--illustration-accent-4", + "cssVarGetter": "var(--illustration-accent-4)" + }, + { + "key": "c2ef06ba096398830f782147e47b9825cf016e6e", + "name": "gray-2", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#464B55" + }, + "cssVarSetter": "--illustration-gray-2", + "cssVarGetter": "var(--illustration-gray-2)" + }, + { + "key": "01d83da3e4f139c0249979b16b57bbfcd8800b42", + "name": "black", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0A0B0D" + }, + "cssVarSetter": "--illustration-black", + "cssVarGetter": "var(--illustration-black)" + }, + { + "key": "1ac216af08ff0ebd5e8da96c3e434984df58a3cf", + "name": "accent-2", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#45D9F5" + }, + "cssVarSetter": "--illustration-accent-2", + "cssVarGetter": "var(--illustration-accent-2)" + }, + { + "key": "2712067c08bfd1c738125b79013b576ed4193c24", + "name": "invert", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFFFFF" + }, + "cssVarSetter": "--illustration-invert", + "cssVarGetter": "var(--illustration-invert)" + }, + { + "key": "27a4cd1f761fca1eca513d75da0a2b90e02191bf", + "name": "gray", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#464B55" + }, + "cssVarSetter": "--illustration-gray", + "cssVarGetter": "var(--illustration-gray)" + }, + { + "key": "3d45bc2dca486cbc8a7780ca07372258524f9a6a", + "name": "accent-3", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#F07836" + }, + "cssVarSetter": "--illustration-accent-3", + "cssVarGetter": "var(--illustration-accent-3)" + }, + { + "key": "4becda743977862f9f06956487546d564ff3fc45", + "name": "white", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#FFFFFF" + }, + "cssVarSetter": "--illustration-white", + "cssVarGetter": "var(--illustration-white)" + }, + { + "key": "6d603dc0c1e32aa67037676c713be658df0f8253", + "name": "invert-2", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#0A0B0D" + }, + "cssVarSetter": "--illustration-invert-2", + "cssVarGetter": "var(--illustration-invert-2)" + }, + { + "key": "8271abef0dea9d201283c417e6b6752edd928481", + "name": "base-gray", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#727886" + }, + "cssVarSetter": "--illustration-base-gray", + "cssVarGetter": "var(--illustration-base-gray)" + }, + { + "key": "8d049ee1f2731ffa0dd9c5711883d6b837ab58d9", + "name": "negative", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#F0616D" + }, + "cssVarSetter": "--illustration-negative", + "cssVarGetter": "var(--illustration-negative)" + }, + { + "key": "974bc31241e76aea4443eb5efb21326e45ca8c8e", + "name": "accent-1", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#ECD069" + }, + "cssVarSetter": "--illustration-accent-1", + "cssVarGetter": "var(--illustration-accent-1)" + }, + { + "key": "f8181841e39e27aeb9974a05cf9b873b898e572f", + "name": "primary", + "type": "dark", + "prefix": "illustration", + "paint": { + "type": "solid", + "value": "#578BFA" + }, + "cssVarSetter": "--illustration-primary", + "cssVarGetter": "var(--illustration-primary)" + } + ] + }, "items": { "2:33979": { "type": "heroSquare", @@ -210,7 +576,7 @@ "height": 240, "description": "restricted, country, warning, map, pin, point, location, warning state", "createdAt": "2022-08-05T05:36:48.405Z", - "lastUpdated": "2025-12-01T18:17:47.000Z", + "lastUpdated": "2025-12-24T16:31:49.441Z", "outputs": { "svgLight": "./heroSquare/svg/light/restrictedCountry-7.svg", "svgDark": "./heroSquare/svg/dark/restrictedCountry-7.svg", @@ -6862,26 +7228,6 @@ }, "version": 5 }, - "2:41456": { - "type": "pictogram", - "name": "browserMultiPlatform", - "hash": "erOO2eGkzV/jpJadAht0QJ+0CHibMVLYvwiWx1UxDF0=", - "width": 48, - "height": 48, - "description": "", - "createdAt": "2022-08-05T05:36:58.593Z", - "lastUpdated": "2025-12-01T18:14:05.134Z", - "outputs": { - "svgLight": "./pictogram/svg/light/browserMultiPlatform-6.svg", - "svgDark": "./pictogram/svg/dark/browserMultiPlatform-6.svg", - "svgThemed": "./pictogram/svg/themeable/browserMultiPlatform-6.svg", - "svgJsLight": "./pictogram/svgJs/light/browserMultiPlatform-6.js", - "svgJsDark": "./pictogram/svgJs/dark/browserMultiPlatform-6.js", - "pngLight": "./pictogram/png/light/browserMultiPlatform-6.png", - "pngDark": "./pictogram/png/dark/browserMultiPlatform-6.png" - }, - "version": 6 - }, "2:41457": { "type": "pictogram", "name": "coinFocus", @@ -10030,7 +10376,7 @@ "height": 48, "description": "calendar, percentage, taxes, money, schedule, organize, funds, %, 📆, 📅, 🗓", "createdAt": "2022-08-05T05:36:58.128Z", - "lastUpdated": "2025-12-01T18:13:24.337Z", + "lastUpdated": "2025-12-24T16:31:49.434Z", "outputs": { "svgLight": "./pictogram/svg/light/taxSeason-3.svg", "svgDark": "./pictogram/svg/dark/taxSeason-3.svg", @@ -10050,7 +10396,7 @@ "height": 48, "description": "exclamation mark, warning, alert, help, crucial, indication, red, serious, circle, emphasis, !, ❗️, error state", "createdAt": "2022-08-05T05:36:48.568Z", - "lastUpdated": "2025-12-01T18:17:00.384Z", + "lastUpdated": "2026-03-16T14:55:21.103Z", "outputs": { "svgLight": "./pictogram/svg/light/strongWarning-4.svg", "svgDark": "./pictogram/svg/dark/strongWarning-4.svg", @@ -24730,7 +25076,7 @@ "height": 240, "description": "", "createdAt": "2025-07-03T14:26:31.504Z", - "lastUpdated": "2025-08-07T22:11:25.420Z", + "lastUpdated": "2025-12-09T23:08:10.526Z", "outputs": { "svgLight": "./heroSquare/svg/light/baseCheck-1.svg", "svgDark": "./heroSquare/svg/dark/baseCheck-1.svg", @@ -25065,22 +25411,22 @@ "18659:332": { "type": "pictogram", "name": "baseCheckSmall", - "hash": "2cbCTxABh2HV0sqwablxHqO0FUb+Mm2pAR2Qv/0Jj8g=", + "hash": "1eBcdpSkIXZ1HODJdg7lZ3rx3YFXiAwY3/udaN+ccNM=", "width": 48, "height": 48, "description": "", "createdAt": "2025-07-03T14:26:31.306Z", - "lastUpdated": "2025-07-03T14:26:31.306Z", + "lastUpdated": "2026-01-31T00:40:08.679Z", "outputs": { - "svgLight": "./pictogram/svg/light/baseCheckSmall-0.svg", - "svgDark": "./pictogram/svg/dark/baseCheckSmall-0.svg", - "svgThemed": "./pictogram/svg/themeable/baseCheckSmall-0.svg", - "svgJsLight": "./pictogram/svgJs/light/baseCheckSmall-0.js", - "svgJsDark": "./pictogram/svgJs/dark/baseCheckSmall-0.js", - "pngLight": "./pictogram/png/light/baseCheckSmall-0.png", - "pngDark": "./pictogram/png/dark/baseCheckSmall-0.png" + "svgLight": "./pictogram/svg/light/baseCheckSmall-1.svg", + "svgDark": "./pictogram/svg/dark/baseCheckSmall-1.svg", + "svgThemed": "./pictogram/svg/themeable/baseCheckSmall-1.svg", + "svgJsLight": "./pictogram/svgJs/light/baseCheckSmall-1.js", + "svgJsDark": "./pictogram/svgJs/dark/baseCheckSmall-1.js", + "pngLight": "./pictogram/png/light/baseCheckSmall-1.png", + "pngDark": "./pictogram/png/dark/baseCheckSmall-1.png" }, - "version": 0 + "version": 1 }, "18659:338": { "type": "pictogram", @@ -27725,22 +28071,22 @@ "22761:60": { "type": "spotSquare", "name": "cryptoEconomyArrows", - "hash": "8NDrAp0bdIanQPmW0e56fCvfi9CZQmXy9xDvFoy3vp4=", + "hash": "r0edyUBAoWyp1GbifhqAEXYZhbrN8N8xjjNkZzH6YbE=", "width": 96, "height": 96, "description": "globe, international, economy, freedom, growth, crypto, economic, money, coins", "createdAt": "2025-10-24T18:53:50.479Z", - "lastUpdated": "2025-12-01T18:14:06.056Z", + "lastUpdated": "2026-01-28T22:56:40.771Z", "outputs": { - "svgLight": "./spotSquare/svg/light/cryptoEconomyArrows-1.svg", - "svgDark": "./spotSquare/svg/dark/cryptoEconomyArrows-1.svg", - "svgThemed": "./spotSquare/svg/themeable/cryptoEconomyArrows-1.svg", - "svgJsLight": "./spotSquare/svgJs/light/cryptoEconomyArrows-1.js", - "svgJsDark": "./spotSquare/svgJs/dark/cryptoEconomyArrows-1.js", - "pngLight": "./spotSquare/png/light/cryptoEconomyArrows-1.png", - "pngDark": "./spotSquare/png/dark/cryptoEconomyArrows-1.png" + "svgLight": "./spotSquare/svg/light/cryptoEconomyArrows-2.svg", + "svgDark": "./spotSquare/svg/dark/cryptoEconomyArrows-2.svg", + "svgThemed": "./spotSquare/svg/themeable/cryptoEconomyArrows-2.svg", + "svgJsLight": "./spotSquare/svgJs/light/cryptoEconomyArrows-2.js", + "svgJsDark": "./spotSquare/svgJs/dark/cryptoEconomyArrows-2.js", + "pngLight": "./spotSquare/png/light/cryptoEconomyArrows-2.png", + "pngDark": "./spotSquare/png/dark/cryptoEconomyArrows-2.png" }, - "version": 1 + "version": 2 }, "23235:456": { "type": "pictogram", @@ -28670,7 +29016,7 @@ "height": 48, "description": "", "createdAt": "2025-12-05T17:03:40.067Z", - "lastUpdated": "2025-12-05T17:03:40.067Z", + "lastUpdated": "2025-12-24T16:31:49.427Z", "outputs": { "svgLight": "./pictogram/svg/light/robot-0.svg", "svgDark": "./pictogram/svg/dark/robot-0.svg", @@ -28681,6 +29027,2846 @@ "pngDark": "./pictogram/png/dark/robot-0.png" }, "version": 0 + }, + "24785:58": { + "type": "heroSquare", + "name": "test", + "hash": "l28eqcD75twYfnHO/GUElQbRXe4eAiknPy2nQXLb1HQ=", + "width": 240, + "height": 240, + "description": "", + "createdAt": "2025-12-10T22:56:27.793Z", + "lastUpdated": "2025-12-10T22:56:27.793Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/test-0.svg", + "svgDark": "./heroSquare/svg/dark/test-0.svg", + "svgThemed": "./heroSquare/svg/themeable/test-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/test-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/test-0.js", + "pngLight": "./heroSquare/png/light/test-0.png", + "pngDark": "./heroSquare/png/dark/test-0.png" + }, + "version": 0 + }, + "25214:79": { + "type": "heroSquare", + "name": "borrowCoinsBtc", + "hash": "G+kBUhUcuSPmEP/QVVJKfh+q25FjL44+MVg64yr+YG8=", + "width": 240, + "height": 240, + "description": "", + "createdAt": "2025-12-24T16:31:49.400Z", + "lastUpdated": "2026-01-15T15:34:18.584Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/borrowCoinsBtc-0.svg", + "svgDark": "./heroSquare/svg/dark/borrowCoinsBtc-0.svg", + "svgThemed": "./heroSquare/svg/themeable/borrowCoinsBtc-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/borrowCoinsBtc-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/borrowCoinsBtc-0.js", + "pngLight": "./heroSquare/png/light/borrowCoinsBtc-0.png", + "pngDark": "./heroSquare/png/dark/borrowCoinsBtc-0.png" + }, + "version": 0 + }, + "25928:225": { + "type": "spotSquare", + "name": "goldSilverFutures", + "hash": "x4PU3BC/HM/X4MSb0H8MGSYBLPe7l0D5s6lM+7AZJfY=", + "width": 96, + "height": 96, + "description": "coin, coins, moon, more, empty state", + "createdAt": "2026-01-15T15:34:18.568Z", + "lastUpdated": "2026-01-15T15:34:18.568Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/goldSilverFutures-0.svg", + "svgDark": "./spotSquare/svg/dark/goldSilverFutures-0.svg", + "svgThemed": "./spotSquare/svg/themeable/goldSilverFutures-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/goldSilverFutures-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/goldSilverFutures-0.js", + "pngLight": "./spotSquare/png/light/goldSilverFutures-0.png", + "pngDark": "./spotSquare/png/dark/goldSilverFutures-0.png" + }, + "version": 0 + }, + "27184:33": { + "type": "pictogram", + "name": "commodities", + "hash": "j+3vV51Gr//kKcGYAlxNt9h11ravkL8I4VoEiCzPLnA=", + "width": 40, + "height": 40, + "description": "commodities", + "createdAt": "2026-01-28T22:56:40.760Z", + "lastUpdated": "2026-01-28T22:56:40.760Z", + "outputs": { + "svgLight": "./pictogram/svg/light/commodities-0.svg", + "svgDark": "./pictogram/svg/dark/commodities-0.svg", + "svgThemed": "./pictogram/svg/themeable/commodities-0.svg", + "svgJsLight": "./pictogram/svgJs/light/commodities-0.js", + "svgJsDark": "./pictogram/svgJs/dark/commodities-0.js", + "pngLight": "./pictogram/png/light/commodities-0.png", + "pngDark": "./pictogram/png/dark/commodities-0.png" + }, + "version": 0 + }, + "27602:11": { + "type": "pictogram", + "name": "arrowsUpDown", + "hash": "bO8qkgHq9vpn8vOOS7a6w9eVoHPN3lb2VvbYql9NaYk=", + "width": 40, + "height": 40, + "description": "pictogram, leverage, invest, prime, advanced, derive, arrow, triangles", + "createdAt": "2026-02-03T20:41:52.497Z", + "lastUpdated": "2026-02-03T21:27:09.620Z", + "outputs": { + "svgLight": "./pictogram/svg/light/arrowsUpDown-0.svg", + "svgDark": "./pictogram/svg/dark/arrowsUpDown-0.svg", + "svgThemed": "./pictogram/svg/themeable/arrowsUpDown-0.svg", + "svgJsLight": "./pictogram/svgJs/light/arrowsUpDown-0.js", + "svgJsDark": "./pictogram/svgJs/dark/arrowsUpDown-0.js", + "pngLight": "./pictogram/png/light/arrowsUpDown-0.png", + "pngDark": "./pictogram/png/dark/arrowsUpDown-0.png" + }, + "version": 0 + }, + "28377:29": { + "type": "pictogram", + "name": "pieChartWithArrow", + "hash": "5q0rW7pnRFYuntzNpwSSaQVCBC+u3tlfoWWTmls6JS0=", + "width": 48, + "height": 48, + "description": "", + "createdAt": "2026-03-02T18:23:13.233Z", + "lastUpdated": "2026-03-03T01:34:10.718Z", + "outputs": { + "svgLight": "./pictogram/svg/light/pieChartWithArrow-0.svg", + "svgDark": "./pictogram/svg/dark/pieChartWithArrow-0.svg", + "svgThemed": "./pictogram/svg/themeable/pieChartWithArrow-0.svg", + "svgJsLight": "./pictogram/svgJs/light/pieChartWithArrow-0.js", + "svgJsDark": "./pictogram/svgJs/dark/pieChartWithArrow-0.js", + "pngLight": "./pictogram/png/light/pieChartWithArrow-0.png", + "pngDark": "./pictogram/png/dark/pieChartWithArrow-0.png" + }, + "version": 0 + }, + "28377:47": { + "type": "spotSquare", + "name": "pieChartWithArrow", + "hash": "qz5S8tKbLlU2rCPdnHpizyxx7HfgRNn2H5zhltj0l1k=", + "width": 96, + "height": 96, + "description": "", + "createdAt": "2026-03-02T18:23:13.225Z", + "lastUpdated": "2026-03-03T01:34:10.727Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/pieChartWithArrow-0.svg", + "svgDark": "./spotSquare/svg/dark/pieChartWithArrow-0.svg", + "svgThemed": "./spotSquare/svg/themeable/pieChartWithArrow-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/pieChartWithArrow-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/pieChartWithArrow-0.js", + "pngLight": "./spotSquare/png/light/pieChartWithArrow-0.png", + "pngDark": "./spotSquare/png/dark/pieChartWithArrow-0.png" + }, + "version": 0 + }, + "28417:512": { + "type": "spotSquare", + "name": "pieChartWithArrowBlue", + "hash": "wh+4Ir4LzQUFvfPU9W/j+nuHZBCVmeqZMEr/bs0pAf4=", + "width": 96, + "height": 96, + "description": "", + "createdAt": "2026-03-03T21:55:09.217Z", + "lastUpdated": "2026-03-03T21:55:09.217Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/pieChartWithArrowBlue-0.svg", + "svgDark": "./spotSquare/svg/dark/pieChartWithArrowBlue-0.svg", + "svgThemed": "./spotSquare/svg/themeable/pieChartWithArrowBlue-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/pieChartWithArrowBlue-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/pieChartWithArrowBlue-0.js", + "pngLight": "./spotSquare/png/light/pieChartWithArrowBlue-0.png", + "pngDark": "./spotSquare/png/dark/pieChartWithArrowBlue-0.png" + }, + "version": 0 + }, + "28417:529": { + "type": "pictogram", + "name": "pieChartWithArrowBlue", + "hash": "9Zs6DU2qpejYXfjPcNrK3rctXxWSPAEutyYKtvszHks=", + "width": 48, + "height": 48, + "description": "", + "createdAt": "2026-03-03T21:56:10.265Z", + "lastUpdated": "2026-03-03T21:56:10.265Z", + "outputs": { + "svgLight": "./pictogram/svg/light/pieChartWithArrowBlue-0.svg", + "svgDark": "./pictogram/svg/dark/pieChartWithArrowBlue-0.svg", + "svgThemed": "./pictogram/svg/themeable/pieChartWithArrowBlue-0.svg", + "svgJsLight": "./pictogram/svgJs/light/pieChartWithArrowBlue-0.js", + "svgJsDark": "./pictogram/svgJs/dark/pieChartWithArrowBlue-0.js", + "pngLight": "./pictogram/png/light/pieChartWithArrowBlue-0.png", + "pngDark": "./pictogram/png/dark/pieChartWithArrowBlue-0.png" + }, + "version": 0 + }, + "28633:2306": { + "type": "pictogram", + "name": "download", + "hash": "BPADqIJljEtePqdV+1YEhDDP7AYKY7uUQxKXFheFEho=", + "width": 48, + "height": 48, + "description": "below, currency, money, arrow, downwards, down, direction, 👇, ⬇️, 🔻, 💰, 💸, 💵, 💶, 💷, 💴", + "createdAt": "2026-03-11T21:09:16.029Z", + "lastUpdated": "2026-04-07T23:04:46.229Z", + "outputs": { + "svgLight": "./pictogram/svg/light/download-1.svg", + "svgDark": "./pictogram/svg/dark/download-1.svg", + "svgThemed": "./pictogram/svg/themeable/download-1.svg", + "svgJsLight": "./pictogram/svgJs/light/download-1.js", + "svgJsDark": "./pictogram/svgJs/dark/download-1.js", + "pngLight": "./pictogram/png/light/download-1.png", + "pngDark": "./pictogram/png/dark/download-1.png" + }, + "version": 1 + }, + "28806:27": { + "type": "heroSquare", + "name": "cryptoPortfolioUsdc", + "hash": "n60WIslqP9FSyCf85tYm8heWUadUrRj1nD4aDgZJDs4=", + "width": 240, + "height": 240, + "description": "coin, folder, blue, yellow", + "createdAt": "2026-03-16T14:55:20.785Z", + "lastUpdated": "2026-03-16T14:55:20.785Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/cryptoPortfolioUsdc-0.svg", + "svgDark": "./heroSquare/svg/dark/cryptoPortfolioUsdc-0.svg", + "svgThemed": "./heroSquare/svg/themeable/cryptoPortfolioUsdc-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/cryptoPortfolioUsdc-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/cryptoPortfolioUsdc-0.js", + "pngLight": "./heroSquare/png/light/cryptoPortfolioUsdc-0.png", + "pngDark": "./heroSquare/png/dark/cryptoPortfolioUsdc-0.png" + }, + "version": 0 + }, + "28806:28": { + "type": "heroSquare", + "name": "tradingPerpetualsUsdc", + "hash": "uPWJynN6LsfLDFzQyt6Fji/o2hD+fSXAeybEm/aMt0w=", + "width": 240, + "height": 240, + "description": "", + "createdAt": "2026-03-16T14:55:20.861Z", + "lastUpdated": "2026-03-16T14:55:20.861Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/tradingPerpetualsUsdc-0.svg", + "svgDark": "./heroSquare/svg/dark/tradingPerpetualsUsdc-0.svg", + "svgThemed": "./heroSquare/svg/themeable/tradingPerpetualsUsdc-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/tradingPerpetualsUsdc-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/tradingPerpetualsUsdc-0.js", + "pngLight": "./heroSquare/png/light/tradingPerpetualsUsdc-0.png", + "pngDark": "./heroSquare/png/dark/tradingPerpetualsUsdc-0.png" + }, + "version": 0 + }, + "28806:29": { + "type": "spotSquare", + "name": "instantUnstaking", + "hash": "EA/pFwvwSmkzOsvBEepf9LIoTCV/hMqYwL7c32snnuw=", + "width": 96, + "height": 96, + "description": "", + "createdAt": "2026-03-16T14:55:20.585Z", + "lastUpdated": "2026-04-06T17:51:44.795Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instantUnstaking-1.svg", + "svgDark": "./spotSquare/svg/dark/instantUnstaking-1.svg", + "svgThemed": "./spotSquare/svg/themeable/instantUnstaking-1.svg", + "svgJsLight": "./spotSquare/svgJs/light/instantUnstaking-1.js", + "svgJsDark": "./spotSquare/svgJs/dark/instantUnstaking-1.js", + "pngLight": "./spotSquare/png/light/instantUnstaking-1.png", + "pngDark": "./spotSquare/png/dark/instantUnstaking-1.png" + }, + "version": 1 + }, + "28806:30": { + "type": "spotIcon", + "name": "instantAccess", + "hash": "Rv36iX07h8VkvptvHOhpxHMR5/pR492cPtGwRtjNK80=", + "width": 32, + "height": 32, + "description": "", + "createdAt": "2026-03-16T14:55:20.820Z", + "lastUpdated": "2026-04-01T17:18:37.445Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instantAccess-1.svg", + "svgDark": "./spotIcon/svg/dark/instantAccess-1.svg", + "svgThemed": "./spotIcon/svg/themeable/instantAccess-1.svg", + "svgJsLight": "./spotIcon/svgJs/light/instantAccess-1.js", + "svgJsDark": "./spotIcon/svgJs/dark/instantAccess-1.js", + "pngLight": "./spotIcon/png/light/instantAccess-1.png", + "pngDark": "./spotIcon/png/dark/instantAccess-1.png" + }, + "version": 1 + }, + "28806:31": { + "type": "spotRectangle", + "name": "stakingUpgrade", + "hash": "RNpN2oqTntoRXzRl3c8tusMiZlGSiH1Rzg0v6RiGkt0=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-16T14:55:20.868Z", + "lastUpdated": "2026-03-16T14:55:20.868Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/stakingUpgrade-0.svg", + "svgDark": "./spotRectangle/svg/dark/stakingUpgrade-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/stakingUpgrade-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/stakingUpgrade-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/stakingUpgrade-0.js", + "pngLight": "./spotRectangle/png/light/stakingUpgrade-0.png", + "pngDark": "./spotRectangle/png/dark/stakingUpgrade-0.png" + }, + "version": 0 + }, + "28806:32": { + "type": "heroSquare", + "name": "instoWalletSecurity", + "hash": "DeJcgHnR6olum5glCOV396LiEOjmV3SOIpkFd4NK6pA=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, lock, circle, square, blue, wallet, security", + "createdAt": "2026-03-16T14:55:20.730Z", + "lastUpdated": "2026-03-19T18:42:00.221Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoWalletSecurity-0.svg", + "svgDark": "./heroSquare/svg/dark/instoWalletSecurity-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoWalletSecurity-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoWalletSecurity-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoWalletSecurity-0.js", + "pngLight": "./heroSquare/png/light/instoWalletSecurity-0.png", + "pngDark": "./heroSquare/png/dark/instoWalletSecurity-0.png" + }, + "version": 0 + }, + "28806:33": { + "type": "heroSquare", + "name": "instoAddBankAccount", + "hash": "XaMeEKJToe8FnkribzoxeJdCis7xUDZICgeb0OpA/G8=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.814Z", + "lastUpdated": "2026-03-19T18:42:00.242Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoAddBankAccount-0.svg", + "svgDark": "./heroSquare/svg/dark/instoAddBankAccount-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoAddBankAccount-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoAddBankAccount-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoAddBankAccount-0.js", + "pngLight": "./heroSquare/png/light/instoAddBankAccount-0.png", + "pngDark": "./heroSquare/png/dark/instoAddBankAccount-0.png" + }, + "version": 0 + }, + "28806:34": { + "type": "heroSquare", + "name": "instoAdd2Fa", + "hash": "FpyBEMS0gvIzPdt1UwH21CxfRd54FtqqNglDfyb5r/s=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, 2FA, Secure, security, two, factor, authentication, safe, safety, add, plus, lock, combination, password", + "createdAt": "2026-03-16T14:55:21.034Z", + "lastUpdated": "2026-03-19T18:41:59.983Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoAdd2Fa-0.svg", + "svgDark": "./heroSquare/svg/dark/instoAdd2Fa-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoAdd2Fa-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoAdd2Fa-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoAdd2Fa-0.js", + "pngLight": "./heroSquare/png/light/instoAdd2Fa-0.png", + "pngDark": "./heroSquare/png/dark/instoAdd2Fa-0.png" + }, + "version": 0 + }, + "28806:36": { + "type": "heroSquare", + "name": "instoPhoneUnknown", + "hash": "N2n4hrl9M0CUyiERQd2T3n7iYVqbOMPmTWfx+kk08oM=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, phone, unknown, question mark, ❓ , ❔", + "createdAt": "2026-03-16T14:55:20.772Z", + "lastUpdated": "2026-03-19T18:42:00.071Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoPhoneUnknown-0.svg", + "svgDark": "./heroSquare/svg/dark/instoPhoneUnknown-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoPhoneUnknown-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoPhoneUnknown-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoPhoneUnknown-0.js", + "pngLight": "./heroSquare/png/light/instoPhoneUnknown-0.png", + "pngDark": "./heroSquare/png/dark/instoPhoneUnknown-0.png" + }, + "version": 0 + }, + "28806:37": { + "type": "heroSquare", + "name": "instoCoinbaseOneProtectedCrypto", + "hash": "ttLIpGE9rY8GNURE/cLYF61QcitADIl3cxHIBfO2J4w=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coinbaseone, one, shield, protect, protected", + "createdAt": "2026-03-16T14:55:20.793Z", + "lastUpdated": "2026-04-06T17:51:44.802Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoCoinbaseOneProtectedCrypto-1.svg", + "svgDark": "./heroSquare/svg/dark/instoCoinbaseOneProtectedCrypto-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoCoinbaseOneProtectedCrypto-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoCoinbaseOneProtectedCrypto-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoCoinbaseOneProtectedCrypto-1.js", + "pngLight": "./heroSquare/png/light/instoCoinbaseOneProtectedCrypto-1.png", + "pngDark": "./heroSquare/png/dark/instoCoinbaseOneProtectedCrypto-1.png" + }, + "version": 1 + }, + "28806:38": { + "type": "heroSquare", + "name": "instoDocumentSuccess", + "hash": "EvryjLLcYKzRG9Aj+hw3PCqiGGGUykXNAXGIBNGK4T0=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, Documents, reviewed, success, checkmark, confirm, complete, ✅, success state", + "createdAt": "2026-03-16T14:55:20.800Z", + "lastUpdated": "2026-04-01T17:18:37.471Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoDocumentSuccess-1.svg", + "svgDark": "./heroSquare/svg/dark/instoDocumentSuccess-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoDocumentSuccess-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoDocumentSuccess-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoDocumentSuccess-1.js", + "pngLight": "./heroSquare/png/light/instoDocumentSuccess-1.png", + "pngDark": "./heroSquare/png/dark/instoDocumentSuccess-1.png" + }, + "version": 1 + }, + "28806:39": { + "type": "heroSquare", + "name": "instoPrivateKey", + "hash": "C/O5xR20IXttRgB3vwj5TeP6o6qoTwaiFFIRzuQj5+o=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, private, key, network, access, secure, encryption, encrypted, acces", + "createdAt": "2026-03-16T14:55:20.875Z", + "lastUpdated": "2026-04-01T17:18:37.423Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoPrivateKey-1.svg", + "svgDark": "./heroSquare/svg/dark/instoPrivateKey-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoPrivateKey-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoPrivateKey-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoPrivateKey-1.js", + "pngLight": "./heroSquare/png/light/instoPrivateKey-1.png", + "pngDark": "./heroSquare/png/dark/instoPrivateKey-1.png" + }, + "version": 1 + }, + "28806:40": { + "type": "heroSquare", + "name": "instoGovernance", + "hash": "P2ncyec/sPl7MfPydgp6Uobb8g1G7AO3oHGNVZ1ixPU=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, governance, vote, staking, proposal, ballot, box, yes, no, maybe, so, coin", + "createdAt": "2026-03-16T14:55:20.705Z", + "lastUpdated": "2026-03-19T18:42:00.011Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoGovernance-0.svg", + "svgDark": "./heroSquare/svg/dark/instoGovernance-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoGovernance-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoGovernance-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoGovernance-0.js", + "pngLight": "./heroSquare/png/light/instoGovernance-0.png", + "pngDark": "./heroSquare/png/dark/instoGovernance-0.png" + }, + "version": 0 + }, + "28806:41": { + "type": "heroSquare", + "name": "instoEthStakingUpsell", + "hash": "ETnuascHcEnB4AtwLP9QFeXBWJrSBBb5cBgEnK8TOT4=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, staking, ethereum, upsell, hand, earn, interest, eth2, 2.0, sparkles, ✨", + "createdAt": "2026-03-16T14:55:20.711Z", + "lastUpdated": "2026-03-19T18:41:59.924Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoEthStakingUpsell-0.svg", + "svgDark": "./heroSquare/svg/dark/instoEthStakingUpsell-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoEthStakingUpsell-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoEthStakingUpsell-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoEthStakingUpsell-0.js", + "pngLight": "./heroSquare/png/light/instoEthStakingUpsell-0.png", + "pngDark": "./heroSquare/png/dark/instoEthStakingUpsell-0.png" + }, + "version": 0 + }, + "28806:42": { + "type": "heroSquare", + "name": "instoEthStakingRewards", + "hash": "yI1Oux/Wug/Hyf3U0d+4H5/k9BGL2u2+PhX1t5EE4Oc=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, staking, trade, coins, stars, eth2, stacks of coins, graph", + "createdAt": "2026-03-16T14:55:20.855Z", + "lastUpdated": "2026-03-19T18:42:00.247Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoEthStakingRewards-0.svg", + "svgDark": "./heroSquare/svg/dark/instoEthStakingRewards-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoEthStakingRewards-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoEthStakingRewards-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoEthStakingRewards-0.js", + "pngLight": "./heroSquare/png/light/instoEthStakingRewards-0.png", + "pngDark": "./heroSquare/png/dark/instoEthStakingRewards-0.png" + }, + "version": 0 + }, + "28806:43": { + "type": "heroSquare", + "name": "instoStaking", + "hash": "oLRhNL7lu8PDtK7+YhH/nt9/oRcnlV0OFVFTN8C9nLg=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, circles, coins, yellow, blue, graph, staking", + "createdAt": "2026-03-16T14:55:20.889Z", + "lastUpdated": "2026-03-19T18:42:00.405Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoStaking-0.svg", + "svgDark": "./heroSquare/svg/dark/instoStaking-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoStaking-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoStaking-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoStaking-0.js", + "pngLight": "./heroSquare/png/light/instoStaking-0.png", + "pngDark": "./heroSquare/png/dark/instoStaking-0.png" + }, + "version": 0 + }, + "28806:44": { + "type": "heroSquare", + "name": "instoOpenEmail", + "hash": "XelfRCc36iBuLEtwhg1njwLug5UQGdkhIeIaZY1lFCs=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, open, email, envelope, letter, 📧 📥 📤 ✉ 📩 📨", + "createdAt": "2026-03-16T14:55:20.896Z", + "lastUpdated": "2026-04-01T17:18:37.482Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoOpenEmail-1.svg", + "svgDark": "./heroSquare/svg/dark/instoOpenEmail-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoOpenEmail-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoOpenEmail-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoOpenEmail-1.js", + "pngLight": "./heroSquare/png/light/instoOpenEmail-1.png", + "pngDark": "./heroSquare/png/dark/instoOpenEmail-1.png" + }, + "version": 1 + }, + "28806:45": { + "type": "heroSquare", + "name": "instoPrimeStaking", + "hash": "r2QtnlSxq0qkppS8wSQpZ9ZpJwxDzIfED8KxQWKxGSA=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor, Prime, Staking, Stake, Crypto, Interest, Earn, Coins, Assets, Circles, Universe, sparkles, ✨", + "createdAt": "2026-03-16T14:55:20.882Z", + "lastUpdated": "2026-03-19T18:42:00.156Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoPrimeStaking-0.svg", + "svgDark": "./heroSquare/svg/dark/instoPrimeStaking-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoPrimeStaking-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoPrimeStaking-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoPrimeStaking-0.js", + "pngLight": "./heroSquare/png/light/instoPrimeStaking-0.png", + "pngDark": "./heroSquare/png/dark/instoPrimeStaking-0.png" + }, + "version": 0 + }, + "28806:46": { + "type": "heroSquare", + "name": "instoEarnGlobe", + "hash": "JZjT51OYZLsDnjV2SHROMjWW4HLlu6eoNfUJLLarYNY=", + "width": 240.00250244140625, + "height": 239.99951171875, + "description": "insto, prime, negroni, orange, institutional, institutional investor, earn, globe, coin, percent, staking, yield, assets, circle, international", + "createdAt": "2026-03-16T14:55:20.684Z", + "lastUpdated": "2026-03-19T18:42:00.097Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoEarnGlobe-0.svg", + "svgDark": "./heroSquare/svg/dark/instoEarnGlobe-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoEarnGlobe-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoEarnGlobe-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoEarnGlobe-0.js", + "pngLight": "./heroSquare/png/light/instoEarnGlobe-0.png", + "pngDark": "./heroSquare/png/dark/instoEarnGlobe-0.png" + }, + "version": 0 + }, + "28806:47": { + "type": "heroSquare", + "name": "instoStakingMissedReturns", + "hash": "Dv6x/E6P9ZwyMzKQ9vNqFzYUQn32mRipZf039HiGGis=", + "width": 240.0001220703125, + "height": 240.00021362304688, + "description": "insto, prime, negroni, orange, institutional, institutional investor, staking, earn, yield, interest, would, have, missed, out, on, coins, clock, time, money, grow", + "createdAt": "2026-03-16T14:55:20.737Z", + "lastUpdated": "2026-04-01T17:18:37.413Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoStakingMissedReturns-1.svg", + "svgDark": "./heroSquare/svg/dark/instoStakingMissedReturns-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoStakingMissedReturns-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoStakingMissedReturns-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoStakingMissedReturns-1.js", + "pngLight": "./heroSquare/png/light/instoStakingMissedReturns-1.png", + "pngDark": "./heroSquare/png/dark/instoStakingMissedReturns-1.png" + }, + "version": 1 + }, + "28806:48": { + "type": "heroSquare", + "name": "instoOnChain", + "hash": "g2J7zRHsoM4mu46YlacHXkULE4FxwMZQXN7drYUQE3U=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.765Z", + "lastUpdated": "2026-04-01T17:18:37.477Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoOnChain-2.svg", + "svgDark": "./heroSquare/svg/dark/instoOnChain-2.svg", + "svgThemed": "./heroSquare/svg/themeable/instoOnChain-2.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoOnChain-2.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoOnChain-2.js", + "pngLight": "./heroSquare/png/light/instoOnChain-2.png", + "pngDark": "./heroSquare/png/dark/instoOnChain-2.png" + }, + "version": 2 + }, + "28806:49": { + "type": "heroSquare", + "name": "instoSecurityKeyAuth", + "hash": "E/l8FwYkfiAg+y/TxQCTL9MvdFWYhwuQoLR9h9cbRZU=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.518Z", + "lastUpdated": "2026-03-19T18:42:00.362Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoSecurityKeyAuth-0.svg", + "svgDark": "./heroSquare/svg/dark/instoSecurityKeyAuth-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoSecurityKeyAuth-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoSecurityKeyAuth-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoSecurityKeyAuth-0.js", + "pngLight": "./heroSquare/png/light/instoSecurityKeyAuth-0.png", + "pngDark": "./heroSquare/png/dark/instoSecurityKeyAuth-0.png" + }, + "version": 0 + }, + "28806:51": { + "type": "heroSquare", + "name": "instoWeb3MobileSetupStart", + "hash": "9tNBvUOUe9I2rBA4mv+GxpaRBl6QYKeUziU34SKowi8=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.807Z", + "lastUpdated": "2026-03-19T18:42:00.237Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoWeb3MobileSetupStart-0.svg", + "svgDark": "./heroSquare/svg/dark/instoWeb3MobileSetupStart-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoWeb3MobileSetupStart-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoWeb3MobileSetupStart-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoWeb3MobileSetupStart-0.js", + "pngLight": "./heroSquare/png/light/instoWeb3MobileSetupStart-0.png", + "pngDark": "./heroSquare/png/dark/instoWeb3MobileSetupStart-0.png" + }, + "version": 0 + }, + "28806:52": { + "type": "heroSquare", + "name": "instoRequestSent", + "hash": "K2cK2Rs90zxlhsIg5FPmYTl4lAejzb5cFthA9yhGdpQ=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.745Z", + "lastUpdated": "2026-04-01T17:18:37.403Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoRequestSent-1.svg", + "svgDark": "./heroSquare/svg/dark/instoRequestSent-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoRequestSent-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoRequestSent-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoRequestSent-1.js", + "pngLight": "./heroSquare/png/light/instoRequestSent-1.png", + "pngDark": "./heroSquare/png/dark/instoRequestSent-1.png" + }, + "version": 1 + }, + "28806:53": { + "type": "heroSquare", + "name": "instoEnableBiometrics", + "hash": "hUIVx1Yvw4LOA5pD+HfuzO2iGWzQm3ju4kQj1jznxRo=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.057Z", + "lastUpdated": "2026-03-19T18:42:00.226Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoEnableBiometrics-0.svg", + "svgDark": "./heroSquare/svg/dark/instoEnableBiometrics-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoEnableBiometrics-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoEnableBiometrics-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoEnableBiometrics-0.js", + "pngLight": "./heroSquare/png/light/instoEnableBiometrics-0.png", + "pngDark": "./heroSquare/png/dark/instoEnableBiometrics-0.png" + }, + "version": 0 + }, + "28806:54": { + "type": "heroSquare", + "name": "instoKeyGenerationPending", + "hash": "GqnFcvii8yeGBTjB4r1gAAhIXhaSk2Qxa7BeH0M0hR8=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.848Z", + "lastUpdated": "2026-03-19T18:42:00.415Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoKeyGenerationPending-0.svg", + "svgDark": "./heroSquare/svg/dark/instoKeyGenerationPending-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoKeyGenerationPending-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoKeyGenerationPending-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoKeyGenerationPending-0.js", + "pngLight": "./heroSquare/png/light/instoKeyGenerationPending-0.png", + "pngDark": "./heroSquare/png/dark/instoKeyGenerationPending-0.png" + }, + "version": 0 + }, + "28806:55": { + "type": "heroSquare", + "name": "instoWallet", + "hash": "cFhvRv5VpWHHY0Kn7uZL2uOJeU4kVdwV4/HKOf8o+pw=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.834Z", + "lastUpdated": "2026-03-19T18:42:00.119Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoWallet-0.svg", + "svgDark": "./heroSquare/svg/dark/instoWallet-0.svg", + "svgThemed": "./heroSquare/svg/themeable/instoWallet-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoWallet-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoWallet-0.js", + "pngLight": "./heroSquare/png/light/instoWallet-0.png", + "pngDark": "./heroSquare/png/dark/instoWallet-0.png" + }, + "version": 0 + }, + "28806:56": { + "type": "heroSquare", + "name": "instoKeyGenerationComplete", + "hash": "T+o5k8tQkm1f7Nsen8FU115BENxGac4eq3G+EkM4dBo=", + "width": 240, + "height": 240, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.996Z", + "lastUpdated": "2026-04-01T17:18:37.440Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/instoKeyGenerationComplete-1.svg", + "svgDark": "./heroSquare/svg/dark/instoKeyGenerationComplete-1.svg", + "svgThemed": "./heroSquare/svg/themeable/instoKeyGenerationComplete-1.svg", + "svgJsLight": "./heroSquare/svgJs/light/instoKeyGenerationComplete-1.js", + "svgJsDark": "./heroSquare/svgJs/dark/instoKeyGenerationComplete-1.js", + "pngLight": "./heroSquare/png/light/instoKeyGenerationComplete-1.png", + "pngDark": "./heroSquare/png/dark/instoKeyGenerationComplete-1.png" + }, + "version": 1 + }, + "28806:57": { + "type": "spotRectangle", + "name": "insto", + "hash": "U0IIl3kjXtekc5BkZPKLOi++PwIdCpy3gz23kFeRMck=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.981Z", + "lastUpdated": "2026-03-19T18:42:00.018Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/insto-0.svg", + "svgDark": "./spotRectangle/svg/dark/insto-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/insto-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/insto-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/insto-0.js", + "pngLight": "./spotRectangle/png/light/insto-0.png", + "pngDark": "./spotRectangle/png/dark/insto-0.png" + }, + "version": 0 + }, + "28806:58": { + "type": "spotRectangle", + "name": "instoPrimeStaking", + "hash": "JC+Oke9V4PW67sTQmo7znBey6di/ZGf23VQGDqiD3U4=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, Prime, Staking, Stake, Crypto, Interest, Earn, Coins, Assets, Circles, Universe, sparkles, ✨", + "createdAt": "2026-03-16T14:55:20.758Z", + "lastUpdated": "2026-03-19T18:41:59.946Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoPrimeStaking-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoPrimeStaking-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoPrimeStaking-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoPrimeStaking-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoPrimeStaking-0.js", + "pngLight": "./spotRectangle/png/light/instoPrimeStaking-0.png", + "pngDark": "./spotRectangle/png/dark/instoPrimeStaking-0.png" + }, + "version": 0 + }, + "28806:59": { + "type": "spotRectangle", + "name": "instoStaking", + "hash": "Hj3i5hX6dCfdj0zEKXvPfVG3CadT4TGdAGBi7VaJ7U0=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coins, chart, stake, staking, liquid, earn, more, finance, graph, bar", + "createdAt": "2026-03-16T14:55:20.988Z", + "lastUpdated": "2026-03-19T18:41:59.951Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoStaking-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoStaking-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoStaking-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoStaking-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoStaking-0.js", + "pngLight": "./spotRectangle/png/light/instoStaking-0.png", + "pngDark": "./spotRectangle/png/dark/instoStaking-0.png" + }, + "version": 0 + }, + "28806:60": { + "type": "spotRectangle", + "name": "instoEthStakingMovement", + "hash": "VAZD4NmMZrn4LJ3ArZADUy0gS9PIdBspb+oG994avZU=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, staking, send, transfer, circles, movement, forward, exciting, 🟣, 🟢, 🔵", + "createdAt": "2026-03-16T14:55:20.932Z", + "lastUpdated": "2026-03-19T18:42:00.076Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoEthStakingMovement-1.svg", + "svgDark": "./spotRectangle/svg/dark/instoEthStakingMovement-1.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoEthStakingMovement-1.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoEthStakingMovement-1.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoEthStakingMovement-1.js", + "pngLight": "./spotRectangle/png/light/instoEthStakingMovement-1.png", + "pngDark": "./spotRectangle/png/dark/instoEthStakingMovement-1.png" + }, + "version": 1 + }, + "28806:61": { + "type": "spotRectangle", + "name": "instoGetStartedInMinutes", + "hash": "hz3ahFtLPuulmYMf2uaEzaf3IKRw4HcTr65LrGwJxFU=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, get, started, stopwatch, clock, time, fast, get, going, please", + "createdAt": "2026-03-16T14:55:20.967Z", + "lastUpdated": "2026-03-19T18:41:59.957Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoGetStartedInMinutes-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoGetStartedInMinutes-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoGetStartedInMinutes-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoGetStartedInMinutes-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoGetStartedInMinutes-0.js", + "pngLight": "./spotRectangle/png/light/instoGetStartedInMinutes-0.png", + "pngDark": "./spotRectangle/png/dark/instoGetStartedInMinutes-0.png" + }, + "version": 0 + }, + "28806:62": { + "type": "spotRectangle", + "name": "instoCurrency", + "hash": "Ezl7vmgDdRHoe1eD/1W6jAthryhkj+pCgT4rNQoWTiI=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.004Z", + "lastUpdated": "2026-03-19T18:42:00.232Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoCurrency-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoCurrency-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoCurrency-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoCurrency-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoCurrency-0.js", + "pngLight": "./spotRectangle/png/light/instoCurrency-0.png", + "pngDark": "./spotRectangle/png/dark/instoCurrency-0.png" + }, + "version": 0 + }, + "28806:63": { + "type": "spotRectangle", + "name": "instoSemiCustodial", + "hash": "7zv//Q6pFsSSqmpRWTduIwaJ5TYnrDXmCP6NccispuE=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, semi, custodial, semi custodial, bank, user, avatar, coin", + "createdAt": "2026-03-16T14:55:20.717Z", + "lastUpdated": "2026-03-19T18:41:59.962Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoSemiCustodial-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoSemiCustodial-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoSemiCustodial-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoSemiCustodial-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoSemiCustodial-0.js", + "pngLight": "./spotRectangle/png/light/instoSemiCustodial-0.png", + "pngDark": "./spotRectangle/png/dark/instoSemiCustodial-0.png" + }, + "version": 0 + }, + "28806:64": { + "type": "spotRectangle", + "name": "instoCryptoAndMore", + "hash": "NDE3S2JO42hzVQsPnUYF6pz8VFxg8l45C5CQWho4Gzw=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coin, coins, moon, more, empty state", + "createdAt": "2026-03-16T14:55:20.724Z", + "lastUpdated": "2026-04-01T17:18:37.487Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoCryptoAndMore-2.svg", + "svgDark": "./spotRectangle/svg/dark/instoCryptoAndMore-2.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoCryptoAndMore-2.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoCryptoAndMore-2.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoCryptoAndMore-2.js", + "pngLight": "./spotRectangle/png/light/instoCryptoAndMore-2.png", + "pngDark": "./spotRectangle/png/dark/instoCryptoAndMore-2.png" + }, + "version": 2 + }, + "28806:65": { + "type": "spotRectangle", + "name": "instoEmptyTrading", + "hash": "y70yKKKG4rOJco7uVhwjQC2oGpC4ez6GHFaq+9E295o=", + "width": 240, + "height": 120, + "description": "insto, prime, negroni, orange, institutional, institutional investor, empty state, trading, exchange", + "createdAt": "2026-03-16T14:55:20.652Z", + "lastUpdated": "2026-04-01T17:29:48.568Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoEmptyTrading-1.svg", + "svgDark": "./spotRectangle/svg/dark/instoEmptyTrading-1.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoEmptyTrading-1.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoEmptyTrading-1.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoEmptyTrading-1.js", + "pngLight": "./spotRectangle/png/light/instoEmptyTrading-1.png", + "pngDark": "./spotRectangle/png/dark/instoEmptyTrading-1.png" + }, + "version": 1 + }, + "28806:66": { + "type": "spotSquare", + "name": "instoEthStaking", + "hash": "XZW4YNFFjs2TrPDnTwR5zAfruBazFnetdoxB1u8XffE=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, ethereum, 2.0, staking, crypto, currency, earn, yield, interest, growth, make, money, asset, increase, reward", + "createdAt": "2026-03-16T14:55:20.669Z", + "lastUpdated": "2026-03-19T18:42:00.277Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoEthStaking-0.svg", + "svgDark": "./spotSquare/svg/dark/instoEthStaking-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoEthStaking-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoEthStaking-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoEthStaking-0.js", + "pngLight": "./spotSquare/png/light/instoEthStaking-0.png", + "pngDark": "./spotSquare/png/dark/instoEthStaking-0.png" + }, + "version": 0 + }, + "28806:67": { + "type": "spotSquare", + "name": "instoStaking", + "hash": "aJAYl1SS+aXUUz0aJ7CBbxwCDuXL2t2Olrtv6lI+Vcs=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coins, chart, stake, staking, liquid, earn, more, finance, graph, bar", + "createdAt": "2026-03-16T14:55:20.659Z", + "lastUpdated": "2026-03-19T18:42:00.174Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoStaking-0.svg", + "svgDark": "./spotSquare/svg/dark/instoStaking-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoStaking-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoStaking-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoStaking-0.js", + "pngLight": "./spotSquare/png/light/instoStaking-0.png", + "pngDark": "./spotSquare/png/dark/instoStaking-0.png" + }, + "version": 0 + }, + "28806:68": { + "type": "spotSquare", + "name": "instoPrimeStaking", + "hash": "aTB+qNSPUGgdjRNWqTK3ANT+fJXXpW6HNyHOZ7Ngzz0=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, Prime, Staking, Stake, Crypto, Interest, Earn, Coins, Assets, Circles, Universe, sparkles, ✨", + "createdAt": "2026-03-16T14:55:20.623Z", + "lastUpdated": "2026-03-19T18:42:00.168Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoPrimeStaking-0.svg", + "svgDark": "./spotSquare/svg/dark/instoPrimeStaking-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoPrimeStaking-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoPrimeStaking-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoPrimeStaking-0.js", + "pngLight": "./spotSquare/png/light/instoPrimeStaking-0.png", + "pngDark": "./spotSquare/png/dark/instoPrimeStaking-0.png" + }, + "version": 0 + }, + "28806:69": { + "type": "spotSquare", + "name": "instoEthStakingRewards", + "hash": "3botCxHzI9t0G2in4itb9fyEtvj2bKmpbQm/Io2P+6Y=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, staking, trade, coins, stars, eth2, stacks of coins, graph", + "createdAt": "2026-03-16T14:55:20.638Z", + "lastUpdated": "2026-03-19T18:42:00.378Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoEthStakingRewards-0.svg", + "svgDark": "./spotSquare/svg/dark/instoEthStakingRewards-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoEthStakingRewards-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoEthStakingRewards-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoEthStakingRewards-0.js", + "pngLight": "./spotSquare/png/light/instoEthStakingRewards-0.png", + "pngDark": "./spotSquare/png/dark/instoEthStakingRewards-0.png" + }, + "version": 0 + }, + "28806:70": { + "type": "spotSquare", + "name": "instoPixDeposits", + "hash": "E4pDSi4hyIHRr7Wzlk/6PL3GCifK5OUHuMdy0KF0Vnk=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, PIX, Deposits, bank, coin, brazil, south, america, latam, arrow", + "createdAt": "2026-03-16T14:55:21.087Z", + "lastUpdated": "2026-03-19T18:42:00.389Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoPixDeposits-0.svg", + "svgDark": "./spotSquare/svg/dark/instoPixDeposits-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoPixDeposits-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoPixDeposits-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoPixDeposits-0.js", + "pngLight": "./spotSquare/png/light/instoPixDeposits-0.png", + "pngDark": "./spotSquare/png/dark/instoPixDeposits-0.png" + }, + "version": 0 + }, + "28806:71": { + "type": "spotSquare", + "name": "instoDappWallet", + "hash": "+apiUyU3wOQZfYLScv6zr3dpxCTZnUakmoiI7pwhGyE=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, dappwallet, wallet, 🌐, web3", + "createdAt": "2026-03-16T14:55:21.079Z", + "lastUpdated": "2026-03-19T18:42:00.092Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoDappWallet-1.svg", + "svgDark": "./spotSquare/svg/dark/instoDappWallet-1.svg", + "svgThemed": "./spotSquare/svg/themeable/instoDappWallet-1.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoDappWallet-1.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoDappWallet-1.js", + "pngLight": "./spotSquare/png/light/instoDappWallet-1.png", + "pngDark": "./spotSquare/png/dark/instoDappWallet-1.png" + }, + "version": 1 + }, + "28806:72": { + "type": "spotSquare", + "name": "instoWaiting", + "hash": "k1OxbtDV4KOtKrSPLlyQmRY8qvxfINN5UNxq7O7HZxE=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.072Z", + "lastUpdated": "2026-04-01T17:18:37.427Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoWaiting-2.svg", + "svgDark": "./spotSquare/svg/dark/instoWaiting-2.svg", + "svgThemed": "./spotSquare/svg/themeable/instoWaiting-2.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoWaiting-2.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoWaiting-2.js", + "pngLight": "./spotSquare/png/light/instoWaiting-2.png", + "pngDark": "./spotSquare/png/dark/instoWaiting-2.png" + }, + "version": 2 + }, + "28806:73": { + "type": "spotSquare", + "name": "instoSecurityKey", + "hash": "SYHcII9Vbgo/0YnOmoGvqT7IL4EeLSzLdx+oiMsgrFo=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.065Z", + "lastUpdated": "2026-03-19T18:41:59.941Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoSecurityKey-1.svg", + "svgDark": "./spotSquare/svg/dark/instoSecurityKey-1.svg", + "svgThemed": "./spotSquare/svg/themeable/instoSecurityKey-1.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoSecurityKey-1.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoSecurityKey-1.js", + "pngLight": "./spotSquare/png/light/instoSecurityKey-1.png", + "pngDark": "./spotSquare/png/dark/instoSecurityKey-1.png" + }, + "version": 1 + }, + "28806:74": { + "type": "spotSquare", + "name": "instoSideChainSide", + "hash": "ggiWlUwAXn0QXmQHXVYJs7B66EF3ARq34/D27MCnlE4=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor, chain, hexagon, connections, blue", + "createdAt": "2026-03-16T14:55:21.027Z", + "lastUpdated": "2026-03-19T18:42:00.162Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoSideChainSide-0.svg", + "svgDark": "./spotSquare/svg/dark/instoSideChainSide-0.svg", + "svgThemed": "./spotSquare/svg/themeable/instoSideChainSide-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoSideChainSide-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoSideChainSide-0.js", + "pngLight": "./spotSquare/png/light/instoSideChainSide-0.png", + "pngDark": "./spotSquare/png/dark/instoSideChainSide-0.png" + }, + "version": 0 + }, + "28806:75": { + "type": "spotSquare", + "name": "instoUbiKey", + "hash": "P7Mjuq5GrJmmNn4tMVisMDiBYHwamoCN5WhAPrQXxZc=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.019Z", + "lastUpdated": "2026-04-01T17:29:48.556Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoUbiKey-1.svg", + "svgDark": "./spotSquare/svg/dark/instoUbiKey-1.svg", + "svgThemed": "./spotSquare/svg/themeable/instoUbiKey-1.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoUbiKey-1.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoUbiKey-1.js", + "pngLight": "./spotSquare/png/light/instoUbiKey-1.png", + "pngDark": "./spotSquare/png/dark/instoUbiKey-1.png" + }, + "version": 1 + }, + "28806:76": { + "type": "spotSquare", + "name": "instoAuthenticatorProgress", + "hash": "mU+JmsJXCnNrjE6Vxh45tOQeC++BFgBXGZ2uX57zTxM=", + "width": 96, + "height": 96, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.778Z", + "lastUpdated": "2026-04-01T17:18:37.457Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/instoAuthenticatorProgress-2.svg", + "svgDark": "./spotSquare/svg/dark/instoAuthenticatorProgress-2.svg", + "svgThemed": "./spotSquare/svg/themeable/instoAuthenticatorProgress-2.svg", + "svgJsLight": "./spotSquare/svgJs/light/instoAuthenticatorProgress-2.js", + "svgJsDark": "./spotSquare/svgJs/dark/instoAuthenticatorProgress-2.js", + "pngLight": "./spotSquare/png/light/instoAuthenticatorProgress-2.png", + "pngDark": "./spotSquare/png/dark/instoAuthenticatorProgress-2.png" + }, + "version": 2 + }, + "28806:77": { + "type": "pictogram", + "name": "instoAuthenticatorProgress", + "hash": "e3oITDv0bk99hCMmGmv1U0ghU6cW6FtrU/x+dQFTDI8=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, trust, true, genuine, actual, verification", + "createdAt": "2026-03-16T14:55:20.974Z", + "lastUpdated": "2026-03-19T18:42:00.142Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoAuthenticatorProgress-0.svg", + "svgDark": "./pictogram/svg/dark/instoAuthenticatorProgress-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoAuthenticatorProgress-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoAuthenticatorProgress-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoAuthenticatorProgress-0.js", + "pngLight": "./pictogram/png/light/instoAuthenticatorProgress-0.png", + "pngDark": "./pictogram/png/dark/instoAuthenticatorProgress-0.png" + }, + "version": 0 + }, + "28806:78": { + "type": "pictogram", + "name": "instoEarnGraph", + "hash": "JOTJEHPntynQibmCos3DPdogbQE0JUn0jCqPLRCPlTk=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.698Z", + "lastUpdated": "2026-03-19T18:42:00.348Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEarnGraph-0.svg", + "svgDark": "./pictogram/svg/dark/instoEarnGraph-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEarnGraph-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEarnGraph-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEarnGraph-0.js", + "pngLight": "./pictogram/png/light/instoEarnGraph-0.png", + "pngDark": "./pictogram/png/dark/instoEarnGraph-0.png" + }, + "version": 0 + }, + "28806:79": { + "type": "pictogram", + "name": "instoPasswordWalletLocked", + "hash": "trIAMAeOpji4noji4wXm+r6N5wy8n0Ni1uMgbj9A7t0=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, lock, circle, square, blue", + "createdAt": "2026-03-16T14:55:20.841Z", + "lastUpdated": "2026-03-19T18:42:00.353Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoPasswordWalletLocked-0.svg", + "svgDark": "./pictogram/svg/dark/instoPasswordWalletLocked-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoPasswordWalletLocked-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoPasswordWalletLocked-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoPasswordWalletLocked-0.js", + "pngLight": "./pictogram/png/light/instoPasswordWalletLocked-0.png", + "pngDark": "./pictogram/png/dark/instoPasswordWalletLocked-0.png" + }, + "version": 0 + }, + "28806:80": { + "type": "pictogram", + "name": "instoDecentralizedWeb3", + "hash": "rZJ2ip8gPmh3XSH0eCIaL6I6jba5irVHSMBgTCZDTK4=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.945Z", + "lastUpdated": "2026-03-19T18:42:00.054Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoDecentralizedWeb3-1.svg", + "svgDark": "./pictogram/svg/dark/instoDecentralizedWeb3-1.svg", + "svgThemed": "./pictogram/svg/themeable/instoDecentralizedWeb3-1.svg", + "svgJsLight": "./pictogram/svgJs/light/instoDecentralizedWeb3-1.js", + "svgJsDark": "./pictogram/svgJs/dark/instoDecentralizedWeb3-1.js", + "pngLight": "./pictogram/png/light/instoDecentralizedWeb3-1.png", + "pngDark": "./pictogram/png/dark/instoDecentralizedWeb3-1.png" + }, + "version": 1 + }, + "28806:81": { + "type": "pictogram", + "name": "instoApyInterest", + "hash": "qZBARvsXKKwKGQhod37ZLDVvwErS2atLdJ8U0rhNYEs=", + "width": 40, + "height": 40, + "description": "insto, prime, negroni, orange, institutional, institutional investor, APY, interest, growth, graph, chart, yield, coin, arrow, trending, value, increase, growing, 📈", + "createdAt": "2026-03-16T14:55:20.960Z", + "lastUpdated": "2026-04-01T17:18:37.466Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoApyInterest-2.svg", + "svgDark": "./pictogram/svg/dark/instoApyInterest-2.svg", + "svgThemed": "./pictogram/svg/themeable/instoApyInterest-2.svg", + "svgJsLight": "./pictogram/svgJs/light/instoApyInterest-2.js", + "svgJsDark": "./pictogram/svgJs/dark/instoApyInterest-2.js", + "pngLight": "./pictogram/png/light/instoApyInterest-2.png", + "pngDark": "./pictogram/png/dark/instoApyInterest-2.png" + }, + "version": 2 + }, + "28806:82": { + "type": "pictogram", + "name": "instoRestaking", + "hash": "QQBXY7DwpakFAmolMZLbJk/HkZNm8udU3U5mfp+HVxk=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coinbase, one, cb1, refresh, restore, refill, 🔄, staking", + "createdAt": "2026-03-16T14:55:20.827Z", + "lastUpdated": "2026-04-01T17:18:37.431Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoRestaking-2.svg", + "svgDark": "./pictogram/svg/dark/instoRestaking-2.svg", + "svgThemed": "./pictogram/svg/themeable/instoRestaking-2.svg", + "svgJsLight": "./pictogram/svgJs/light/instoRestaking-2.js", + "svgJsDark": "./pictogram/svgJs/dark/instoRestaking-2.js", + "pngLight": "./pictogram/png/light/instoRestaking-2.png", + "pngDark": "./pictogram/png/dark/instoRestaking-2.png" + }, + "version": 2 + }, + "28806:83": { + "type": "pictogram", + "name": "browserMultiPlatform", + "hash": "SGhEu3DgFSFhwl4Gr1txITjMB0jpksTGgyCMOpA2OZ0=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, circle, blue, yellow, monitor, multiplatform, browser, app, mobile, extension", + "createdAt": "2022-08-05T05:36:58.593Z", + "lastUpdated": "2026-03-19T18:42:00.042Z", + "outputs": { + "svgLight": "./pictogram/svg/light/browserMultiPlatform-7.svg", + "svgDark": "./pictogram/svg/dark/browserMultiPlatform-7.svg", + "svgThemed": "./pictogram/svg/themeable/browserMultiPlatform-7.svg", + "svgJsLight": "./pictogram/svgJs/light/browserMultiPlatform-7.js", + "svgJsDark": "./pictogram/svgJs/dark/browserMultiPlatform-7.js", + "pngLight": "./pictogram/png/light/browserMultiPlatform-7.png", + "pngDark": "./pictogram/png/dark/browserMultiPlatform-7.png" + }, + "version": 7 + }, + "28806:84": { + "type": "pictogram", + "name": "instoCoinbaseOneShield", + "hash": "8SiRgdl9WiMWti7HozosRbSSk+JCnv8FMt4+4jp6igk=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, coinbase, one, cb1, shield, protection, guard, defense, cover, safety, security", + "createdAt": "2026-03-16T14:55:20.952Z", + "lastUpdated": "2026-03-19T18:41:59.930Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoCoinbaseOneShield-0.svg", + "svgDark": "./pictogram/svg/dark/instoCoinbaseOneShield-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoCoinbaseOneShield-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoCoinbaseOneShield-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoCoinbaseOneShield-0.js", + "pngLight": "./pictogram/png/light/instoCoinbaseOneShield-0.png", + "pngDark": "./pictogram/png/dark/instoCoinbaseOneShield-0.png" + }, + "version": 0 + }, + "28806:85": { + "type": "pictogram", + "name": "instoBorrowingLending", + "hash": "MiuI220vc0n0tUYnEZAzANewq6MIBmzMTISYrJIMZ/c=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.691Z", + "lastUpdated": "2026-03-19T18:42:00.299Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoBorrowingLending-0.svg", + "svgDark": "./pictogram/svg/dark/instoBorrowingLending-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoBorrowingLending-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoBorrowingLending-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoBorrowingLending-0.js", + "pngLight": "./pictogram/png/light/instoBorrowingLending-0.png", + "pngDark": "./pictogram/png/dark/instoBorrowingLending-0.png" + }, + "version": 0 + }, + "28806:86": { + "type": "pictogram", + "name": "instoAdvancedTradingRebates", + "hash": "6dpKbde9GVMGsxt1gyvzHd90xFXvK4ioxKtUF9p0oBU=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, return, exchange, rebate, crypto", + "createdAt": "2026-03-16T14:55:20.542Z", + "lastUpdated": "2026-03-19T18:42:00.081Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoAdvancedTradingRebates-0.svg", + "svgDark": "./pictogram/svg/dark/instoAdvancedTradingRebates-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoAdvancedTradingRebates-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoAdvancedTradingRebates-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoAdvancedTradingRebates-0.js", + "pngLight": "./pictogram/png/light/instoAdvancedTradingRebates-0.png", + "pngDark": "./pictogram/png/dark/instoAdvancedTradingRebates-0.png" + }, + "version": 0 + }, + "28806:87": { + "type": "pictogram", + "name": "instoCrypto101", + "hash": "TWtNGjVW8Ey/dflBCHdasveJWxwqFwzxoBg1FO+z2lw=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, crypto, beginner, coin, circle, book, yellow, blue", + "createdAt": "2026-03-16T14:55:20.938Z", + "lastUpdated": "2026-03-19T18:42:00.006Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoCrypto101-0.svg", + "svgDark": "./pictogram/svg/dark/instoCrypto101-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoCrypto101-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoCrypto101-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoCrypto101-0.js", + "pngLight": "./pictogram/png/light/instoCrypto101-0.png", + "pngDark": "./pictogram/png/dark/instoCrypto101-0.png" + }, + "version": 0 + }, + "28806:88": { + "type": "pictogram", + "name": "instoDelegate", + "hash": "dzb4Ahk5SFq58LzoafTuxEWvyr7+tZY23wk4LaOZ9JU=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, represent, envoy, agent, assign, entrust, give, person, check, checkmark, ✅, ✔️, 👶, 👧, 🧒, 👦, 👩, 🧑, 👨, 👩‍🦱, 🧑‍🦱, 👨‍🦱, 👩‍🦰, 🧑‍🦰, 👨‍🦰, 👱‍♀️, 👱, 👱‍♂️, 👩‍🦳, 🧑‍🦳, 👨‍🦳, 👩‍🦲, 🧑‍🦲, 👨‍🦲, 🧔, 👵,🧓, 👴, 👲, 👳‍♀️, 👳, 👳‍♂️, 🧕, 👮‍♀️, 👮, 👮‍♂️, 👷‍♀️, 👷, 👷‍♂️, 💂‍♀️, 💂, 💂‍♂️, 🕵️‍♀️, 🕵️, 🕵️‍♂️, 👩‍⚕️, 🧑‍⚕️, 👨‍⚕️, 👩‍🌾, 🧑‍🌾, 👨‍🌾, 👩‍🍳, 🧑‍🍳, 👨‍🍳, 👩‍🎓, 🧑‍🎓, 👨‍🎓, 👩‍🎤, 🧑‍🎤, 👨‍🎤, 👩‍🏫, 🧑‍🏫, 👨‍🏫, 👩‍🏭, 🧑‍🏭, 👨‍🏭, 👩‍💻, 🧑‍💻, 👨‍💻, 👩‍💼, 🧑‍💼, 👨‍💼, 👩‍🔧, 🧑‍🔧, 👨‍🔧, 👩‍🔬, 🧑‍🔬, 👨‍🔬, 👩‍🎨, 🧑‍🎨, 👨‍🎨, 👩‍🚒, 🧑‍🚒, 👨‍🚒, 👩‍✈️, 🧑‍✈️, 👨‍✈️, 👩‍🚀,🧑‍🚀, 👨‍🚀, 👩‍⚖️, 🤵‍♀️, 🤵, 🤵‍♂️, 👸, 🤴, 🥷, 🦸‍♀️, 🦸, 🦸‍♂️, 🦹‍♀️, 🦹, 🦹‍♂️, 🤶, 🧑‍🎄, 🎅, 🧙‍♀️, 🧙, 🧙‍♂️ ,🧝‍♀️ ,🧝, 🧝‍♂️, 🧛‍♀️, 🧛, 🧛‍♂️", + "createdAt": "2026-03-16T14:55:20.924Z", + "lastUpdated": "2026-03-19T18:41:59.916Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoDelegate-0.svg", + "svgDark": "./pictogram/svg/dark/instoDelegate-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoDelegate-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoDelegate-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoDelegate-0.js", + "pngLight": "./pictogram/png/light/instoDelegate-0.png", + "pngDark": "./pictogram/png/dark/instoDelegate-0.png" + }, + "version": 0 + }, + "28806:89": { + "type": "pictogram", + "name": "instoStakingGraph", + "hash": "wgLlISJI7QAAM4PYk9+DNHJIXcX09rmS9xv8IPLrZ4U=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, circles, coins, yellow, blue, graph, staking", + "createdAt": "2026-03-16T14:55:20.600Z", + "lastUpdated": "2026-03-19T18:42:00.205Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoStakingGraph-0.svg", + "svgDark": "./pictogram/svg/dark/instoStakingGraph-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoStakingGraph-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoStakingGraph-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoStakingGraph-0.js", + "pngLight": "./pictogram/png/light/instoStakingGraph-0.png", + "pngDark": "./pictogram/png/dark/instoStakingGraph-0.png" + }, + "version": 0 + }, + "28806:90": { + "type": "pictogram", + "name": "instoGem", + "hash": "Fsnay78eBYjlv15x+nrA3tixMHUr94fvsDBmu+wY09I=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, diamond, reward, sparkle, earn, crystal, 💎, 💍, ✨, ❇️", + "createdAt": "2026-03-16T14:55:20.579Z", + "lastUpdated": "2026-03-19T18:42:00.372Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoGem-0.svg", + "svgDark": "./pictogram/svg/dark/instoGem-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoGem-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoGem-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoGem-0.js", + "pngLight": "./pictogram/png/light/instoGem-0.png", + "pngDark": "./pictogram/png/dark/instoGem-0.png" + }, + "version": 0 + }, + "28806:91": { + "type": "pictogram", + "name": "instoprimeMobileApp", + "hash": "a4iQOupxdsB2BFytE1PP92hkLQtH0Rv5d1BbyqDcwRw=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:21.042Z", + "lastUpdated": "2026-03-19T18:42:00.200Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoprimeMobileApp-0.svg", + "svgDark": "./pictogram/svg/dark/instoprimeMobileApp-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoprimeMobileApp-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoprimeMobileApp-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoprimeMobileApp-0.js", + "pngLight": "./pictogram/png/light/instoprimeMobileApp-0.png", + "pngDark": "./pictogram/png/dark/instoprimeMobileApp-0.png" + }, + "version": 0 + }, + "28806:92": { + "type": "pictogram", + "name": "instoEthRewards", + "hash": "rW6BQodKMvMt1gepSpr+CFX9W4Zv+WUhZyPs/AJmDYo=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, pictogram, ribbon, star, learning, rewards, eth, ethereum, asset, staking, l2", + "createdAt": "2026-03-16T14:55:20.910Z", + "lastUpdated": "2026-03-19T18:42:00.087Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEthRewards-0.svg", + "svgDark": "./pictogram/svg/dark/instoEthRewards-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEthRewards-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEthRewards-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEthRewards-0.js", + "pngLight": "./pictogram/png/light/instoEthRewards-0.png", + "pngDark": "./pictogram/png/dark/instoEthRewards-0.png" + }, + "version": 0 + }, + "28806:93": { + "type": "pictogram", + "name": "instoEth", + "hash": "+Dd3U7iToiPNNWuE6/kC/lp0o8AW8vQ2sViYpuyolnc=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.903Z", + "lastUpdated": "2026-03-19T18:42:00.193Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEth-0.svg", + "svgDark": "./pictogram/svg/dark/instoEth-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEth-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEth-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEth-0.js", + "pngLight": "./pictogram/png/light/instoEth-0.png", + "pngDark": "./pictogram/png/dark/instoEth-0.png" + }, + "version": 0 + }, + "28806:94": { + "type": "pictogram", + "name": "instoAccount", + "hash": "J9sFS+GPrHdgQ0zcKovhbyaia0WO7N6V42YFdvvkcEw=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.607Z", + "lastUpdated": "2026-03-19T18:42:00.216Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoAccount-0.svg", + "svgDark": "./pictogram/svg/dark/instoAccount-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoAccount-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoAccount-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoAccount-0.js", + "pngLight": "./pictogram/png/light/instoAccount-0.png", + "pngDark": "./pictogram/png/dark/instoAccount-0.png" + }, + "version": 0 + }, + "28806:95": { + "type": "pictogram", + "name": "instoAddressBook", + "hash": "Hg3LrIZGcVmDZidQ9ryTcCvvZPavDcs2jZ+CXOmRB/o=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, address, contacts, book, people, phone numbers, names, 📕, 📗, 📘, 📙, 📖, 📚, 📓, 📒, 📔, 📇, 📲, 📱, 🤳, 📳, 👶, 👧, 🧒, 👦, 👩, 🧑, 👨, 👩‍🦱, 🧑‍🦱, 👨‍🦱, 👩‍🦰, 🧑‍🦰, 👨‍🦰, 👱‍♀️, 👱, 👱‍♂️, 👩‍🦳, 🧑‍🦳, 👨‍🦳, 👩‍🦲, 🧑‍🦲, 👨‍🦲, 🧔, 👵,🧓, 👴, 👲, 👳‍♀️, 👳, 👳‍♂️, 🧕, 👮‍♀️, 👮, 👮‍♂️, 👷‍♀️, 👷, 👷‍♂️, 💂‍♀️, 💂, 💂‍♂️, 🕵️‍♀️, 🕵️, 🕵️‍♂️, 👩‍⚕️, 🧑‍⚕️, 👨‍⚕️, 👩‍🌾, 🧑‍🌾, 👨‍🌾, 👩‍🍳, 🧑‍🍳, 👨‍🍳, 👩‍🎓, 🧑‍🎓, 👨‍🎓, 👩‍🎤, 🧑‍🎤, 👨‍🎤, 👩‍🏫, 🧑‍🏫, 👨‍🏫, 👩‍🏭, 🧑‍🏭, 👨‍🏭, 👩‍💻, 🧑‍💻, 👨‍💻, 👩‍💼, 🧑‍💼, 👨‍💼, 👩‍🔧, 🧑‍🔧, 👨‍🔧, 👩‍🔬, 🧑‍🔬, 👨‍🔬, 👩‍🎨, 🧑‍🎨, 👨‍🎨, 👩‍🚒, 🧑‍🚒, 👨‍🚒, 👩‍✈️, 🧑‍✈️, 👨‍✈️, 👩‍🚀,🧑‍🚀, 👨‍🚀, 👩‍⚖️, 🤵‍♀️, 🤵, 🤵‍♂️, 👸, 🤴, 🥷, 🦸‍♀️, 🦸, 🦸‍♂️, 🦹‍♀️, 🦹, 🦹‍♂️, 🤶, 🧑‍🎄, 🎅, 🧙‍♀️, 🧙, 🧙‍♂️ ,🧝‍♀️ ,🧝, 🧝‍♂️, 🧛‍♀️, 🧛, 🧛‍♂️", + "createdAt": "2026-03-16T14:55:20.917Z", + "lastUpdated": "2026-03-19T18:42:00.102Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoAddressBook-0.svg", + "svgDark": "./pictogram/svg/dark/instoAddressBook-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoAddressBook-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoAddressBook-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoAddressBook-0.js", + "pngLight": "./pictogram/png/light/instoAddressBook-0.png", + "pngDark": "./pictogram/png/dark/instoAddressBook-0.png" + }, + "version": 0 + }, + "28806:96": { + "type": "pictogram", + "name": "instoEthStakingChart", + "hash": "Ucn3TJBivaLChyPqDv3QaX0fpO84O9IclH5SGSrXosI=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, eth, ethereum, asset, staking, l2, returns, gains, interest", + "createdAt": "2026-03-16T14:55:20.594Z", + "lastUpdated": "2026-03-19T18:42:00.253Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEthStakingChart-0.svg", + "svgDark": "./pictogram/svg/dark/instoEthStakingChart-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEthStakingChart-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEthStakingChart-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEthStakingChart-0.js", + "pngLight": "./pictogram/png/light/instoEthStakingChart-0.png", + "pngDark": "./pictogram/png/dark/instoEthStakingChart-0.png" + }, + "version": 0 + }, + "28806:97": { + "type": "pictogram", + "name": "instoNftLibrary", + "hash": "DEVLCnvfIU5sxp6KjL03SNTH1cQjM0jO+BZB7Br20lQ=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, square, blue, music, music note, user, play, document, digital, collectibles, nfts", + "createdAt": "2026-03-16T14:55:20.571Z", + "lastUpdated": "2026-03-19T18:42:00.148Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoNftLibrary-0.svg", + "svgDark": "./pictogram/svg/dark/instoNftLibrary-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoNftLibrary-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoNftLibrary-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoNftLibrary-0.js", + "pngLight": "./pictogram/png/light/instoNftLibrary-0.png", + "pngDark": "./pictogram/png/dark/instoNftLibrary-0.png" + }, + "version": 0 + }, + "28806:98": { + "type": "pictogram", + "name": "instoDecentralizationEverything", + "hash": "igS7BcVRGyjMH1t6OllCF7kzW973GxmLCxKs6JIU1eo=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.563Z", + "lastUpdated": "2026-03-19T18:42:00.271Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoDecentralizationEverything-0.svg", + "svgDark": "./pictogram/svg/dark/instoDecentralizationEverything-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoDecentralizationEverything-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoDecentralizationEverything-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoDecentralizationEverything-0.js", + "pngLight": "./pictogram/png/light/instoDecentralizationEverything-0.png", + "pngDark": "./pictogram/png/dark/instoDecentralizationEverything-0.png" + }, + "version": 0 + }, + "28806:99": { + "type": "pictogram", + "name": "instoWalletWarning", + "hash": "xAUIC/ZbKd4G5YestFsVXxQlNGmCqqGJ0K5fA4BrQ10=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, wallet, storage, crypto transactions, pay, retrieve, digital assets, exclamation mark, warning, alert, help, crucial, indication, yellow, 💰, 💵, 💸, ⚠️", + "createdAt": "2026-03-16T14:55:20.556Z", + "lastUpdated": "2026-03-19T18:42:00.107Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoWalletWarning-0.svg", + "svgDark": "./pictogram/svg/dark/instoWalletWarning-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoWalletWarning-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoWalletWarning-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoWalletWarning-0.js", + "pngLight": "./pictogram/png/light/instoWalletWarning-0.png", + "pngDark": "./pictogram/png/dark/instoWalletWarning-0.png" + }, + "version": 0 + }, + "28806:100": { + "type": "pictogram", + "name": "instoKey", + "hash": "wSV5vH2dMbYY2BTIg4vexzYhVMDDymAyh1i6SQdXovM=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, lock, secure, security, protect, shield, key, protection, guard, defense, cover, safety, 🔑, 🗝, 🔐, 🔒", + "createdAt": "2026-03-16T14:55:20.677Z", + "lastUpdated": "2026-03-19T18:42:00.293Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoKey-1.svg", + "svgDark": "./pictogram/svg/dark/instoKey-1.svg", + "svgThemed": "./pictogram/svg/themeable/instoKey-1.svg", + "svgJsLight": "./pictogram/svgJs/light/instoKey-1.js", + "svgJsDark": "./pictogram/svgJs/dark/instoKey-1.js", + "pngLight": "./pictogram/png/light/instoKey-1.png", + "pngDark": "./pictogram/png/dark/instoKey-1.png" + }, + "version": 1 + }, + "28806:101": { + "type": "pictogram", + "name": "instoRiskStaking", + "hash": "Soifk2XZOjRS0htbbEwRso8AVPwoHI9BminzRSHNgJw=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, exclamation, risk, caution, wrapping, ETH, staking, graph, growth, chart, value, market", + "createdAt": "2026-03-16T14:55:20.548Z", + "lastUpdated": "2026-03-19T18:42:00.131Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoRiskStaking-0.svg", + "svgDark": "./pictogram/svg/dark/instoRiskStaking-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoRiskStaking-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoRiskStaking-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoRiskStaking-0.js", + "pngLight": "./pictogram/png/light/instoRiskStaking-0.png", + "pngDark": "./pictogram/png/dark/instoRiskStaking-0.png" + }, + "version": 0 + }, + "28806:102": { + "type": "spotIcon", + "name": "instoStakingProduct", + "hash": "/t+6L4gjFY949kn8Vs7Q8z4Qt2rjCG2eyGlR6F29dpA=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, product, icons, staking", + "createdAt": "2026-03-16T14:55:20.527Z", + "lastUpdated": "2026-03-19T18:42:00.367Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoStakingProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoStakingProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoStakingProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoStakingProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoStakingProduct-0.js", + "pngLight": "./spotIcon/png/light/instoStakingProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoStakingProduct-0.png" + }, + "version": 0 + }, + "28806:103": { + "type": "pictogram", + "name": "instoEarnCoins", + "hash": "S1grSa0mEZQvjzGpb5l5VmPrUWq/z8N/8YNNWI2I4Nk=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, add, more, plus, coin, additional, crypto, 🪙, ➕", + "createdAt": "2026-03-16T14:55:20.535Z", + "lastUpdated": "2026-03-19T18:42:00.410Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEarnCoins-0.svg", + "svgDark": "./pictogram/svg/dark/instoEarnCoins-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEarnCoins-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEarnCoins-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEarnCoins-0.js", + "pngLight": "./pictogram/png/light/instoEarnCoins-0.png", + "pngDark": "./pictogram/png/dark/instoEarnCoins-0.png" + }, + "version": 0 + }, + "28806:104": { + "type": "pictogram", + "name": "instoFiat", + "hash": "4LANlqJi+5WM54w4Vkjqvhk9PWo7sjGIoltWln9XqBM=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, bank, fund, stock, currency, money, building, institution, 💵, 💸, 🏦, 🏧, 💴, 💶, 💷", + "createdAt": "2026-03-16T14:55:21.050Z", + "lastUpdated": "2026-03-19T18:42:00.263Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoFiat-0.svg", + "svgDark": "./pictogram/svg/dark/instoFiat-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoFiat-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoFiat-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoFiat-0.js", + "pngLight": "./pictogram/png/light/instoFiat-0.png", + "pngDark": "./pictogram/png/dark/instoFiat-0.png" + }, + "version": 0 + }, + "28806:105": { + "type": "pictogram", + "name": "instoTrading", + "hash": "86MWF1eSxZWhW0yYwJ3tDuBYRlgo6AGlqfWriilQzQA=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, chart, trading, candles, graph, numbers, data, visualization, 📈, 📉, 📊, 🕯, 🪔,", + "createdAt": "2026-03-16T14:55:20.645Z", + "lastUpdated": "2026-03-19T18:42:00.125Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoTrading-0.svg", + "svgDark": "./pictogram/svg/dark/instoTrading-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoTrading-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoTrading-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoTrading-0.js", + "pngLight": "./pictogram/png/light/instoTrading-0.png", + "pngDark": "./pictogram/png/dark/instoTrading-0.png" + }, + "version": 0 + }, + "28806:107": { + "type": "pictogram", + "name": "instoSelfCustodyWallet", + "hash": "j968X9tqwfvgR7WwHdTij9KNuQ2ICHpBfRb4qjQROBY=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor, wallet, user, blue, circle, self custody", + "createdAt": "2026-03-16T14:55:20.630Z", + "lastUpdated": "2026-03-19T18:41:59.935Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoSelfCustodyWallet-0.svg", + "svgDark": "./pictogram/svg/dark/instoSelfCustodyWallet-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoSelfCustodyWallet-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoSelfCustodyWallet-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoSelfCustodyWallet-0.js", + "pngLight": "./pictogram/png/light/instoSelfCustodyWallet-0.png", + "pngDark": "./pictogram/png/dark/instoSelfCustodyWallet-0.png" + }, + "version": 0 + }, + "28897:135": { + "type": "pictogram", + "name": "instoSecuredAssets", + "hash": "EYSVx2WR7xVeb2tLuCJdroF9HUsRVjz3OT/Ys/zNDqw=", + "width": 48, + "height": 48, + "description": "circle, yellow, blue, coin, secure, storage", + "createdAt": "2026-03-18T17:25:34.687Z", + "lastUpdated": "2026-03-19T18:42:00.288Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoSecuredAssets-0.svg", + "svgDark": "./pictogram/svg/dark/instoSecuredAssets-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoSecuredAssets-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoSecuredAssets-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoSecuredAssets-0.js", + "pngLight": "./pictogram/png/light/instoSecuredAssets-0.png", + "pngDark": "./pictogram/png/dark/instoSecuredAssets-0.png" + }, + "version": 0 + }, + "28897:153": { + "type": "pictogram", + "name": "instoBorrowCoins", + "hash": "hNr0gqJp9a5bwtrymPqivsOK8r1UEdFquXRU46M9jlM=", + "width": 48, + "height": 48, + "description": "borrow, coins, blue, yellow", + "createdAt": "2026-03-18T17:25:34.692Z", + "lastUpdated": "2026-03-19T18:42:00.113Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoBorrowCoins-0.svg", + "svgDark": "./pictogram/svg/dark/instoBorrowCoins-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoBorrowCoins-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoBorrowCoins-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoBorrowCoins-0.js", + "pngLight": "./pictogram/png/light/instoBorrowCoins-0.png", + "pngDark": "./pictogram/png/dark/instoBorrowCoins-0.png" + }, + "version": 0 + }, + "28897:159": { + "type": "pictogram", + "name": "instoEasyToUse", + "hash": "6KwoD0pi1wrigmj/XcvU2aBbSV7Ok2KDIjMeAYz1bFc=", + "width": 48, + "height": 48, + "description": "diamond, reward, sparkle, earn, crystal, 💎, 💍, ✨, ❇️", + "createdAt": "2026-03-18T17:25:34.682Z", + "lastUpdated": "2026-03-19T18:42:00.384Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoEasyToUse-0.svg", + "svgDark": "./pictogram/svg/dark/instoEasyToUse-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoEasyToUse-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoEasyToUse-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoEasyToUse-0.js", + "pngLight": "./pictogram/png/light/instoEasyToUse-0.png", + "pngDark": "./pictogram/png/dark/instoEasyToUse-0.png" + }, + "version": 0 + }, + "28897:183": { + "type": "pictogram", + "name": "instoCoinFocus", + "hash": "1c5wUogaIJTmN7Wdr8gBI0B5Gwxtp1DXaGVZ2r0FWkc=", + "width": 48, + "height": 48, + "description": "", + "createdAt": "2026-03-18T17:25:34.672Z", + "lastUpdated": "2026-03-19T18:42:00.032Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoCoinFocus-0.svg", + "svgDark": "./pictogram/svg/dark/instoCoinFocus-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoCoinFocus-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoCoinFocus-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoCoinFocus-0.js", + "pngLight": "./pictogram/png/light/instoCoinFocus-0.png", + "pngDark": "./pictogram/png/dark/instoCoinFocus-0.png" + }, + "version": 0 + }, + "28897:191": { + "type": "pictogram", + "name": "instoGlobalConnections", + "hash": "fJbBWuJzttLPDJo/Pz7BLgS7ZEcmTu1TZkCHXLxcW9c=", + "width": 48, + "height": 48, + "description": "", + "createdAt": "2026-03-18T17:25:34.667Z", + "lastUpdated": "2026-03-19T18:41:59.978Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoGlobalConnections-0.svg", + "svgDark": "./pictogram/svg/dark/instoGlobalConnections-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoGlobalConnections-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoGlobalConnections-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoGlobalConnections-0.js", + "pngLight": "./pictogram/png/light/instoGlobalConnections-0.png", + "pngDark": "./pictogram/png/dark/instoGlobalConnections-0.png" + }, + "version": 0 + }, + "28897:202": { + "type": "pictogram", + "name": "instoMonitoringPerformance", + "hash": "zcRcwNRlOYnCexVpjB/7txgotED7qH9d0JUdD7my/Ms=", + "width": 48, + "height": 48, + "description": "arrow, coins, up, gain, blue, circle, portfolio", + "createdAt": "2026-03-18T17:25:34.661Z", + "lastUpdated": "2026-03-19T18:42:00.425Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoMonitoringPerformance-0.svg", + "svgDark": "./pictogram/svg/dark/instoMonitoringPerformance-0.svg", + "svgThemed": "./pictogram/svg/themeable/instoMonitoringPerformance-0.svg", + "svgJsLight": "./pictogram/svgJs/light/instoMonitoringPerformance-0.js", + "svgJsDark": "./pictogram/svgJs/dark/instoMonitoringPerformance-0.js", + "pngLight": "./pictogram/png/light/instoMonitoringPerformance-0.png", + "pngDark": "./pictogram/png/dark/instoMonitoringPerformance-0.png" + }, + "version": 0 + }, + "28897:213": { + "type": "pictogram", + "name": "instoDecentralizedExchange", + "hash": "j8q+ChwIfvT5KEwY6oYWkmJwWD0npCKFeAE0zqLX1rk=", + "width": 48, + "height": 48, + "description": "insto, prime, negroni, orange, institutional, institutional investor,", + "createdAt": "2026-03-16T14:55:20.615Z", + "lastUpdated": "2026-03-19T18:41:59.994Z", + "outputs": { + "svgLight": "./pictogram/svg/light/instoDecentralizedExchange-1.svg", + "svgDark": "./pictogram/svg/dark/instoDecentralizedExchange-1.svg", + "svgThemed": "./pictogram/svg/themeable/instoDecentralizedExchange-1.svg", + "svgJsLight": "./pictogram/svgJs/light/instoDecentralizedExchange-1.js", + "svgJsDark": "./pictogram/svgJs/dark/instoDecentralizedExchange-1.js", + "pngLight": "./pictogram/png/light/instoDecentralizedExchange-1.png", + "pngDark": "./pictogram/png/dark/instoDecentralizedExchange-1.png" + }, + "version": 1 + }, + "28932:405": { + "type": "spotIcon", + "name": "instoDelegate", + "hash": "o9LMH0mlZtOlTxnW+PAGlZVZLvcRu4KYxKsBEYc7FYs=", + "width": 32, + "height": 32, + "description": "represent, envoy, agent, assign, entrust, give, person, check, checkmark, ✅, ✔️, 👶, 👧, 🧒, 👦, 👩, 🧑, 👨, 👩‍🦱, 🧑‍🦱, 👨‍🦱, 👩‍🦰, 🧑‍🦰, 👨‍🦰, 👱‍♀️, 👱, 👱‍♂️, 👩‍🦳, 🧑‍🦳, 👨‍🦳, 👩‍🦲, 🧑‍🦲, 👨‍🦲, 🧔, 👵,🧓, 👴, 👲, 👳‍♀️, 👳, 👳‍♂️, 🧕, 👮‍♀️, 👮, 👮‍♂️, 👷‍♀️, 👷, 👷‍♂️, 💂‍♀️, 💂, 💂‍♂️, 🕵️‍♀️, 🕵️, 🕵️‍♂️, 👩‍⚕️, 🧑‍⚕️, 👨‍⚕️, 👩‍🌾, 🧑‍🌾, 👨‍🌾, 👩‍🍳, 🧑‍🍳, 👨‍🍳, 👩‍🎓, 🧑‍🎓, 👨‍🎓, 👩‍🎤, 🧑‍🎤, 👨‍🎤, 👩‍🏫, 🧑‍🏫, 👨‍🏫, 👩‍🏭, 🧑‍🏭, 👨‍🏭, 👩‍💻, 🧑‍💻, 👨‍💻, 👩‍💼, 🧑‍💼, 👨‍💼, 👩‍🔧, 🧑‍🔧, 👨‍🔧, 👩‍🔬, 🧑‍🔬, 👨‍🔬, 👩‍🎨, 🧑‍🎨, 👨‍🎨, 👩‍🚒, 🧑‍🚒, 👨‍🚒, 👩‍✈️, 🧑‍✈️, 👨‍✈️, 👩‍🚀,🧑‍🚀, 👨‍🚀, 👩‍⚖️, 🤵‍♀️, 🤵, 🤵‍♂️, 👸, 🤴, 🥷, 🦸‍♀️, 🦸, 🦸‍♂️, 🦹‍♀️, 🦹, 🦹‍♂️, 🤶, 🧑‍🎄, 🎅, 🧙‍♀️, 🧙, 🧙‍♂️ ,🧝‍♀️ ,🧝, 🧝‍♂️, 🧛‍♀️, 🧛, 🧛‍♂️", + "createdAt": "2026-03-19T18:41:59.421Z", + "lastUpdated": "2026-04-01T17:18:37.418Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoDelegate-1.svg", + "svgDark": "./spotIcon/svg/dark/instoDelegate-1.svg", + "svgThemed": "./spotIcon/svg/themeable/instoDelegate-1.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoDelegate-1.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoDelegate-1.js", + "pngLight": "./spotIcon/png/light/instoDelegate-1.png", + "pngDark": "./spotIcon/png/dark/instoDelegate-1.png" + }, + "version": 1 + }, + "28932:406": { + "type": "spotIcon", + "name": "instoMultiCoin", + "hash": "d59PaS1TU15uCidJ3/LnRthDgwRi9ryx5WkpyHbFKnI=", + "width": 32, + "height": 32, + "description": "", + "createdAt": "2026-03-19T18:41:59.446Z", + "lastUpdated": "2026-03-19T18:41:59.446Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoMultiCoin-0.svg", + "svgDark": "./spotIcon/svg/dark/instoMultiCoin-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoMultiCoin-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoMultiCoin-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoMultiCoin-0.js", + "pngLight": "./spotIcon/png/light/instoMultiCoin-0.png", + "pngDark": "./spotIcon/png/dark/instoMultiCoin-0.png" + }, + "version": 0 + }, + "28932:407": { + "type": "spotIcon", + "name": "instoShield", + "hash": "HqqVuSMtw/DPQmNO7Xlyw4cIhR6wWYUND0tmN2+GQlY=", + "width": 32, + "height": 32, + "description": "shield, protection, guard, defense, cover, safety, security", + "createdAt": "2026-03-19T18:41:59.440Z", + "lastUpdated": "2026-03-19T18:41:59.440Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoShield-0.svg", + "svgDark": "./spotIcon/svg/dark/instoShield-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoShield-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoShield-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoShield-0.js", + "pngLight": "./spotIcon/png/light/instoShield-0.png", + "pngDark": "./spotIcon/png/dark/instoShield-0.png" + }, + "version": 0 + }, + "28932:413": { + "type": "spotIcon", + "name": "instoAuthenticator", + "hash": "wb3d6Wh6eZ8pwKoUslc7XD520dteggI6NEonJhjvbEE=", + "width": 32, + "height": 32, + "description": "trust, true, genuine, actual, verification", + "createdAt": "2026-03-19T18:41:59.458Z", + "lastUpdated": "2026-03-19T18:41:59.458Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoAuthenticator-0.svg", + "svgDark": "./spotIcon/svg/dark/instoAuthenticator-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoAuthenticator-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoAuthenticator-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoAuthenticator-0.js", + "pngLight": "./spotIcon/png/light/instoAuthenticator-0.png", + "pngDark": "./spotIcon/png/dark/instoAuthenticator-0.png" + }, + "version": 0 + }, + "28932:419": { + "type": "spotIcon", + "name": "instoPieChart", + "hash": "2ujmpvzNNUgIKwwnFCm398HNADxoyKQzAYCntC3GvB4=", + "width": 32, + "height": 32, + "description": "chart pie, data, visualization, numbers, graph, 📊, 📉, 📈, 🥧", + "createdAt": "2026-03-19T18:41:59.507Z", + "lastUpdated": "2026-03-19T18:41:59.507Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoPieChart-0.svg", + "svgDark": "./spotIcon/svg/dark/instoPieChart-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoPieChart-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoPieChart-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoPieChart-0.js", + "pngLight": "./spotIcon/png/light/instoPieChart-0.png", + "pngDark": "./spotIcon/png/dark/instoPieChart-0.png" + }, + "version": 0 + }, + "28932:426": { + "type": "spotIcon", + "name": "instoFast", + "hash": "KU6Nod+zm6ViRXsNXu2Poc1NM8luiVbR+0QKn//wDHE=", + "width": 32, + "height": 32, + "description": "quick, time, clock, speed, lightning, 🕦, 🕐, 🕚, 🕥, 🕧, 🕙, 🕣, 🕠, 🕝, 🕢, 🕟, 🕜, 🕤, 🕡, 🕞, 🕘, 🕒, 🕗, 🕔, 🕑, 🕖, 🕓, 🕛, ⏰, ⏱, 🕰, 🔄, ⏳, ⌛️", + "createdAt": "2026-03-19T18:41:59.476Z", + "lastUpdated": "2026-04-01T17:18:37.398Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoFast-1.svg", + "svgDark": "./spotIcon/svg/dark/instoFast-1.svg", + "svgThemed": "./spotIcon/svg/themeable/instoFast-1.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoFast-1.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoFast-1.js", + "pngLight": "./spotIcon/png/light/instoFast-1.png", + "pngDark": "./spotIcon/png/dark/instoFast-1.png" + }, + "version": 1 + }, + "28932:434": { + "type": "spotIcon", + "name": "instoRecurringPurchases", + "hash": "7zUJ2OtjTuKKLiFZiqzKApJ+8LAJqKZAeZBaxW+kK1w=", + "width": 32, + "height": 32, + "description": "reoccur, regular, schedule, calendar, organize, date, year, month, week, book, refresh, 📆, 📅, 🗓", + "createdAt": "2026-03-19T18:41:59.483Z", + "lastUpdated": "2026-04-01T17:18:37.451Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoRecurringPurchases-1.svg", + "svgDark": "./spotIcon/svg/dark/instoRecurringPurchases-1.svg", + "svgThemed": "./spotIcon/svg/themeable/instoRecurringPurchases-1.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoRecurringPurchases-1.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoRecurringPurchases-1.js", + "pngLight": "./spotIcon/png/light/instoRecurringPurchases-1.png", + "pngDark": "./spotIcon/png/dark/instoRecurringPurchases-1.png" + }, + "version": 1 + }, + "28932:440": { + "type": "spotIcon", + "name": "instoChat", + "hash": "XZugJGVEn+2RwcyF3RkvdlPLwKcomex1wDO/gcsXkJY=", + "width": 32, + "height": 32, + "description": "chat bubble, speech, communication, social, interaction, message, 💬", + "createdAt": "2026-03-19T18:41:59.470Z", + "lastUpdated": "2026-03-19T18:41:59.470Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoChat-0.svg", + "svgDark": "./spotIcon/svg/dark/instoChat-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoChat-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoChat-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoChat-0.js", + "pngLight": "./spotIcon/png/light/instoChat-0.png", + "pngDark": "./spotIcon/png/dark/instoChat-0.png" + }, + "version": 0 + }, + "28932:456": { + "type": "spotIcon", + "name": "instoAdvancedTradeProduct", + "hash": "UVCI06VeRHdr0Kkn5pE99uKOJuiyTBkSYmW0qCBdr78=", + "width": 32, + "height": 32, + "description": "product, icons, advanced, trade", + "createdAt": "2026-03-19T18:41:59.409Z", + "lastUpdated": "2026-03-19T18:41:59.409Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoAdvancedTradeProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoAdvancedTradeProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoAdvancedTradeProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoAdvancedTradeProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoAdvancedTradeProduct-0.js", + "pngLight": "./spotIcon/png/light/instoAdvancedTradeProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoAdvancedTradeProduct-0.png" + }, + "version": 0 + }, + "28932:463": { + "type": "spotIcon", + "name": "instoPaySDKProduct", + "hash": "erk6n8tJDtA0n8rgYpjGa+JDtmszirD/oD/p+n1YlV8=", + "width": 32, + "height": 32, + "description": "product, icons, pay, SDK", + "createdAt": "2026-03-19T18:41:59.415Z", + "lastUpdated": "2026-03-19T18:41:59.415Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoPaySDKProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoPaySDKProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoPaySDKProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoPaySDKProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoPaySDKProduct-0.js", + "pngLight": "./spotIcon/png/light/instoPaySDKProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoPaySDKProduct-0.png" + }, + "version": 0 + }, + "28932:470": { + "type": "spotIcon", + "name": "instoDataMarketplace", + "hash": "ppzm8vuR+RmGbJtEPRH/e8yP8075Enh3J7dbdIOAhqI=", + "width": 32, + "height": 32, + "description": "product, icons, data, marketplace", + "createdAt": "2026-03-19T18:41:59.403Z", + "lastUpdated": "2026-03-19T18:41:59.403Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoDataMarketplace-0.svg", + "svgDark": "./spotIcon/svg/dark/instoDataMarketplace-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoDataMarketplace-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoDataMarketplace-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoDataMarketplace-0.js", + "pngLight": "./spotIcon/png/light/instoDataMarketplace-0.png", + "pngDark": "./spotIcon/png/dark/instoDataMarketplace-0.png" + }, + "version": 0 + }, + "28932:471": { + "type": "spotIcon", + "name": "instoRewardsProduct", + "hash": "P11sYawkz+4ymtNx0OHfGjGG1H1uUlQSv/LYKxt9AbQ=", + "width": 32, + "height": 32, + "description": "", + "createdAt": "2026-03-19T18:41:59.397Z", + "lastUpdated": "2026-03-19T18:41:59.397Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoRewardsProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoRewardsProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoRewardsProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoRewardsProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoRewardsProduct-0.js", + "pngLight": "./spotIcon/png/light/instoRewardsProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoRewardsProduct-0.png" + }, + "version": 0 + }, + "28932:472": { + "type": "spotIcon", + "name": "instoWalletAsAServiceProduct", + "hash": "4Xy9WBlZMIfR3gVyhUg4Us6cZHxLhzDdgc91UrzjIKU=", + "width": 32, + "height": 32, + "description": "product, icons, wallet, as, a, service", + "createdAt": "2026-03-19T18:41:59.433Z", + "lastUpdated": "2026-03-19T18:41:59.433Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoWalletAsAServiceProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoWalletAsAServiceProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoWalletAsAServiceProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoWalletAsAServiceProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoWalletAsAServiceProduct-0.js", + "pngLight": "./spotIcon/png/light/instoWalletAsAServiceProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoWalletAsAServiceProduct-0.png" + }, + "version": 0 + }, + "28932:473": { + "type": "spotIcon", + "name": "instoBorrowProduct", + "hash": "61VX6q+/BbgtZ/SyLBqABMJhv+yUbx3MmRu3n1xHwCo=", + "width": 32, + "height": 32, + "description": "product, icons, borrow", + "createdAt": "2026-03-19T18:41:59.495Z", + "lastUpdated": "2026-03-19T18:41:59.495Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoBorrowProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoBorrowProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoBorrowProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoBorrowProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoBorrowProduct-0.js", + "pngLight": "./spotIcon/png/light/instoBorrowProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoBorrowProduct-0.png" + }, + "version": 0 + }, + "28932:474": { + "type": "spotIcon", + "name": "instoLearningRewardsProduct", + "hash": "t66gTlSTO9jY/lenl9yW4C8L8RgPkn8D5Q2UTauMBNM=", + "width": 32, + "height": 32, + "description": "product, icons, learning, rewards", + "createdAt": "2026-03-19T18:41:59.501Z", + "lastUpdated": "2026-03-19T18:41:59.501Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoLearningRewardsProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoLearningRewardsProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoLearningRewardsProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoLearningRewardsProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoLearningRewardsProduct-0.js", + "pngLight": "./spotIcon/png/light/instoLearningRewardsProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoLearningRewardsProduct-0.png" + }, + "version": 0 + }, + "28932:475": { + "type": "spotIcon", + "name": "instoCommerceProduct", + "hash": "BlW26SDFvmHDmqv0RD7cRz62R1wGmD63seuNNk1DjSw=", + "width": 32, + "height": 32, + "description": "product, icons, commerce", + "createdAt": "2026-03-19T18:41:59.489Z", + "lastUpdated": "2026-03-19T18:41:59.489Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoCommerceProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoCommerceProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoCommerceProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoCommerceProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoCommerceProduct-0.js", + "pngLight": "./spotIcon/png/light/instoCommerceProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoCommerceProduct-0.png" + }, + "version": 0 + }, + "28932:476": { + "type": "spotIcon", + "name": "instoPrivateClientProduct", + "hash": "Iv5jJOVb/EK9sDbykvbH/UFXgXaKwnE60WMEukVNPrY=", + "width": 32, + "height": 32, + "description": "product, icons, private, client", + "createdAt": "2026-03-19T18:41:59.452Z", + "lastUpdated": "2026-04-01T17:18:37.435Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoPrivateClientProduct-1.svg", + "svgDark": "./spotIcon/svg/dark/instoPrivateClientProduct-1.svg", + "svgThemed": "./spotIcon/svg/themeable/instoPrivateClientProduct-1.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoPrivateClientProduct-1.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoPrivateClientProduct-1.js", + "pngLight": "./spotIcon/png/light/instoPrivateClientProduct-1.png", + "pngDark": "./spotIcon/png/dark/instoPrivateClientProduct-1.png" + }, + "version": 1 + }, + "28932:477": { + "type": "spotIcon", + "name": "instoCustodyProduct", + "hash": "y9FBN+kUphztjH1yFWKjX64Ne8BFfjIfvNFspIGWnUw=", + "width": 32, + "height": 32, + "description": "product, icons, custody", + "createdAt": "2026-03-19T18:41:59.427Z", + "lastUpdated": "2026-03-19T18:41:59.427Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoCustodyProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoCustodyProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoCustodyProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoCustodyProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoCustodyProduct-0.js", + "pngLight": "./spotIcon/png/light/instoCustodyProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoCustodyProduct-0.png" + }, + "version": 0 + }, + "28932:478": { + "type": "spotIcon", + "name": "instoPrimeProduct", + "hash": "0zo4ZV/RfNNPMNJGkb6miPTH/jqU2K0bUVDpe3ttAZU=", + "width": 32, + "height": 32, + "description": "product, icons, prime", + "createdAt": "2026-03-19T18:41:59.391Z", + "lastUpdated": "2026-03-19T18:41:59.391Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoPrimeProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoPrimeProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoPrimeProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoPrimeProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoPrimeProduct-0.js", + "pngLight": "./spotIcon/png/light/instoPrimeProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoPrimeProduct-0.png" + }, + "version": 0 + }, + "28932:479": { + "type": "spotIcon", + "name": "instoHelpCenterProduct", + "hash": "htJHL5wFVVUY9BrSZmgBL5/yhmbScpt1GFZb+psQcmU=", + "width": 32, + "height": 32, + "description": "product, icons, help, center", + "createdAt": "2026-03-19T18:41:59.384Z", + "lastUpdated": "2026-03-19T18:41:59.384Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoHelpCenterProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoHelpCenterProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoHelpCenterProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoHelpCenterProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoHelpCenterProduct-0.js", + "pngLight": "./spotIcon/png/light/instoHelpCenterProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoHelpCenterProduct-0.png" + }, + "version": 0 + }, + "28932:480": { + "type": "spotIcon", + "name": "instoDerivativesProduct", + "hash": "FwSHAyFcOzdRfyyKASxfpKAqv209oqGgA0/t9NVKuHc=", + "width": 32, + "height": 32, + "description": "derivatives, pictogram, leverage, invest, prime, advanced, derive, arrow, triangles", + "createdAt": "2026-03-19T18:41:59.512Z", + "lastUpdated": "2026-03-19T18:41:59.512Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoDerivativesProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoDerivativesProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoDerivativesProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoDerivativesProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoDerivativesProduct-0.js", + "pngLight": "./spotIcon/png/light/instoDerivativesProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoDerivativesProduct-0.png" + }, + "version": 0 + }, + "28932:489": { + "type": "spotIcon", + "name": "instoBusinessProduct", + "hash": "FZxWx6V2lmHUWNqATsWiFonwA9MOes1JA2VHSOSWy+w=", + "width": 32, + "height": 32, + "description": "product, icons, advanced, trade, coinbaseone, One,", + "createdAt": "2026-03-19T18:41:59.378Z", + "lastUpdated": "2026-03-19T18:41:59.378Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoBusinessProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoBusinessProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoBusinessProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoBusinessProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoBusinessProduct-0.js", + "pngLight": "./spotIcon/png/light/instoBusinessProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoBusinessProduct-0.png" + }, + "version": 0 + }, + "28932:496": { + "type": "spotIcon", + "name": "instoProductPro", + "hash": "NVxovXdlEbwQtBlNB4cofL/NhFIYIxbDZJiaov59uY0=", + "width": 32, + "height": 32, + "description": "product, icons, icon, small, coinbase, 32x32, pro", + "createdAt": "2026-03-19T18:41:59.371Z", + "lastUpdated": "2026-03-19T18:41:59.371Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoProductPro-0.svg", + "svgDark": "./spotIcon/svg/dark/instoProductPro-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoProductPro-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoProductPro-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoProductPro-0.js", + "pngLight": "./spotIcon/png/light/instoProductPro-0.png", + "pngDark": "./spotIcon/png/dark/instoProductPro-0.png" + }, + "version": 0 + }, + "28932:503": { + "type": "spotIcon", + "name": "instoProductCompliance", + "hash": "Js2rPui2bIN4NDniw00HSSWds5RFLXYPG4/AtghHueE=", + "width": 32, + "height": 32, + "description": "product, icons, icon, small, coinbase, 32x32, compliance", + "createdAt": "2026-03-19T18:41:59.365Z", + "lastUpdated": "2026-03-19T18:41:59.365Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoProductCompliance-0.svg", + "svgDark": "./spotIcon/svg/dark/instoProductCompliance-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoProductCompliance-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoProductCompliance-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoProductCompliance-0.js", + "pngLight": "./spotIcon/png/light/instoProductCompliance-0.png", + "pngDark": "./spotIcon/png/dark/instoProductCompliance-0.png" + }, + "version": 0 + }, + "28932:513": { + "type": "spotIcon", + "name": "instoProductCoinbaseCard", + "hash": "y4tc/Dh9hUvPOVsiX3XzE3eoArPSiRJ2mGF/QpxEYO0=", + "width": 32, + "height": 32, + "description": "product, icons, icon, small, coinbase, 32x32, card", + "createdAt": "2026-03-19T18:41:59.359Z", + "lastUpdated": "2026-03-19T18:41:59.359Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoProductCoinbaseCard-0.svg", + "svgDark": "./spotIcon/svg/dark/instoProductCoinbaseCard-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoProductCoinbaseCard-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoProductCoinbaseCard-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoProductCoinbaseCard-0.js", + "pngLight": "./spotIcon/png/light/instoProductCoinbaseCard-0.png", + "pngDark": "./spotIcon/png/dark/instoProductCoinbaseCard-0.png" + }, + "version": 0 + }, + "28932:514": { + "type": "spotIcon", + "name": "instoIdVerification", + "hash": "d+8TzuZNlY1iQElnHapb+GM+s7x7QRKYGsDuvK/jIuA=", + "width": 32, + "height": 32, + "description": "check, checkmark, secure, 2fa, protection, identity card, profile, personal, ID, human, card, 🆔, ✅, ✔️, 👶, 👧, 🧒, 👦, 👩, 🧑, 👨, 👩‍🦱, 🧑‍🦱, 👨‍🦱, 👩‍🦰, 🧑‍🦰, 👨‍🦰, 👱‍♀️, 👱, 👱‍♂️, 👩‍🦳, 🧑‍🦳, 👨‍🦳, 👩‍🦲, 🧑‍🦲, 👨‍🦲, 🧔, 👵,🧓, 👴, 👲, 👳‍♀️, 👳, 👳‍♂️, 🧕, 👮‍♀️, 👮, 👮‍♂️, 👷‍♀️, 👷, 👷‍♂️, 💂‍♀️, 💂, 💂‍♂️, 🕵️‍♀️, 🕵️, 🕵️‍♂️, 👩‍⚕️, 🧑‍⚕️, 👨‍⚕️, 👩‍🌾, 🧑‍🌾, 👨‍🌾, 👩‍🍳, 🧑‍🍳, 👨‍🍳, 👩‍🎓, 🧑‍🎓, 👨‍🎓, 👩‍🎤, 🧑‍🎤, 👨‍🎤, 👩‍🏫, 🧑‍🏫, 👨‍🏫, 👩‍🏭, 🧑‍🏭, 👨‍🏭, 👩‍💻, 🧑‍💻, 👨‍💻, 👩‍💼, 🧑‍💼, 👨‍💼, 👩‍🔧, 🧑‍🔧, 👨‍🔧, 👩‍🔬, 🧑‍🔬, 👨‍🔬, 👩‍🎨, 🧑‍🎨, 👨‍🎨, 👩‍🚒, 🧑‍🚒, 👨‍🚒, 👩‍✈️, 🧑‍✈️, 👨‍✈️, 👩‍🚀,🧑‍🚀, 👨‍🚀, 👩‍⚖️, 🤵‍♀️, 🤵, 🤵‍♂️, 👸, 🤴, 🥷, 🦸‍♀️, 🦸, 🦸‍♂️, 🦹‍♀️, 🦹, 🦹‍♂️, 🤶, 🧑‍🎄, 🎅, 🧙‍♀️, 🧙, 🧙‍♂️ ,🧝‍♀️ ,🧝, 🧝‍♂️, 🧛‍♀️, 🧛, 🧛‍♂️, success state", + "createdAt": "2026-03-19T18:41:59.353Z", + "lastUpdated": "2026-03-19T18:41:59.353Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoIdVerification-0.svg", + "svgDark": "./spotIcon/svg/dark/instoIdVerification-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoIdVerification-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoIdVerification-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoIdVerification-0.js", + "pngLight": "./spotIcon/png/light/instoIdVerification-0.png", + "pngDark": "./spotIcon/png/dark/instoIdVerification-0.png" + }, + "version": 0 + }, + "28932:515": { + "type": "spotIcon", + "name": "instoCoinbaseOneEarn", + "hash": "0U0QNXnP9+dsBIIILrMznDPNm7zcKBUU2n6U7Dn9c1w=", + "width": 32, + "height": 32, + "description": "Coinbase, One, Concierge, person, attendant", + "createdAt": "2026-03-19T18:41:59.464Z", + "lastUpdated": "2026-03-19T18:41:59.464Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoCoinbaseOneEarn-0.svg", + "svgDark": "./spotIcon/svg/dark/instoCoinbaseOneEarn-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoCoinbaseOneEarn-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoCoinbaseOneEarn-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoCoinbaseOneEarn-0.js", + "pngLight": "./spotIcon/png/light/instoCoinbaseOneEarn-0.png", + "pngDark": "./spotIcon/png/dark/instoCoinbaseOneEarn-0.png" + }, + "version": 0 + }, + "28932:516": { + "type": "spotIcon", + "name": "instoLayeredNetworks", + "hash": "moXeAjJjJmryfuVjmrxrK7DnilvzT4KuGoTy78Ytous=", + "width": 31.999969482421875, + "height": 32, + "description": "layers, layer three, three, isometric, networks, base, blue, yellow", + "createdAt": "2026-03-19T18:41:59.347Z", + "lastUpdated": "2026-03-19T18:41:59.347Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoLayeredNetworks-0.svg", + "svgDark": "./spotIcon/svg/dark/instoLayeredNetworks-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoLayeredNetworks-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoLayeredNetworks-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoLayeredNetworks-0.js", + "pngLight": "./spotIcon/png/light/instoLayeredNetworks-0.png", + "pngDark": "./spotIcon/png/dark/instoLayeredNetworks-0.png" + }, + "version": 0 + }, + "28932:517": { + "type": "spotIcon", + "name": "instoSignInProduct", + "hash": "AA24VCrRu+IcyrgtIJxqLXHtQTHjMEdpN/3Z2X3nb3U=", + "width": 32, + "height": 32, + "description": "product, icons, sign, in, with, coinbase", + "createdAt": "2026-03-19T18:41:59.340Z", + "lastUpdated": "2026-03-19T18:41:59.340Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoSignInProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoSignInProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoSignInProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoSignInProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoSignInProduct-0.js", + "pngLight": "./spotIcon/png/light/instoSignInProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoSignInProduct-0.png" + }, + "version": 0 + }, + "28932:518": { + "type": "spotIcon", + "name": "instoCloudProduct", + "hash": "bKIL9yTh29Uw5Li/0qFV8OOc5S6Xwp0m2E2+FwtNvO0=", + "width": 32, + "height": 32, + "description": "product, icons, cloud, developer, portal", + "createdAt": "2026-03-19T18:41:59.334Z", + "lastUpdated": "2026-03-19T18:41:59.334Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoCloudProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoCloudProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoCloudProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoCloudProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoCloudProduct-0.js", + "pngLight": "./spotIcon/png/light/instoCloudProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoCloudProduct-0.png" + }, + "version": 0 + }, + "28932:531": { + "type": "spotIcon", + "name": "instoAssetHubProduct", + "hash": "M93ddJbknrGNQPv533yYsXudfuG00QJ6Rly8A5mC2yc=", + "width": 32, + "height": 32, + "description": "", + "createdAt": "2026-03-19T18:41:59.327Z", + "lastUpdated": "2026-03-19T18:41:59.327Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoAssetHubProduct-0.svg", + "svgDark": "./spotIcon/svg/dark/instoAssetHubProduct-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoAssetHubProduct-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoAssetHubProduct-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoAssetHubProduct-0.js", + "pngLight": "./spotIcon/png/light/instoAssetHubProduct-0.png", + "pngDark": "./spotIcon/png/dark/instoAssetHubProduct-0.png" + }, + "version": 0 + }, + "28932:536": { + "type": "spotIcon", + "name": "instoProductWallet", + "hash": "GPEgRiiwQhv2Y17RGxI7+YyuaFtjFonmxIblzgqPjOM=", + "width": 32, + "height": 32, + "description": "product, icons, icon, small, coinbase, 32x32, wallet", + "createdAt": "2026-03-19T18:41:59.311Z", + "lastUpdated": "2026-03-19T18:41:59.311Z", + "outputs": { + "svgLight": "./spotIcon/svg/light/instoProductWallet-0.svg", + "svgDark": "./spotIcon/svg/dark/instoProductWallet-0.svg", + "svgThemed": "./spotIcon/svg/themeable/instoProductWallet-0.svg", + "svgJsLight": "./spotIcon/svgJs/light/instoProductWallet-0.js", + "svgJsDark": "./spotIcon/svgJs/dark/instoProductWallet-0.js", + "pngLight": "./spotIcon/png/light/instoProductWallet-0.png", + "pngDark": "./spotIcon/png/dark/instoProductWallet-0.png" + }, + "version": 0 + }, + "28955:90": { + "type": "spotRectangle", + "name": "instoMargin", + "hash": "toHN4t43CJ2PTc/MOKmTW7mCAIxyQjvsQxk5qNb6hoM=", + "width": 240, + "height": 120, + "description": "margin, trading, add, stack, more, lever, up, buy, sell, put, options, trade, risk", + "createdAt": "2026-03-20T17:10:13.579Z", + "lastUpdated": "2026-03-20T17:10:13.579Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoMargin-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoMargin-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoMargin-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoMargin-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoMargin-0.js", + "pngLight": "./spotRectangle/png/light/instoMargin-0.png", + "pngDark": "./spotRectangle/png/dark/instoMargin-0.png" + }, + "version": 0 + }, + "28955:91": { + "type": "spotRectangle", + "name": "instoApiKey", + "hash": "TJHIzwzNSHlcG5IIthWf6RE9AodmieRj0d6iCf9tuzQ=", + "width": 240, + "height": 120, + "description": "API, key, access, account, connect, unlock, gain, trust", + "createdAt": "2026-03-20T17:10:13.588Z", + "lastUpdated": "2026-04-01T17:18:37.388Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoApiKey-1.svg", + "svgDark": "./spotRectangle/svg/dark/instoApiKey-1.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoApiKey-1.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoApiKey-1.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoApiKey-1.js", + "pngLight": "./spotRectangle/png/light/instoApiKey-1.png", + "pngDark": "./spotRectangle/png/dark/instoApiKey-1.png" + }, + "version": 1 + }, + "28956:112": { + "type": "spotRectangle", + "name": "instoRefreshKey", + "hash": "4mQkdXQASFAqdwOek6T0YMDneh6uKy08a2RxtT+lgu4=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.592Z", + "lastUpdated": "2026-03-20T17:10:13.592Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoRefreshKey-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoRefreshKey-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoRefreshKey-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoRefreshKey-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoRefreshKey-0.js", + "pngLight": "./spotRectangle/png/light/instoRefreshKey-0.png", + "pngDark": "./spotRectangle/png/dark/instoRefreshKey-0.png" + }, + "version": 0 + }, + "28956:134": { + "type": "spotRectangle", + "name": "instoKey", + "hash": "6JVOaU9Ek4ecKM8AIJmX4vb5UbXhWO8EmIwSOBBy6hY=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.601Z", + "lastUpdated": "2026-03-20T17:10:13.601Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoKey-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoKey-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoKey-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoKey-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoKey-0.js", + "pngLight": "./spotRectangle/png/light/instoKey-0.png", + "pngDark": "./spotRectangle/png/dark/instoKey-0.png" + }, + "version": 0 + }, + "28957:289": { + "type": "spotRectangle", + "name": "instoSetupComplete", + "hash": "KNVeELUPPKjNxz0mmAhMlcQmQOvSkkE2fHgo5AI+bUk=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.584Z", + "lastUpdated": "2026-03-20T17:10:13.584Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoSetupComplete-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoSetupComplete-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoSetupComplete-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoSetupComplete-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoSetupComplete-0.js", + "pngLight": "./spotRectangle/png/light/instoSetupComplete-0.png", + "pngDark": "./spotRectangle/png/dark/instoSetupComplete-0.png" + }, + "version": 0 + }, + "28957:1807": { + "type": "spotRectangle", + "name": "instoDesignateSigner", + "hash": "yNcY0FqnckkEgHkNL+esE7gp/rRrgLGF8neY18FRr4g=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.605Z", + "lastUpdated": "2026-03-20T17:10:13.605Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoDesignateSigner-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoDesignateSigner-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoDesignateSigner-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoDesignateSigner-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoDesignateSigner-0.js", + "pngLight": "./spotRectangle/png/light/instoDesignateSigner-0.png", + "pngDark": "./spotRectangle/png/dark/instoDesignateSigner-0.png" + }, + "version": 0 + }, + "28957:1856": { + "type": "spotRectangle", + "name": "instoAboutOnchain", + "hash": "cgKEGcAkqXoiWlSNfZpo7Td20dAQHMhFPZQ9pFyZWYU=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.574Z", + "lastUpdated": "2026-03-20T17:10:13.574Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoAboutOnchain-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoAboutOnchain-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoAboutOnchain-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoAboutOnchain-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoAboutOnchain-0.js", + "pngLight": "./spotRectangle/png/light/instoAboutOnchain-0.png", + "pngDark": "./spotRectangle/png/dark/instoAboutOnchain-0.png" + }, + "version": 0 + }, + "28958:42": { + "type": "spotRectangle", + "name": "instoSetupOnchain", + "hash": "hxdFYLCTKOSt9xLjV7gYslu4uiRSeAuEuiMetTLCYnI=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.569Z", + "lastUpdated": "2026-03-20T17:10:13.569Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoSetupOnchain-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoSetupOnchain-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoSetupOnchain-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoSetupOnchain-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoSetupOnchain-0.js", + "pngLight": "./spotRectangle/png/light/instoSetupOnchain-0.png", + "pngDark": "./spotRectangle/png/dark/instoSetupOnchain-0.png" + }, + "version": 0 + }, + "28958:231": { + "type": "spotRectangle", + "name": "instoOnchainSetupInProgress", + "hash": "6iNT/h9jr8Sdpg2YyAu/rKUf7Q2eFFueuNkjd+LOXhs=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.610Z", + "lastUpdated": "2026-03-20T17:10:13.610Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoOnchainSetupInProgress-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoOnchainSetupInProgress-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoOnchainSetupInProgress-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoOnchainSetupInProgress-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoOnchainSetupInProgress-0.js", + "pngLight": "./spotRectangle/png/light/instoOnchainSetupInProgress-0.png", + "pngDark": "./spotRectangle/png/dark/instoOnchainSetupInProgress-0.png" + }, + "version": 0 + }, + "28958:422": { + "type": "spotRectangle", + "name": "instoConsensusWaitingForApprovals", + "hash": "rP3Rn3cTnwFipOomleMUPX8XPYsq+dW+pfuOUa2Z8gc=", + "width": 240, + "height": 120, + "description": "", + "createdAt": "2026-03-20T17:10:13.597Z", + "lastUpdated": "2026-03-20T17:10:13.597Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoConsensusWaitingForApprovals-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoConsensusWaitingForApprovals-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoConsensusWaitingForApprovals-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoConsensusWaitingForApprovals-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoConsensusWaitingForApprovals-0.js", + "pngLight": "./spotRectangle/png/light/instoConsensusWaitingForApprovals-0.png", + "pngDark": "./spotRectangle/png/dark/instoConsensusWaitingForApprovals-0.png" + }, + "version": 0 + }, + "28958:490": { + "type": "spotRectangle", + "name": "instoQRCode", + "hash": "kT/9HEzDWcRD/b1CGSV3J8lFBXYCV29/G73/6BF5gvg=", + "width": 240, + "height": 119.9998550415039, + "description": "", + "createdAt": "2026-03-20T17:10:13.559Z", + "lastUpdated": "2026-03-20T18:18:19.319Z", + "outputs": { + "svgLight": "./spotRectangle/svg/light/instoQRCode-0.svg", + "svgDark": "./spotRectangle/svg/dark/instoQRCode-0.svg", + "svgThemed": "./spotRectangle/svg/themeable/instoQRCode-0.svg", + "svgJsLight": "./spotRectangle/svgJs/light/instoQRCode-0.js", + "svgJsDark": "./spotRectangle/svgJs/dark/instoQRCode-0.js", + "pngLight": "./spotRectangle/png/light/instoQRCode-0.png", + "pngDark": "./spotRectangle/png/dark/instoQRCode-0.png" + }, + "version": 0 + }, + "29036:24": { + "type": "heroSquare", + "name": "flipStable", + "hash": "Fws+tuML9lLE4Qp8M2bg/YFhgbFaQ9GO55/1iNSmOB8=", + "width": 240, + "height": 240, + "description": "coin, conversion, convert, yellow, blue, cbltc, litecoin", + "createdAt": "2026-03-27T14:01:24.918Z", + "lastUpdated": "2026-03-27T14:01:24.918Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/flipStable-0.svg", + "svgDark": "./heroSquare/svg/dark/flipStable-0.svg", + "svgThemed": "./heroSquare/svg/themeable/flipStable-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/flipStable-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/flipStable-0.js", + "pngLight": "./heroSquare/png/light/flipStable-0.png", + "pngDark": "./heroSquare/png/dark/flipStable-0.png" + }, + "version": 0 + }, + "29086:51": { + "type": "spotSquare", + "name": "inrTrade", + "hash": "rilGKvyBxEThpxE+NM9qYwsUIIsRSw63fNwSiZ4oxUA=", + "width": 96, + "height": 96, + "description": "pictogram, coin, crypto learning, rewards, bitcoin, btc, satoshi, giveaway, free, competition", + "createdAt": "2026-03-27T14:01:24.937Z", + "lastUpdated": "2026-03-27T14:01:24.937Z", + "outputs": { + "svgLight": "./spotSquare/svg/light/inrTrade-0.svg", + "svgDark": "./spotSquare/svg/dark/inrTrade-0.svg", + "svgThemed": "./spotSquare/svg/themeable/inrTrade-0.svg", + "svgJsLight": "./spotSquare/svgJs/light/inrTrade-0.js", + "svgJsDark": "./spotSquare/svgJs/dark/inrTrade-0.js", + "pngLight": "./spotSquare/png/light/inrTrade-0.png", + "pngDark": "./spotSquare/png/dark/inrTrade-0.png" + }, + "version": 0 + }, + "29086:67": { + "type": "pictogram", + "name": "inrTrade", + "hash": "itoHe7BAmTyYQvllRf+kZW/ANtGtxA6jUYWPnbRAbGs=", + "width": 48, + "height": 48, + "description": "pictogram, coin, crypto learning, rewards, bitcoin, btc, satoshi, giveaway, free, competition", + "createdAt": "2026-03-27T14:01:24.930Z", + "lastUpdated": "2026-03-27T14:01:24.930Z", + "outputs": { + "svgLight": "./pictogram/svg/light/inrTrade-0.svg", + "svgDark": "./pictogram/svg/dark/inrTrade-0.svg", + "svgThemed": "./pictogram/svg/themeable/inrTrade-0.svg", + "svgJsLight": "./pictogram/svgJs/light/inrTrade-0.js", + "svgJsDark": "./pictogram/svgJs/dark/inrTrade-0.js", + "pngLight": "./pictogram/png/light/inrTrade-0.png", + "pngDark": "./pictogram/png/dark/inrTrade-0.png" + }, + "version": 0 + }, + "29466:21": { + "type": "heroSquare", + "name": "cbmega", + "hash": "jzfg2tXWY6sJYvgTXX/CLgq8Qn1WIOIkblY/D6Ku7oE=", + "width": 240, + "height": 240, + "description": "coin, conversion, convert, yellow, blue, mega, cbmega", + "createdAt": "2026-04-16T19:09:39.933Z", + "lastUpdated": "2026-04-16T19:09:53.197Z", + "outputs": { + "svgLight": "./heroSquare/svg/light/cbmega-0.svg", + "svgDark": "./heroSquare/svg/dark/cbmega-0.svg", + "svgThemed": "./heroSquare/svg/themeable/cbmega-0.svg", + "svgJsLight": "./heroSquare/svgJs/light/cbmega-0.js", + "svgJsDark": "./heroSquare/svgJs/dark/cbmega-0.js", + "pngLight": "./heroSquare/png/light/cbmega-0.png", + "pngDark": "./heroSquare/png/dark/cbmega-0.png" + }, + "version": 0 } } } diff --git a/packages/illustrations/package.json b/packages/illustrations/package.json index 530f6fd315..3d9c5a6da6 100644 --- a/packages/illustrations/package.json +++ b/packages/illustrations/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-illustrations", - "version": "4.29.0", + "version": "4.38.0", "description": "CDS illustrations", "repository": { "type": "git", diff --git a/packages/illustrations/src/__generated__/heroSquare/data/descriptionMap.ts b/packages/illustrations/src/__generated__/heroSquare/data/descriptionMap.ts index 255a54ddc7..597b46459b 100644 --- a/packages/illustrations/src/__generated__/heroSquare/data/descriptionMap.ts +++ b/packages/illustrations/src/__generated__/heroSquare/data/descriptionMap.ts @@ -12,7 +12,7 @@ const descriptionMap: Record = { '0': ['routingAccount'], '1': ['routingAccount'], '2': ['routingAccount'], - '3': ['routingAccount', 'generative', 'minting', 'dappsGeneral'], + '3': ['routingAccount', 'minting', 'dappsGeneral', 'generative'], '4': ['routingAccount'], '5': ['routingAccount'], '6': ['routingAccount'], @@ -20,2609 +20,2591 @@ const descriptionMap: Record = { '8': ['routingAccount'], '9': ['routingAccount'], '400': ['error400', 'errorWeb400'], - '404': ['emptyStateNft404Page', 'errorWeb404Mobile', 'errorWeb404'], - '500': ['errorWeb500', 'errorApp500'], - circles: [ - 'staking', - 'p2pPayments', - 'dappsArts', - 'startToday', - 'completeAQuiz', - 'didDecentralizedIdentity', - 'secureAndTrusted', - 'crossBorderPayments', - 'defiDecentralizedBorrowingLending', - 'cryptoAssets', - 'digitalCollectibles', - 'dappsFinance', + '404': ['emptyStateNft404Page', 'errorWeb404', 'errorWeb404Mobile'], + '500': ['errorApp500', 'errorWeb500'], + coin: [ + 'cbmega', + 'futures', + 'emptyStateNft404Page', + 'congratulationsOnEarningCrypto', + 'cryptoForBeginners', + 'cryptoPortfolio', + 'earnToLearn', + 'semiCustodial', + 'moneyDecentralized', + 'rotatingRewards', + 'earnMore', + 'cardBoosted', + 'feeScale', + 'powerOfCrypto', + 'coinbaseOneSavingFunds', + 'coinCheckmark', + 'encryptedEverything', 'multicoinSupport', - 'defiDecentralizedTradingExchange', - 'stableValue', - 'stressTestedColdStorage', - 'invest', - 'globalTransactions', - 'shareOnSocialMedia', - 'backedByUsDollar', - 'ratingsAndReviews', - 'selfCustodyCrypto', - 'decentralizedWebWeb3', - 'optInPushNotificationsEmail', - 'getStartedInMinutes', - 'coinFifty', - ], - coins: [ - 'staking', - 'tradeGeneral', - 'sendCryptoFaster', - 'insufficientBalance', - 'stayInControlSelfHostedWalletsStorage', - 'coinbaseCardSpendCrypto', - 'dappsL2Support', + 'tradeImmediately', + 'governance', + 'limitOrders', + 'freeBtc', + 'networkWarning', + 'secureStorage', + 'dappsGaming', + 'secureAndTrusted', + 'readyToTrade', 'directDepositPhone', - 'cryptoAssets', - 'walletAsset', - 'borrowWallet', - 'dappsFinance', - 'defiHow', - 'stableValue', - 'defiEarn', - 'exchange', - 'cryptoEconomy', + 'earnGrowth', 'cryptoAndMore', - 'invest', - 'oilAndGold', + 'coinbaseFees', + 'earnCryptoCard', + 'estimatedAmount', + 'securityShield', 'currencyPairs', - 'stakingMissedReturnsUsdc', - 'holdCrypto', - 'insuranceProtection', - 'quickBuy', - 'backedByUsDollar', - 'gainsAndLosses', - 'ratingsAndReviews', - 'holdingCrypto', - 'walletUi', - 'cryptoWallet', - 'trendingHotAssets', - 'stakingMissedReturns', - 'ethStakingRewards', - 'selfCustodyCrypto', - 'buyFirstCrypto', + 'coinbaseOneWelcome', + 'defiHow', + 'mining', 'coinbaseOneUSDCBig', - 'transactionLimit', - 'portfolioPerformance', + 'usdtToUSDC', + 'protocol', + 'tradeGeneral', + 'stablecoin', + 'earnGlobe', + 'buy', + 'exchange', + 'realToUSDC', + 'coinbaseOneAirdrop', + 'oilAndGold', + 'cbxrp', + 'cbltc', + 'cbdoge', + 'cbada', 'usdAndUsdc', - 'ethereumToWallet', - ], - yellow: [ - 'staking', - 'digitalGold', - 'liquidationBufferYellow', - 'multipleAccountsWalletsForOneUser', - 'coinCheckmark', - 'quest', - 'dappsArts', - 'governanceMallet', + 'cryptoPortfolioUsdc', + 'instoGovernance', + 'instoEarnGlobe', + 'flipStable', + ], + conversion: ['cbmega', 'cbbtc', 'cbxrp', 'cbltc', 'cbdoge', 'cbada', 'flipStable'], + convert: [ + 'cbmega', + 'usdtToUSDC', + 'realToUSDC', + 'cbbtc', 'cbxrp', - 'browserExtension', - 'didDecentralizedIdentity', - 'dappsMusic', - 'cryptoPortfolio', - 'lowCost', - 'dappsGaming', - 'secureAndTrusted', + 'cbltc', 'cbdoge', - 'encryptedEverything', - 'defiDecentralizedBorrowingLending', - 'cryptoAssets', - 'borrowWallet', - 'coinbaseOneEarn', - 'dappsFinance', - 'multicoinSupport', + 'cbada', + 'flipStable', + ], + yellow: [ + 'cbmega', 'blockchain', - 'defiDecentralizedTradingExchange', - 'cryptoEconomy', - 'generative', - 'stressTestedColdStorage', - 'invest', + 'congratulationsOnEarningCrypto', 'cryptoForBeginners', - 'settlement', + 'cryptoPortfolio', + 'didDecentralizedIdentity', + 'dappsArts', + 'dappsFinance', + 'dappsMusic', 'earnToLearn', - 'cbltc', - 'layerThree', - 'insuranceProtection', + 'gainsAndLosses', + 'layeredNetworks', + 'optInPushNotificationsEmail', + 'portfolioPerformance', + 'quickAndSimple', + 'selfCustodyCrypto', 'semiCustodial', - 'moneyDecentralized', 'shareOnSocialMedia', - 'noFeesMotion', - 'multiPlatformMobileAppBrowserExtension', 'taxesDetails', - 'backedByUsDollar', - 'congratulationsOnEarningCrypto', + 'trendingHotAssets', + 'cryptoWallet', + 'insuranceProtection', + 'moneyDecentralized', + 'multiPlatformMobileAppBrowserExtension', + 'stressTestedColdStorage', + 'decentralizedWebWeb3', + 'multipleAccountsWalletsForOneUser', + 'coinCheckmark', 'sidechain', - 'layeredNetworks', - 'gainsAndLosses', - 'ratingsAndReviews', + 'cryptoEconomy', + 'invest', + 'encryptedEverything', + 'multicoinSupport', + 'noFees', 'earn', - 'cryptoWallet', + 'defiDecentralizedTradingExchange', + 'ratingsAndReviews', + 'browserExtension', + 'borrowWallet', 'secureStorage', - 'trendingHotAssets', - 'quickAndSimple', - 'platform', + 'dappsGaming', + 'secureAndTrusted', + 'staking', + 'cryptoAssets', + 'backedByUsDollar', + 'defiDecentralizedBorrowingLending', 'mining', - 'selfCustodyCrypto', - 'decentralizedWebWeb3', - 'optInPushNotificationsEmail', - 'noFees', - 'portfolioPerformance', + 'noFeesMotion', + 'lowCost', + 'settlement', + 'platform', + 'digitalGold', + 'quest', + 'generative', + 'governanceMallet', + 'coinFifty', 'cbbtc', + 'coinbaseOneEarn', + 'layerThree', + 'liquidationBufferYellow', + 'cbxrp', + 'cbltc', + 'cbdoge', 'cbada', - 'coinFifty', + 'cryptoPortfolioUsdc', + 'instoStaking', + 'flipStable', ], blue: [ - 'staking', - 'p2pPayments', - 'multipleAccountsWalletsForOneUser', - 'quest', + 'cbmega', + 'blockchain', + 'congratulationsOnEarningCrypto', + 'cryptoForBeginners', + 'cryptoPortfolio', + 'didDecentralizedIdentity', 'dappsArts', - 'startToday', - 'governanceMallet', - 'cbxrp', + 'dappsFinance', + 'dappsMusic', + 'earnToLearn', + 'gainsAndLosses', + 'layeredNetworks', + 'optInPushNotificationsEmail', + 'portfolioPerformance', + 'quickAndSimple', + 'selfCustodyCrypto', + 'semiCustodial', + 'shareOnSocialMedia', + 'taxesDetails', + 'trendingHotAssets', 'completeAQuiz', + 'cryptoWallet', + 'getStartedInMinutes', + 'insuranceProtection', + 'moneyDecentralized', + 'multiPlatformMobileAppBrowserExtension', + 'poweredByEthereum', + 'startToday', 'stayInControlSelfHostedWalletsStorage', - 'browserExtension', - 'didDecentralizedIdentity', - 'dappsMusic', - 'basedInUsa', - 'cryptoPortfolio', - 'lowCost', - 'dappsGaming', - 'secureAndTrusted', - 'cbdoge', - 'encryptedEverything', - 'crossBorderPayments', - 'defiDecentralizedBorrowingLending', 'walletSecurity', - 'cryptoAssets', + 'stressTestedColdStorage', + 'crossBorderPayments', 'digitalCollectibles', - 'borrowWallet', - 'coinbaseOneEarn', - 'public', - 'dappsFinance', + 'p2pPayments', + 'decentralization', + 'decentralizedWebWeb3', + 'multipleAccountsWalletsForOneUser', + 'sidechain', + 'cryptoEconomy', + 'invest', + 'collectingNfts', + 'encryptedEverything', 'multicoinSupport', - 'blockchain', + 'noFees', + 'watchVideos', + 'hardwareWallets', + 'earn', 'defiDecentralizedTradingExchange', + 'ratingsAndReviews', + 'browserExtension', + 'secureGlobalTransactions', + 'borrowWallet', 'stableValue', - 'cryptoEconomy', - 'generative', - 'stressTestedColdStorage', - 'invest', - 'cryptoForBeginners', - 'earnToLearn', - 'cbltc', - 'globalTransactions', - 'layerThree', - 'insuranceProtection', - 'semiCustodial', - 'moneyDecentralized', - 'decentralization', - 'shareOnSocialMedia', - 'noFeesMotion', - 'multiPlatformMobileAppBrowserExtension', - 'taxesDetails', + 'secureStorage', + 'dappsGaming', + 'secureAndTrusted', + 'staking', + 'cryptoAssets', + 'linkingYourWalletToYourCoinbaseAccount', 'selfCustody', + 'basedInUsa', 'backedByUsDollar', - 'congratulationsOnEarningCrypto', - 'sidechain', - 'layeredNetworks', - 'gainsAndLosses', - 'ratingsAndReviews', - 'poweredByEthereum', - 'earn', - 'cryptoWallet', - 'secureStorage', - 'trendingHotAssets', - 'quickAndSimple', - 'secureGlobalTransactions', - 'platform', + 'defiDecentralizedBorrowingLending', + 'globalTransactions', 'mining', - 'watchVideos', - 'selfCustodyCrypto', - 'decentralizedWebWeb3', - 'hardwareWallets', - 'optInPushNotificationsEmail', - 'noFees', - 'portfolioPerformance', - 'getStartedInMinutes', - 'collectingNfts', - 'cbbtc', - 'linkingYourWalletToYourCoinbaseAccount', + 'noFeesMotion', + 'lowCost', + 'platform', + 'quest', + 'generative', + 'public', 'sustainable', - 'cbada', + 'governanceMallet', 'coinFifty', - ], - graph: [ - 'staking', - 'performance', - 'defiEnrollBoost', - 'lowCost', + 'cbbtc', 'coinbaseOneEarn', - 'invest', - 'switchAdvancedToSimpleTrading', - 'earn', - 'exploreDecentralizedApps', - 'ethStakingRewards', - ], - staking: [ - 'staking', - 'earnGlobe', - 'ethStakingUpsell', - 'defiHow', - 'stakingMissedReturnsUsdc', - 'stakingMissedReturns', - 'ethStakingRewards', - 'governance', + 'layerThree', + 'cbxrp', + 'cbltc', + 'cbdoge', + 'cbada', + 'cryptoPortfolioUsdc', + 'instoWalletSecurity', + 'instoStaking', + 'flipStable', ], - coinbase: [ - 'referralsAvatars', - 'coinbaseIsDownMobile', - 'rotatingRewards', - 'coinbaseIsDown', - 'coinbaseOneWelcome', - 'coinbaseOneLogo', - 'cardBoosted', - 'selectReward', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'exploreDecentralizedApps', - 'walletUi', - 'referralsWalletPhones', - 'coinbaseRedesigned', - 'coinbaseOneUSDCBig', - 'coinbaseOneAirdrop', - 'usdAndUsdc', - 'linkingYourWalletToYourCoinbaseAccount', + mega: ['cbmega'], + cbmega: ['cbmega'], + leverage: [ + 'leverage', + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', ], - referral: [ - 'referralsAvatars', - 'freeBtc', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'referralsWalletPhones', + trading: [ + 'leverage', + 'marginWarning', + 'futures', + 'advancedTradingUi', + 'advancedTrading', + 'webRAT', + 'defiDecentralizedTradingExchange', + 'margin', + 'orderBooks', + 'readyToTrade', + 'slippageTolerance', + 'tradeGeneral', + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', ], - avatar: [ - 'referralsAvatars', + add: [ + 'leverage', + 'marginWarning', + 'buyFirstCrypto', + 'receivedCard', + 'coinbaseCardLock', + 'add2Fa', + 'addCreditCard', + 'addBankAccount', + 'addPhoneNumber', + 'verifyBankTransactions', + 'verifyCardTransactions', 'yourContacts', - 'accountUnderReview', - 'collectableNfts', - 'ensProfilePic', - 'idBack', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'idFront', - 'referralsWalletPhones', - 'developer', - 'idVerificationSecure', - 'idIssue', - 'coinbaseOneUSDCBig', - 'twoIdVerify', - 'innovation', - 'usdAndUsdc', - ], - magic: [ - 'referralsAvatars', - 'oracle', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'referralsWalletPhones', + 'margin', + 'insufficientBalance', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'addMoreCrypto', + 'coinbaseOneCardWarning', + 'cardAndPhone', + 'instoAdd2Fa', ], - network: [ - 'referralsAvatars', - 'scalable', - 'protocol', - 'lightningNetworkInvoice', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'referralsCoinbaseOne', - 'layerTwo', - 'referralsBitcoin', - 'privateKey', - 'referralsGenericCoin', - 'referralsWalletPhones', - 'powerOfCrypto', - 'lightningNetworkSend', - 'networkWarning', + stack: ['leverage', 'marginWarning', 'margin', 'digitalGold'], + more: [ + 'leverage', + 'marginWarning', + 'transactionLimit', + 'margin', + 'insufficientBalance', + 'cryptoAndMore', + 'oilAndGold', ], - share: [ - 'referralsAvatars', - 'shareOnSocialMedia', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'referralsWalletPhones', + lever: ['leverage', 'marginWarning', 'margin'], + up: [ + 'leverage', + 'marginWarning', + 'portfolioPerformance', + 'trendingHotAssets', + 'payUpFront', + 'margin', + 'engagement', ], - heads: ['referralsAvatars', 'referralsCoinbaseOne', 'referralsBitcoin', 'referralsGenericCoin'], - people: [ - 'referralsAvatars', - 'yourContacts', - 'connectPeople', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', + buy: [ + 'leverage', + 'marginWarning', + 'futures', + 'walletAsset', + 'buyFirstCrypto', + 'coinsInWallet', + 'limitOrders', + 'margin', + 'quickBuy', + 'buy', ], - profile: [ - 'referralsAvatars', - 'accountUnderReview', - 'ensProfilePic', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', - 'innovation', + sell: ['leverage', 'marginWarning', 'futures', 'margin'], + put: ['leverage', 'marginWarning', 'futures', 'margin'], + options: ['leverage', 'marginWarning', 'margin'], + trade: [ + 'leverage', + 'marginWarning', + 'coinbaseWalletToTrade', + 'webRAT', + 'tradeImmediately', + 'margin', + 'tradeHistory', + 'ethStakingRewards', + 'usdtToUSDC', + 'tradeGeneral', + 'exchange', + 'realToUSDC', + 'instoEthStakingRewards', ], - pic: [ - 'referralsAvatars', - 'ensProfilePic', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', + risk: ['leverage', 'marginWarning', 'futures', 'margin', 'defiRisk'], + margin: ['marginWarning', 'margin'], + clock: [ + 'marginWarning', + 'futures', + 'walletAsset', + 'quickAndSimple', + 'pending', + 'addBankAccount', + 'quickBuy', + 'tradeHistory', + 'stakingMissedReturns', + 'stakingMissedReturnsUsdc', + 'instoStakingMissedReturns', ], - PFP: [ - 'referralsAvatars', - 'ensProfilePic', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', + 'error state': [ + 'marginWarning', + 'coinbaseOneWalletWarning', + 'spacedOutSystemError', + 'cardError', + 'web3ActivityError', + 'cardErrorCB1', + ], + futures: ['futures'], + future: ['futures'], + short: ['futures'], + hedge: ['futures'], + balance: ['futures', 'gainsAndLosses', 'insufficientBalance', 'stableValue'], + plus: [ + 'futures', + 'taxesDetails', + 'buyFirstCrypto', + 'receivedCard', + 'coinbaseCardLock', + 'add2Fa', + 'addCreditCard', + 'addBankAccount', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'coinbaseOneCardWarning', + 'commerceInvoices', + 'engagement', + 'instoAdd2Fa', ], - bar: ['performance'], - performance: ['performance'], arrow: [ - 'performance', - 'coinbaseFees', - 'coinbaseWalletToTrade', - 'accessToAdvancedCharts', - 'defiHow', - 'stressTestedColdStorage', - 'earnToLearn', - 'coinbaseOneSavingFunds', - 'stopLimitOrder', - 'stopLimitOrderDown', - 'commerceAccounting', - 'holdingCrypto', - 'automaticPayments', 'futures', - 'trendingHotAssets', + 'earnToLearn', + 'portfolioPerformance', 'selfCustodyCrypto', + 'trendingHotAssets', + 'stressTestedColdStorage', + 'stopLimitOrder', + 'accessToAdvancedCharts', 'decentralizedWebWeb3', + 'coinbaseWalletToTrade', + 'coinbaseOneSavingFunds', + 'holdingCrypto', 'networkWarning', - 'portfolioPerformance', 'focusLimitOrders', 'downloadCoinbaseWallet', - ], - red: [ - 'performance', - 'videoRequest', - 'coinbaseOneInsufficientWallet', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - 'advancedTradingChartsIndicatorsCandles', - 'bigError', - 'web3ActivityError', - ], - green: [ + 'stopLimitOrderDown', + 'automaticPayments', + 'coinbaseFees', + 'commerceAccounting', 'performance', - 'multipleAccountsWalletsForOneUser', - 'stayInControlSelfHostedWalletsStorage', - 'defiDecentralizedBorrowingLending', - 'borrowWallet', - 'public', - 'liquidationBufferGreen', - 'walletNotifications', - 'invest', - 'gasFeesNetworkFees', - 'decentralization', - 'multiPlatformMobileAppBrowserExtension', - 'taxesDetails', - 'advancedTradingChartsIndicatorsCandles', - 'platform', - 'watchVideos', - 'portfolioPerformance', - 'sustainable', + 'defiHow', ], - portfolio: ['namePortfolio', 'multiplePortfolios', 'portfolioPerformance'], - multiple: ['namePortfolio', 'multiplePortfolios'], - multi: ['namePortfolio', 'multiplePortfolios', 'scalable'], - many: ['namePortfolio', 'multiplePortfolios', 'scalable'], - port: ['namePortfolio', 'multiplePortfolios'], - crypto: [ - 'namePortfolio', - 'multiplePortfolios', - 'coinCheckmark', - 'sendCryptoFaster', - 'coinbaseCardSpendCrypto', - 'cardBoosted', - 'earnMore', - 'estimatedAmount', - 'cryptoAppsWallet', - 'cryptoAssets', - 'notificationsAndUpdates', - 'walletAsset', - 'earnCryptoInterest', - 'cryptoEconomy', - 'cryptoForBeginners', - 'earnGrowth', - 'holdCrypto', - 'quickBuy', - 'myNameIsSatoshi', - 'holdingCrypto', - 'cryptoWallet', - 'powerOfCrypto', - 'buyFirstCrypto', - 'browseDecentralizedApps', - 'transactionLimit', - 'earnIdVerification', - 'fileYourCryptoTaxes', - 'discardAssets', - 'selectCorrectCrypto', - 'fileYourCryptoTaxesCheck', - 'webRAT', - 'coinFifty', + nft: [ + 'emptyStateNftSoldOut', + 'emptyStateCheckBackLater', + 'emptyStateNft404Page', + 'brdGift', + 'emptyCollection', + 'exploreDecentralizedApps', + 'receiveGift', + 'collectableNfts', + 'hiddenCollection', ], - assets: [ - 'namePortfolio', - 'multiplePortfolios', - 'tradeGeneral', - 'earnGlobe', - 'recommendInvest', - 'coinbaseCardSpendCrypto', - 'earnMore', - 'cryptoAssets', - 'notificationsAndUpdates', - 'walletAsset', - 'earnGrowth', - 'quickBuy', - 'holdingCrypto', - 'buyFirstCrypto', + cat: [ + 'emptyStateNftSoldOut', + 'emptyStateCheckBackLater', + 'emptyStateNft404Page', + 'collectableNfts', + 'serverCatSystemError', + 'exchangeEmptyState', + 'catLostSystemError', + 'catHoldingWalletEmptyState', ], - piechart: ['namePortfolio', 'multiplePortfolios'], - UI: ['namePortfolio', 'advancedTradingUi', 'multiplePortfolios', 'webRAT'], - name: [ - 'namePortfolio', - 'ensProfilePic', - 'noLongAddresses', - 'claimCryptoUsername', - 'myNameIsSatoshi', + 'empty state': [ + 'emptyStateNftSoldOut', + 'emptyStateCheckBackLater', + 'emptyStateNft404Page', + 'tradeImmediately', + 'cryptoAndMore', + 'artFrameEmptyState', + 'walletFlyEmptyState', + 'squidEmptyState', + 'exchangeEmptyState', ], - type: ['namePortfolio'], - select: [ - 'namePortfolio', - 'multiplePortfolios', - 'yourContacts', - 'cardBoosted', - 'claimCryptoUsername', + distinguished: ['emptyStateNftSoldOut'], + artwork: ['emptyStateNftSoldOut', 'collectableNfts', 'artFrameEmptyState'], + gallery: ['emptyStateNftSoldOut'], + painting: ['emptyStateNftSoldOut'], + moment: ['emptyStateNftSoldOut'], + 'notice me': ['emptyStateNftSoldOut'], + 'mona lisa': ['emptyStateNftSoldOut'], + 'mona cat': ['emptyStateNftSoldOut'], + 'cat in a hat': ['emptyStateNftSoldOut'], + '🎩': ['emptyStateNftSoldOut'], + '🖼': [ + 'emptyStateNftSoldOut', + 'exploreDecentralizedApps', + 'collectableNfts', + 'artFrameEmptyState', ], - cart: ['buy'], - buy: [ - 'buy', - 'margin', - 'limitOrders', - 'leverage', - 'walletAsset', - 'quickBuy', - 'futures', - 'marginWarning', - 'buyFirstCrypto', - 'coinsInWallet', - ], - coin: [ - 'buy', - 'readyToTrade', - 'tradeGeneral', - 'coinbaseFees', - 'earnGlobe', - 'securityShield', - 'coinCheckmark', - 'cbxrp', - 'rotatingRewards', - 'limitOrders', - 'earnCryptoCard', - 'protocol', - 'cryptoPortfolio', - 'coinbaseOneWelcome', - 'realToUSDC', - 'dappsGaming', - 'cardBoosted', - 'secureAndTrusted', - 'earnMore', - 'cbdoge', - 'encryptedEverything', - 'estimatedAmount', - 'feeScale', - 'directDepositPhone', - 'freeBtc', - 'multicoinSupport', - 'defiHow', - 'stablecoin', - 'tradeImmediately', - 'exchange', - 'cryptoAndMore', + '🎨': ['emptyStateNftSoldOut'], + '🖌': ['emptyStateNftSoldOut'], + '✨': [ + 'emptyStateNftSoldOut', 'emptyStateNft404Page', - 'cryptoForBeginners', - 'oilAndGold', - 'earnToLearn', - 'cbltc', - 'earnGrowth', + 'primeEarn', + 'primeStaking', + 'ethStakingUpsell', + 'supportAndMore', + 'bigBtc', 'currencyPairs', - 'coinbaseOneSavingFunds', - 'semiCustodial', - 'moneyDecentralized', - 'congratulationsOnEarningCrypto', - 'futures', - 'secureStorage', - 'mining', - 'powerOfCrypto', - 'coinbaseOneUSDCBig', - 'networkWarning', - 'coinbaseOneAirdrop', - 'usdAndUsdc', - 'usdtToUSDC', - 'governance', - 'cbada', + 'primeDeFi', + 'instoEthStakingUpsell', + 'instoPrimeStaking', ], - bitcoin: ['buy', 'freeBtc', 'myNameIsSatoshi', 'cbbtc'], - shopping: ['buy'], - purchase: ['buy', 'limitOrders', 'buyFirstCrypto', 'coinsInWallet'], + '❇️': ['emptyStateNftSoldOut', 'emptyStateNft404Page', 'supportAndMore'], + '🐈‍⬛': [ + 'emptyStateNftSoldOut', + 'emptyStateCheckBackLater', + 'emptyStateNft404Page', + 'serverCatSystemError', + 'exchangeEmptyState', + 'catLostSystemError', + 'catHoldingWalletEmptyState', + ], + '🙀': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '🐱': [ + 'emptyStateNftSoldOut', + 'emptyStateCheckBackLater', + 'emptyStateNft404Page', + 'serverCatSystemError', + 'exchangeEmptyState', + 'catLostSystemError', + 'catHoldingWalletEmptyState', + ], + '😹': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '😽': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '😸': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '😺': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '😾': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + '😼': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], + swirl: ['emptyStateCheckBackLater'], browser: [ - 'coinbaseIsDownMobile', - 'error400', - 'errorWeb500', - 'coinbaseIsDown', - 'estimatedAmount', - 'errorWeb400', - 'errorRefresh', - 'errorRefreshWeb', 'emptyStateCheckBackLater', 'multiPlatformMobileAppBrowserExtension', 'switchAdvancedToSimpleTrading', - 'errorWeb', + 'coinbaseIsDown', + 'errorRefresh', 'errorWeb404', - 'browseDecentralizedApps', - ], - window: [ 'coinbaseIsDownMobile', 'error400', 'errorWeb500', - 'coinbaseIsDown', 'errorWeb400', - 'errorRefresh', - 'errorRefreshWeb', - 'developer', + 'estimatedAmount', + 'browseDecentralizedApps', 'errorWeb', - 'errorWeb404', + 'errorRefreshWeb', + ], + fun: ['emptyStateCheckBackLater'], + vibes: ['emptyStateCheckBackLater'], + 'big energy': ['emptyStateCheckBackLater'], + shapes: ['emptyStateCheckBackLater'], + movement: ['emptyStateCheckBackLater'], + '📱': [ + 'emptyStateCheckBackLater', + 'receivedCard', + 'exploreDecentralizedApps', + 'downloadCoinbaseWallet', + 'directDepositPhone', + 'walletNotifications', + 'appTrackingTransparency', + 'coinbaseOnePhoneLightning', + 'web3ActivitySigned', ], + '🔴': ['emptyStateCheckBackLater'], + vortex: ['emptyStateNft404Page'], + sparkle: ['emptyStateNft404Page', 'supportAndMore', 'freeBtc'], + party: ['emptyStateNft404Page', 'rocket'], + 'lets go': ['emptyStateNft404Page'], + lfg: ['emptyStateNft404Page'], error: [ + 'emptyStateNft404Page', + 'verifyInfo', + 'errorApp500', + 'docError', + 'coinbaseIsDown', + 'coinbaseCardIssue', + 'errorRefresh', + 'errorWeb404', 'coinbaseIsDownMobile', 'error400', 'errorWeb500', - 'coinbaseIsDown', - 'coinbaseCardIssue', 'errorWeb400', - 'errorRefresh', - 'errorRefreshWeb', - 'refresh', - 'emptyStateNft404Page', - 'errorWeb404Mobile', + 'bigError', + 'bigWarning', 'walletWarning', - 'verifyInfo', + 'outage', 'errorWeb', 'errorMoblie', - 'bigError', - 'docError', - 'outage', - 'bigWarning', - 'errorWeb404', - 'errorApp500', + 'errorWeb404Mobile', + 'errorRefreshWeb', + 'refresh', + ], + Gift: ['brdGift', 'receiveGift'], + BRD: ['brdGift', 'receiveGift'], + box: ['brdGift', 'governance', 'receiveGift', 'instoGovernance'], + '🎁': ['brdGift', 'receiveGift', 'coinbaseOneTokenRewards'], + hand: [ + 'brdGift', + 'coinbaseCardSpend', + 'paperHands', + 'ethStakingUpsell', + 'diamondHands', + 'receiveGift', + 'borrow', + 'addMoreCrypto', + 'bitcoinAndOtherCrypto', + 'smartContract', + 'gamer', + 'instoEthStakingUpsell', + ], + 'success state': [ + 'brdGift', + 'coinbaseCardSpend', + 'receivedCard', + 'documentCertified', + 'powerOfCrypto', + 'verifyEmail', + 'documentSuccess', + 'onTheList', + 'bigBtc', + 'payUpFront', + 'diamondHands', + 'yourContacts', + 'rocket', + 'readyToTrade', + 'appTrackingTransparency', + 'instoDocumentSuccess', + ], + collection: ['emptyCollection', 'hiddenCollection'], + art: [ + 'emptyCollection', + 'dappsArts', + 'exploreDecentralizedApps', + 'collectableNfts', + 'artFrameEmptyState', + 'hiddenCollection', ], + spider: ['emptyCollection'], + museum: ['emptyCollection', 'artFrameEmptyState'], web: [ + 'emptyCollection', + 'decentralizedWebWeb3', + 'errorApp500', + 'webRAT', + 'coinbaseIsDown', + 'errorWeb404', 'coinbaseIsDownMobile', 'error400', 'errorWeb500', - 'coinbaseIsDown', - 'cloud', 'errorWeb400', - 'generative', - 'errorWeb404Mobile', 'minting', 'dappsGeneral', + 'cloud', + 'generative', 'errorWeb', - 'emptyCollection', 'errorMoblie', - 'decentralizedWebWeb3', - 'errorWeb404', - 'errorApp500', - 'webRAT', + 'errorWeb404Mobile', ], - generic: [ - 'coinbaseIsDownMobile', - 'error400', - 'coinbaseIsDown', - 'coinbaseCardIssue', - 'errorWeb400', - 'success', - 'outage', - 'bigWarning', + quick: ['walletAsset', 'quickAndSimple', 'quickBuy'], + fast: [ + 'walletAsset', + 'quickBuy', + 'coinbaseOnePhoneLightning', + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', ], - general: ['coinbaseIsDownMobile', 'coinbaseIsDown', 'success', 'outage', 'bigWarning'], - is: ['coinbaseIsDownMobile', 'coinbaseIsDown', 'coinbaseCardIssue', 'myNameIsSatoshi'], - down: ['coinbaseIsDownMobile', 'coinbaseIsDown', 'holdingCrypto'], - funds: ['coinbaseIsDownMobile', 'coinbaseIsDown', 'coinbaseOneSavingFunds'], - secure: [ - 'coinbaseIsDownMobile', - 'securityShield', - 'coinbaseIsDown', - 'secureAndTrusted', - 'privateKey', - 'secureStorage', - 'secureGlobalTransactions', - ], - safu: ['coinbaseIsDownMobile', 'coinbaseIsDown'], - security: [ - 'coinbaseIsDownMobile', - 'securityShield', - 'coinbaseIsDown', - 'unlockKey', - 'phoneNumber', - 'add2Fa', - 'addPhoneNumber', - 'walletSecurity', - 'web3ActivitySigned', - 'enableBiometrics', - 'idVerificationSecure', - 'keyGeneration', - 'web3MobileSetupStart', + speedy: ['walletAsset', 'quickBuy'], + coins: [ + 'walletAsset', + 'dappsFinance', + 'gainsAndLosses', + 'portfolioPerformance', + 'selfCustodyCrypto', + 'trendingHotAssets', + 'cryptoWallet', + 'insuranceProtection', + 'buyFirstCrypto', + 'stayInControlSelfHostedWalletsStorage', + 'holdCrypto', + 'cryptoEconomy', + 'defiEarn', + 'invest', + 'holdingCrypto', + 'transactionLimit', + 'ratingsAndReviews', + 'walletUi', + 'insufficientBalance', + 'borrowWallet', + 'stableValue', + 'coinbaseCardSpendCrypto', + 'directDepositPhone', + 'staking', + 'cryptoAssets', + 'cryptoAndMore', + 'quickBuy', + 'sendCryptoFaster', + 'backedByUsDollar', + 'currencyPairs', + 'dappsL2Support', + 'ethereumToWallet', + 'ethStakingRewards', + 'defiHow', + 'stakingMissedReturns', + 'coinbaseOneUSDCBig', + 'tradeGeneral', + 'exchange', + 'oilAndGold', + 'stakingMissedReturnsUsdc', + 'usdAndUsdc', + 'instoEthStakingRewards', + 'instoStaking', + 'instoStakingMissedReturns', ], - lock: [ - 'coinbaseIsDownMobile', - 'securityShield', - 'coinbaseIsDown', - 'unlockKey', - 'phoneNumber', - 'add2Fa', - 'walletSecurity', - 'enableBiometrics', - 'idVerificationSecure', - 'keyGeneration', + assets: [ + 'walletAsset', + 'buyFirstCrypto', + 'earnMore', + 'holdingCrypto', + 'coinbaseCardSpendCrypto', + 'notificationsAndUpdates', + 'earnGrowth', + 'cryptoAssets', + 'quickBuy', + 'recommendInvest', + 'namePortfolio', + 'multiplePortfolios', + 'tradeGeneral', + 'earnGlobe', + 'instoEarnGlobe', ], - mobile: [ - 'coinbaseIsDownMobile', - 'error400', - 'errorRefresh', - 'errorRefreshWeb', - 'errorWeb404Mobile', - 'multiPlatformMobileAppBrowserExtension', - 'errorMoblie', + crypto: [ + 'walletAsset', + 'discardAssets', + 'cryptoForBeginners', + 'cryptoWallet', 'buyFirstCrypto', - 'errorApp500', + 'earnMore', + 'cardBoosted', + 'powerOfCrypto', + 'coinCheckmark', + 'holdCrypto', + 'cryptoEconomy', + 'webRAT', + 'myNameIsSatoshi', + 'fileYourCryptoTaxes', + 'holdingCrypto', + 'transactionLimit', + 'earnIdVerification', + 'coinbaseCardSpendCrypto', + 'notificationsAndUpdates', + 'earnGrowth', + 'cryptoAssets', + 'cryptoAppsWallet', + 'quickBuy', + 'selectCorrectCrypto', + 'sendCryptoFaster', + 'estimatedAmount', + 'fileYourCryptoTaxesCheck', + 'earnCryptoInterest', + 'browseDecentralizedApps', + 'namePortfolio', + 'multiplePortfolios', + 'coinFifty', ], - '⚠️': [ - 'coinbaseIsDownMobile', - 'error400', - 'errorWeb500', - 'coinbaseIsDown', + currencies: ['walletAsset', 'quickBuy'], + time: [ + 'walletAsset', + 'pending', + 'coinbaseOneWaitlist', + 'automaticPayments', + 'quickBuy', + 'requestSent', + 'keyGeneration', + 'enableBiometrics', + 'stakingMissedReturns', + 'stakingMissedReturnsUsdc', + 'instoStakingMissedReturns', + ], + trash: ['discardAssets'], + rubbish: ['discardAssets'], + delete: ['discardAssets'], + remove: ['discardAssets'], + restricted: ['restrictedCountry'], + country: ['restrictedCountry'], + warning: [ + 'restrictedCountry', + 'verifyInfo', + 'coinbaseOneDocWarning', + 'networkWarning', 'coinbaseCardIssue', - 'errorWeb400', - 'errorRefresh', - 'errorRefreshWeb', - 'refresh', - 'errorWeb404Mobile', + 'contactsListWarning', + 'bigWarning', 'walletWarning', + 'outage', + 'refresh', + ], + map: ['restrictedCountry'], + pin: ['restrictedCountry'], + point: ['restrictedCountry'], + location: ['restrictedCountry', 'basedInUsa'], + 'warning state': [ + 'restrictedCountry', 'verifyInfo', - 'errorWeb', - 'errorMoblie', + 'coinbaseOneDocWarning', 'docError', + 'coinbaseCardIssue', + 'contactsListWarning', 'idIssue', - 'errorWeb404', - 'errorApp500', + 'coinbaseOneCardWarning', + 'refresh', ], - '🔒': [ - 'coinbaseIsDownMobile', - 'coinbaseIsDown', - 'enableBiometrics', - 'keyGeneration', - 'web3MobileSetupStart', + Prime: ['primeEarn', 'primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Wallet: ['primeEarn', 'coinsInWallet'], + Earn: [ + 'primeEarn', + 'primeStaking', + 'earnMore', + 'earnIdVerification', + 'earnGrowth', + 'earnSuccess', + 'instoPrimeStaking', ], - '': [ - 'coinbaseIsDownMobile', - 'baseConnectLarge', - 'graphChartTrading', - 'coinbaseOneZeroPortal', - 'vipBadge', - 'coinbaseFees', - 'desktopAuthorized', - 'baseLoadingLarge', - 'airdrop', - 'coinbaseOneZeroPromotion', - 'baseRewardsCalmLarge', - 'recurringReward', - 'governanceMallet', - 'baseSecurityLarge', - 'predictionsMarkets', - 'communication', - 'options', - 'basePiechartLarge', - 'fiat', - 'errorRefresh', - 'basePeopleLarge', - 'errorRefreshWeb', - 'baseChartLarge', - 'baseCoinCryptoLarge', - 'baseDecentralizationLarge', - 'lightningNetwork', - 'videoUpload', - 'baseSendLarge', - 'invite', - 'baseNetworkLarge', - 'errorWeb404Mobile', - 'oilAndGold', - 'baseCreatorCoin', - 'baseCreatorCoinEmpty', - 'baseSocial', - 'borrowCoins', - 'baseLocationLarge', - 'instantUnstakingClock', - 'onChain', - 'walletWarning', - 'walletConfirmation', - 'baseMintNftLarge', - 'baseNftLarge', - 'videoReview', - 'desktopUnknown', - 'walletLoading', - 'basePaycoinLarge', - 'baseCoinNetworkLarge', - 'baseEmptyLarge', - 'offChain', - 'errorWeb404', - 'tradingWithLeverage', - 'futuresExpire', - 'moreGains', - 'futuresVsPerps', - 'errorApp500', - 'futuresAndPerps', - 'baseTargetLarge', - 'lend', - 'cardReloadFunds', - 'baseErrorButterfly', - 'baseCheck', - 'baseErrorLarge', + Rewards: ['primeEarn'], + Coins: ['primeEarn', 'primeStaking', 'bigBtc', 'coinsInWallet', 'primeDeFi', 'instoPrimeStaking'], + Assets: ['primeEarn', 'primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Coin: ['primeEarn', 'bigBtc', 'coinsInWallet', 'primeDeFi', 'coinFifty'], + Crypto: [ + 'primeEarn', + 'primeStaking', + 'bigBtc', + 'coinsInWallet', + 'primeDeFi', + 'instoPrimeStaking', ], - 'system error': [ - 'coinbaseIsDownMobile', - 'serverCatSystemError', - 'error400', - 'errorWeb500', - 'coinbaseIsDown', - 'chickenFishSystemError', - 'errorWeb400', - 'errorRefresh', - 'errorRefreshWeb', - 'errorWeb404Mobile', - 'catLostSystemError', - 'errorWeb', - 'errorMoblie', - 'iceCreamMeltingSystemError', - 'alienDonutSystemError', - 'errorWeb404', - 'errorApp500', + Currency: ['primeEarn', 'bigBtc', 'coinsInWallet'], + Money: ['primeEarn'], + Cash: ['primeEarn'], + Staking: ['primeStaking', 'instoPrimeStaking'], + Stake: ['primeStaking', 'instoPrimeStaking'], + Interest: ['primeStaking', 'instoPrimeStaking'], + Circles: ['primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Universe: ['primeStaking', 'primeDeFi', 'instoPrimeStaking'], + sparkles: [ + 'primeStaking', + 'ethStakingUpsell', + 'bigBtc', + 'coinbaseCardSpendCrypto', + 'instoEthStakingUpsell', + 'instoPrimeStaking', ], - margin: ['margin', 'marginWarning'], - trading: [ - 'margin', + chart: [ + 'advancedTradingChartsIndicatorsCandles', 'advancedTradingUi', - 'readyToTrade', - 'liquidationBufferYellow', - 'tradeGeneral', - 'slippageTolerance', - 'leverage', - 'liquidationBufferGreen', - 'defiDecentralizedTradingExchange', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - 'futures', - 'marginWarning', + 'stopLimitOrder', + 'accessToAdvancedCharts', 'orderBooks', - 'advancedTrading', - 'webRAT', - ], - add: [ - 'margin', - 'addBankAccount', - 'insufficientBalance', - 'yourContacts', - 'coinbaseOneCardWarning', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'add2Fa', - 'leverage', - 'coinbaseCardPocket', - 'addPhoneNumber', - 'addMoreCrypto', - 'receivedCard', - 'marginWarning', - 'verifyBankTransactions', - 'buyFirstCrypto', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', - 'verifyCardTransactions', - ], - stack: ['margin', 'digitalGold', 'leverage', 'marginWarning'], - more: [ - 'margin', - 'insufficientBalance', - 'leverage', - 'cryptoAndMore', - 'oilAndGold', - 'marginWarning', - 'transactionLimit', + 'focusLimitOrders', + 'notificationsAndUpdates', + 'defiEnrollBoost', + 'earnInterest', + 'stopLimitOrderDown', ], - lever: ['margin', 'leverage', 'marginWarning'], - up: [ - 'margin', - 'leverage', - 'engagement', - 'payUpFront', - 'marginWarning', - 'trendingHotAssets', + indicator: ['advancedTradingChartsIndicatorsCandles'], + candles: ['advancedTradingChartsIndicatorsCandles'], + green: [ + 'advancedTradingChartsIndicatorsCandles', 'portfolioPerformance', - ], - sell: ['margin', 'leverage', 'futures', 'marginWarning'], - put: ['margin', 'leverage', 'futures', 'marginWarning'], - options: ['margin', 'leverage', 'marginWarning'], - trade: [ - 'margin', - 'tradeGeneral', - 'realToUSDC', - 'leverage', - 'coinbaseWalletToTrade', - 'tradeImmediately', - 'exchange', - 'marginWarning', - 'tradeHistory', - 'ethStakingRewards', - 'usdtToUSDC', - 'webRAT', - ], - risk: ['margin', 'leverage', 'defiRisk', 'futures', 'marginWarning'], - users: ['p2pPayments', 'moneyDecentralized'], - arrows: [ - 'p2pPayments', - 'tradeGeneral', - 'rotatingRewards', + 'taxesDetails', + 'multiPlatformMobileAppBrowserExtension', 'stayInControlSelfHostedWalletsStorage', + 'decentralization', + 'multipleAccountsWalletsForOneUser', + 'invest', + 'watchVideos', 'borrowWallet', - 'defiEarn', - ], - P2P: ['p2pPayments', 'sendToUsername'], - payments: ['p2pPayments', 'crossBorderPayments', 'automaticPayments'], - USDC: ['coinbaseOneUSDCIncentives', 'retailUSDCRewards'], - rewards: [ - 'coinbaseOneUSDCIncentives', - 'bitcoinGlobe', - 'rotatingRewards', - 'cardBoosted', - 'coinbaseOneTokenRewards', - ], - incentives: [ - 'coinbaseOneUSDCIncentives', - 'bitcoinGlobe', - 'coinbaseOnePercentOff', - 'coinbaseOneTokenRewards', - ], - crystalBall: ['coinbaseOneUSDCIncentives', 'bitcoinGlobe'], - reward: [ - 'coinbaseOneUSDCIncentives', - 'bitcoinGlobe', - 'referralsCoinbaseOne', - 'referralsBitcoin', - 'referralsGenericCoin', + 'walletNotifications', + 'gasFeesNetworkFees', + 'performance', + 'defiDecentralizedBorrowingLending', + 'platform', + 'public', + 'sustainable', + 'liquidationBufferGreen', ], - returns: ['coinbaseOneUSDCIncentives', 'bitcoinGlobe'], - '🔮': ['coinbaseOneUSDCIncentives', 'bitcoinGlobe', 'oracle'], - commerce: ['commerceInvoices', 'commerceAccounting'], - invoices: ['commerceInvoices'], - plus: [ - 'commerceInvoices', - 'addBankAccount', - 'coinbaseOneCardWarning', - 'addCreditCard', - 'coinbaseCard', - 'add2Fa', - 'coinbaseCardPocket', - 'engagement', - 'taxesDetails', - 'receivedCard', - 'futures', - 'buyFirstCrypto', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', + red: [ + 'advancedTradingChartsIndicatorsCandles', + 'coinbaseOneInsufficientWallet', + 'videoRequest', + 'performance', + 'bigError', + 'web3ActivityError', + 'liquidationBufferRed', + 'liquidationBufferRedClose', ], - document: [ - 'commerceInvoices', - 'squidEmptyState', - 'onTheList', - 'refresh', - 'walletFlyEmptyState', + chain: ['blockchain', 'sidechain', 'connectPeople'], + blockchain: ['blockchain'], + hexagon: ['blockchain', 'sidechain'], + sequence: ['blockchain'], + congratulations: ['congratulationsOnEarningCrypto'], + prize: ['congratulationsOnEarningCrypto'], + beginner: ['cryptoForBeginners'], + circle: [ + 'cryptoForBeginners', + 'earnToLearn', + 'portfolioPerformance', 'taxesDetails', - 'reviewInfo', - 'commerceAccounting', - 'verifyInfo', - 'tradeHistory', - 'documentCertified', - 'idIssue', - 'processing', - 'collectingNfts', - 'coinbaseOneDocWarning', - ], - '📝': ['commerceInvoices', 'commerceAccounting'], - '📄': ['commerceInvoices', 'smartContract', 'commerceAccounting'], - '📃': ['commerceInvoices', 'smartContract', 'settlement', 'commerceAccounting'], - '📑': ['commerceInvoices', 'smartContract', 'commerceAccounting', 'docError'], - '➕': ['commerceInvoices'], - '💲': ['commerceInvoices', 'settlement', 'coinbaseOneSavingFunds', 'borrow'], - cbone: ['coinbaseOneRewards', 'coinbaseOneTokenRewards'], - earn: [ - 'coinbaseOneRewards', + 'trendingHotAssets', + 'moneyDecentralized', + 'multiPlatformMobileAppBrowserExtension', + 'poweredByEthereum', + 'stayInControlSelfHostedWalletsStorage', + 'walletSecurity', + 'decentralization', + 'cryptoEconomy', + 'encryptedEverything', + 'noFees', + 'watchVideos', + 'earn', + 'secureStorage', + 'videoRequest', + 'dappsGaming', + 'gasFeesNetworkFees', + 'linkingYourWalletToYourCoinbaseAccount', + 'selfCustody', + 'bigError', + 'mining', + 'layerOne', + 'noFeesMotion', + 'layerTwo', 'earnGlobe', - 'earnCryptoCard', 'lowCost', - 'earnNuxHome', - 'retailUSDCRewards', - 'freeBtc', - 'ethStakingUpsell', + 'public', + 'sustainable', + 'coinFifty', 'coinbaseOneEarn', - 'defiEarn', - 'earnInterest', - 'earnToLearn', - 'stakingMissedReturnsUsdc', - 'defiRisk', - 'earn', - 'stakingMissedReturns', - 'earnIdVerification', + 'instoWalletSecurity', + 'instoEarnGlobe', ], - interest: [ - 'coinbaseOneRewards', - 'earnNuxHome', - 'earnMore', - 'retailUSDCRewards', + book: ['cryptoForBeginners', 'advancedTradingUi'], + lines: ['cryptoForBeginners', 'taxesDetails'], + folder: [ + 'cryptoPortfolio', + 'walletFlyEmptyState', + 'squidEmptyState', + 'storage', + 'cryptoPortfolioUsdc', + ], + user: [ + 'didDecentralizedIdentity', + 'selfCustodyCrypto', + 'semiCustodial', + 'digitalCollectibles', + 'collectingNfts', + 'accountUnderReview', + 'linkingYourWalletToYourCoinbaseAccount', + 'selfCustody', + 'coinbaseOneUSDCBig', + 'usdAndUsdc', + ], + check: [ + 'didDecentralizedIdentity', + 'optInPushNotificationsEmail', + 'quickAndSimple', + 'taxesDetails', + 'completeAQuiz', + 'stressTestedColdStorage', + 'whyNotBoth', + 'coinCheckmark', + 'routingAccount', + 'emailNotification', + 'appTrackingTransparency', + 'selectCorrectCrypto', + 'fileYourCryptoTaxesCheck', + 'success', + 'web3MobileSetupSuccess', + 'web3ActivitySigned', + 'settlement', + 'platform', + 'walletConfirmation', + ], + circles: [ + 'didDecentralizedIdentity', + 'dappsArts', + 'dappsFinance', + 'optInPushNotificationsEmail', + 'selfCustodyCrypto', + 'shareOnSocialMedia', + 'completeAQuiz', + 'getStartedInMinutes', + 'startToday', + 'stressTestedColdStorage', + 'crossBorderPayments', + 'digitalCollectibles', + 'p2pPayments', + 'decentralizedWebWeb3', + 'invest', + 'multicoinSupport', + 'defiDecentralizedTradingExchange', + 'ratingsAndReviews', + 'stableValue', + 'secureAndTrusted', + 'staking', + 'cryptoAssets', + 'backedByUsDollar', + 'defiDecentralizedBorrowingLending', + 'globalTransactions', + 'coinFifty', + 'instoStaking', + ], + palette: ['dappsArts'], + globe: [ + 'dappsFinance', + 'cryptoEconomy', + 'secureGlobalTransactions', + 'globalTransactions', + 'earnGlobe', + 'instoEarnGlobe', + ], + music: ['dappsMusic', 'digitalCollectibles', 'collectingNfts'], + 'music note': ['dappsMusic', 'collectingNfts'], + earn: [ + 'earnToLearn', 'ethStakingUpsell', - 'earnCryptoInterest', + 'defiEarn', + 'freeBtc', + 'earn', + 'earnIdVerification', + 'defiRisk', 'earnInterest', - 'earnGrowth', - 'stakingMissedReturnsUsdc', - 'stakingMissedReturns', - ], - APY: ['coinbaseOneRewards', 'retailUSDCRewards'], - growth: [ + 'earnCryptoCard', 'coinbaseOneRewards', - 'rocket', 'earnNuxHome', - 'earnMore', 'retailUSDCRewards', - 'earnCryptoInterest', - 'earnGrowth', + 'stakingMissedReturns', + 'earnGlobe', + 'lowCost', + 'coinbaseOneEarn', + 'stakingMissedReturnsUsdc', + 'instoEthStakingUpsell', + 'instoEarnGlobe', + 'instoStakingMissedReturns', ], - rate: ['coinbaseOneRewards', 'coinbaseOnePercentOff', 'retailUSDCRewards'], - value: [ - 'coinbaseOneRewards', - 'bigBtc', - 'earnMore', - 'feeScale', - 'retailUSDCRewards', - 'stableValue', - 'earnGrowth', + learn: ['earnToLearn'], + bulb: ['earnToLearn'], + gain: ['gainsAndLosses', 'portfolioPerformance', 'trendingHotAssets', 'buyFirstCrypto'], + loss: ['gainsAndLosses'], + layers: ['layeredNetworks', 'layerThree'], + isometric: ['layeredNetworks', 'layerThree'], + networks: ['layeredNetworks', 'layerThree'], + ethereum: [ + 'layeredNetworks', + 'poweredByEthereum', + 'ethStakingUpsell', + 'ethereumToWallet', + 'claimCryptoUsername', + 'ensProfilePic', + 'noLongAddresses', + 'instoEthStakingUpsell', + ], + mail: ['optInPushNotificationsEmail'], + 'speech bubble': ['optInPushNotificationsEmail', 'ratingsAndReviews', 'videoRequest'], + portfolio: ['portfolioPerformance', 'namePortfolio', 'multiplePortfolios'], + simple: ['quickAndSimple', 'switchAdvancedToSimpleTrading'], + timer: ['quickAndSimple', 'getStartedInMinutes', 'pending'], + 'self custody': ['selfCustodyCrypto', 'selfCustody'], + 'semi custodial': ['semiCustodial'], + bank: [ + 'semiCustodial', + 'coinbaseOneSavingFunds', + 'addBankAccount', + 'japanVerifyId', + 'routingAccount', + 'payUpFront', + 'directDepositPhone', + 'cardAndPhone', + ], + share: [ + 'shareOnSocialMedia', + 'referralsBitcoin', + 'referralsWalletPhones', + 'referralsAvatars', + 'referralsCoinbaseOne', + 'referralsGenericCoin', ], - '📈': ['coinbaseOneRewards', 'retailUSDCRewards', 'earnInterest', 'advancedTrading'], + 'social media': ['shareOnSocialMedia'], + UI: ['advancedTradingUi', 'webRAT', 'namePortfolio', 'multiplePortfolios'], advanced: [ 'advancedTradingUi', - 'slippageTolerance', - 'accessToAdvancedCharts', 'stopLimitOrder', - 'stopLimitOrderDown', + 'accessToAdvancedCharts', 'switchAdvancedToSimpleTrading', - 'focusLimitOrders', 'advancedTrading', 'webRAT', - ], - chart: [ - 'advancedTradingUi', - 'defiEnrollBoost', - 'notificationsAndUpdates', - 'accessToAdvancedCharts', - 'earnInterest', - 'stopLimitOrder', - 'stopLimitOrderDown', - 'advancedTradingChartsIndicatorsCandles', - 'orderBooks', 'focusLimitOrders', + 'slippageTolerance', + 'stopLimitOrderDown', ], candlestick: ['advancedTradingUi'], order: ['advancedTradingUi', 'limitOrders', 'orderBooks'], - book: ['advancedTradingUi', 'cryptoForBeginners'], depth: ['advancedTradingUi'], - new: ['multiplePortfolios', 'notificationsAndUpdates', 'coinbaseRedesigned', 'innovation'], - enroll: ['defiEnrollBoost'], - boost: ['defiEnrollBoost'], - percentage: [ - 'defiEnrollBoost', - 'coinbaseOnePercentOff', - 'earnCryptoInterest', - 'defiEarn', - 'earnInterest', - 'fileYourCryptoTaxes', - 'fileYourCryptoTaxesCheck', - ], - defi: [ - 'defiEnrollBoost', - 'defiDecentralizedBorrowingLending', - 'defiHow', - 'defiDecentralizedTradingExchange', - 'defiEarn', - 'walletUi', - 'ethereumToWallet', - ], - 'credit card': ['cardErrorCB1', 'cardError'], - 'uh oh': ['cardErrorCB1', 'cardError', 'chickenFishSystemError', 'spacedOutSystemError'], - 'error state': [ - 'cardErrorCB1', - 'cardError', - 'coinbaseOneWalletWarning', - 'spacedOutSystemError', - 'marginWarning', - 'web3ActivityError', + equal: ['taxesDetails'], + document: [ + 'taxesDetails', + 'documentCertified', + 'reviewInfo', + 'verifyInfo', + 'onTheList', + 'collectingNfts', + 'coinbaseOneDocWarning', + 'idIssue', + 'commerceAccounting', + 'commerceInvoices', + 'tradeHistory', + 'walletFlyEmptyState', + 'squidEmptyState', + 'processing', + 'refresh', ], - cb1: [ - 'cardErrorCB1', - 'coinbaseOneLogo', - 'selectReward', - 'coinbaseOneUSDCBig', - 'coinbaseOneAirdrop', - 'usdAndUsdc', + credit: [ + 'coinbaseCardSpend', + 'receivedCard', + 'coinbaseCardLock', + 'addCreditCard', + 'payUpFront', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'coinbaseOneCardWarning', + 'cardAndPhone', ], - phone: [ - 'coinbaseOnePhoneLightning', - 'limitOrders', - 'videoRequest', + card: [ + 'coinbaseCardSpend', + 'receivedCard', + 'rotatingRewards', + 'coinbaseCardLock', + 'cardBoosted', + 'addCreditCard', + 'payUpFront', + 'verifyCardTransactions', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'automaticPayments', + 'earnCryptoCard', + 'coinbaseOneCardWarning', 'cardAndPhone', - 'phoneNumber', - 'addPhoneNumber', - 'directDepositPhone', - 'web3ActivitySigned', - 'appTrackingTransparency', - 'walletNotifications', - 'phoneUnknown', - 'ratingsAndReviews', - 'exploreDecentralizedApps', - 'referralsWalletPhones', - 'buyFirstCrypto', - 'emailNotification', ], - lighting: ['coinbaseOnePhoneLightning'], - fast: [ - 'coinbaseOnePhoneLightning', - 'lightningNetworkInvoice', - 'walletAsset', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'quickBuy', - 'lightningNetworkSend', - ], - speed: [ - 'coinbaseOnePhoneLightning', - 'lightningNetworkInvoice', - 'coinbaseWalletToTrade', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', - ], - '📱': [ - 'coinbaseOnePhoneLightning', - 'directDepositPhone', - 'web3ActivitySigned', - 'appTrackingTransparency', - 'emptyStateCheckBackLater', - 'walletNotifications', + plastic: [ + 'coinbaseCardSpend', 'receivedCard', - 'exploreDecentralizedApps', - 'downloadCoinbaseWallet', - ], - '🔋': ['coinbaseOnePhoneLightning'], - '⚡': [ - 'coinbaseOnePhoneLightning', - 'lightningNetworkInvoice', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', - ], - BTC: ['bitcoinGlobe', 'bigBtc', 'freeBtc', 'referralsBitcoin'], - bank: [ - 'addBankAccount', - 'routingAccount', - 'cardAndPhone', - 'directDepositPhone', - 'coinbaseOneSavingFunds', - 'semiCustodial', + 'coinbaseCardLock', + 'addCreditCard', 'payUpFront', - 'japanVerifyId', - ], - building: ['addBankAccount', 'japanVerifyId'], - clock: [ - 'addBankAccount', - 'walletAsset', - 'pending', - 'stakingMissedReturnsUsdc', - 'quickBuy', - 'futures', - 'marginWarning', - 'tradeHistory', - 'quickAndSimple', - 'stakingMissedReturns', + 'coinbaseCardPocket', + 'coinbaseCard', + 'coinbaseOneCardWarning', + 'cardAndPhone', ], - tower: ['addBankAccount'], - columns: ['addBankAccount'], money: [ - 'addBankAccount', - 'sendCryptoFaster', - 'insufficientBalance', - 'coinbaseOneCardWarning', - 'bitcoinAndOtherCrypto', + 'coinbaseCardSpend', + 'moneyDecentralized', + 'receivedCard', + 'coinbaseCardLock', + 'cardBoosted', + 'coinbaseOneSavingFunds', + 'cashExcitement', 'addCreditCard', + 'addBankAccount', 'bigBtc', - 'coinbaseCard', - 'remittances', - 'coinbaseCardPocket', - 'cardBoosted', - 'estimatedAmount', + 'transactionLimit', 'freeBtc', + 'earnIdVerification', + 'insufficientBalance', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', 'addMoreCrypto', - 'coinbaseOneSavingFunds', + 'bitcoinAndOtherCrypto', + 'sendCryptoFaster', + 'estimatedAmount', + 'coinbaseOneCardWarning', + 'remittances', + 'stakingMissedReturns', 'stakingMissedReturnsUsdc', - 'moneyDecentralized', + 'instoStakingMissedReturns', + ], + payment: [ + 'coinbaseCardSpend', 'receivedCard', - 'stakingMissedReturns', - 'transactionLimit', - 'earnIdVerification', 'coinbaseCardLock', - 'downloadCoinbaseWallet', 'cashExcitement', - 'coinbaseCardSpend', - ], - onboarding: [ - 'addBankAccount', - 'securityShield', - 'addPhoneNumber', - 'verifyEmail', - 'verifyIdDetails', - 'earnIdVerification', - 'emailNotification', - 'idCard', - 'japanVerifyId', + 'addCreditCard', + 'payUpFront', + 'coinbaseCardPocket', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'addMoreCrypto', + 'bitcoinAndOtherCrypto', + 'coinbaseOneCardWarning', + 'cardAndPhone', ], details: [ + 'coinbaseCardSpend', + 'receivedCard', + 'whyNotBoth', + 'coinbaseCardLock', + 'addCreditCard', 'addBankAccount', 'onTheList', + 'addPhoneNumber', + 'verifyBankTransactions', + 'verifyCardTransactions', 'accountUnderReview', - 'coinbaseOneCardWarning', - 'bitcoinAndOtherCrypto', - 'addCreditCard', - 'coinbaseCard', 'coinbaseCardPocket', - 'addPhoneNumber', 'notificationsAndUpdates', + 'coinbaseCard', 'addMoreCrypto', - 'whyNotBoth', - 'receivedCard', - 'verifyBankTransactions', - 'coinbaseCardLock', + 'bitcoinAndOtherCrypto', 'selectCorrectCrypto', - 'verifyCardTransactions', - 'coinbaseCardSpend', + 'coinbaseOneCardWarning', ], - percent: ['coinbaseOnePercentOff', 'earnGlobe', 'defiRisk'], - discount: ['coinbaseOnePercentOff'], - priceTag: ['coinbaseOnePercentOff'], - '🏷️': ['coinbaseOnePercentOff'], - gold: ['digitalGold', 'oilAndGold'], - cursor: ['digitalGold'], - bricks: ['digitalGold'], - '🥇': ['digitalGold'], - '🧱': ['digitalGold'], - success: ['readyToTrade', 'rocket', 'walletConfirmation', 'success', 'documentSuccess'], - balloon: ['readyToTrade'], - welcome: ['readyToTrade', 'coinbaseOneWelcome'], account: [ - 'readyToTrade', - 'coinbaseOneCardWarning', - 'routingAccount', - 'bitcoinAndOtherCrypto', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', - 'addPhoneNumber', - 'addMoreCrypto', - 'appTrackingTransparency', + 'coinbaseCardSpend', 'receivedCard', - 'verifyIdDetails', - 'verifyBankTransactions', 'coinbaseCardLock', + 'addCreditCard', 'japanVerifyId', + 'addPhoneNumber', + 'routingAccount', + 'verifyBankTransactions', 'verifyCardTransactions', - 'coinbaseCardSpend', - ], - created: ['readyToTrade'], - start: ['readyToTrade', 'startToday', 'realToUSDC', 'tradeImmediately', 'usdtToUSDC'], - 'success state': [ + 'verifyIdDetails', + 'coinbaseCardPocket', + 'coinbaseCard', 'readyToTrade', - 'rocket', - 'onTheList', - 'yourContacts', - 'bigBtc', - 'brdGift', + 'addMoreCrypto', 'appTrackingTransparency', - 'diamondHands', - 'verifyEmail', - 'payUpFront', - 'receivedCard', - 'documentCertified', - 'powerOfCrypto', - 'documentSuccess', - 'coinbaseCardSpend', - ], - squid: ['squidEmptyState'], - folder: ['squidEmptyState', 'cryptoPortfolio', 'walletFlyEmptyState', 'storage'], - '🦑': ['squidEmptyState'], - '📁': ['squidEmptyState', 'walletFlyEmptyState'], - 'empty state': [ - 'squidEmptyState', - 'artFrameEmptyState', - 'emptyStateNftSoldOut', - 'emptyStateCheckBackLater', - 'tradeImmediately', - 'cryptoAndMore', - 'emptyStateNft404Page', - 'walletFlyEmptyState', - 'exchangeEmptyState', - ], - vip: ['vipBadge'], - badge: ['vipBadge'], - lanyard: ['vipBadge'], - stars: ['vipBadge', 'bigBtc', 'ratingsAndReviews', 'cryptoWallet', 'ethStakingRewards'], - liquidation: [ - 'liquidationBufferYellow', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', + 'bitcoinAndOtherCrypto', + 'coinbaseOneCardWarning', ], - buffer: [ - 'liquidationBufferYellow', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', + trending: ['trendingHotAssets'], + hot: ['trendingHotAssets'], + pencil: ['completeAQuiz'], + cross: ['completeAQuiz', 'dappsGaming', 'remittances'], + complete: ['completeAQuiz', 'documentSuccess', 'instoDocumentSuccess'], + quiz: ['completeAQuiz'], + stars: [ + 'cryptoWallet', + 'bigBtc', + 'ratingsAndReviews', + 'ethStakingRewards', + 'vipBadge', + 'instoEthStakingRewards', ], - gauge: [ - 'liquidationBufferYellow', + wallet: [ + 'cryptoWallet', + 'walletSecurity', + 'referralsWalletPhones', + 'whyNotBoth', + 'coinbaseOneInsufficientWallet', + 'coinbaseWalletToTrade', + 'hardwareWallets', + 'exploreDecentralizedApps', + 'walletUi', 'insufficientBalance', - 'feeScale', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - ], - threshold: [ - 'liquidationBufferYellow', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - ], - leverage: [ - 'liquidationBufferYellow', - 'leverage', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - ], - derivatives: [ - 'liquidationBufferYellow', - 'liquidationBufferGreen', - 'liquidationBufferRed', - 'liquidationBufferRedClose', - ], - empty: ['catHoldingWalletEmptyState'], - wallet: [ - 'catHoldingWalletEmptyState', - 'insufficientBalance', - 'coinbaseOneInsufficientWallet', - 'coinbaseOneWalletWarning', - 'cryptoAppsWallet', - 'walletSecurity', - 'coinbaseWalletToTrade', 'borrowWallet', 'walletNotifications', + 'cryptoAppsWallet', + 'walletLoading', + 'linkingYourWalletToYourCoinbaseAccount', + 'selfCustody', + 'coinbaseOneWalletWarning', + 'exchangeEmptyState', + 'catHoldingWalletEmptyState', + 'browseDecentralizedApps', + 'ethereumToWallet', 'web3MobileSetupSuccess', + 'web3ActivityError', + 'web3MobileSetupStart', 'walletWarning', 'walletConfirmation', - 'selfCustody', - 'whyNotBoth', + 'instoWalletSecurity', + ], + coinbase: [ + 'referralsBitcoin', + 'referralsWalletPhones', + 'rotatingRewards', + 'coinbaseOneLogo', + 'selectReward', + 'cardBoosted', + 'coinbaseRedesigned', + 'coinbaseIsDown', 'exploreDecentralizedApps', 'walletUi', - 'referralsWalletPhones', - 'cryptoWallet', - 'walletLoading', - 'hardwareWallets', - 'browseDecentralizedApps', - 'web3ActivityError', + 'coinbaseIsDownMobile', + 'referralsAvatars', 'linkingYourWalletToYourCoinbaseAccount', - 'exchangeEmptyState', - 'ethereumToWallet', - 'web3MobileSetupStart', + 'coinbaseOneWelcome', + 'referralsCoinbaseOne', + 'coinbaseOneUSDCBig', + 'referralsGenericCoin', + 'coinbaseOneAirdrop', + 'usdAndUsdc', ], - cat: [ - 'catHoldingWalletEmptyState', - 'serverCatSystemError', + referral: [ + 'referralsBitcoin', + 'referralsWalletPhones', + 'freeBtc', + 'referralsAvatars', + 'referralsCoinbaseOne', + 'referralsGenericCoin', + ], + avatar: [ + 'referralsBitcoin', + 'referralsWalletPhones', + 'accountUnderReview', + 'twoIdVerify', + 'yourContacts', + 'idBack', + 'idVerificationSecure', + 'referralsAvatars', + 'idIssue', + 'idFront', 'collectableNfts', - 'emptyStateNftSoldOut', - 'emptyStateCheckBackLater', - 'emptyStateNft404Page', - 'catLostSystemError', - 'exchangeEmptyState', + 'ensProfilePic', + 'referralsCoinbaseOne', + 'coinbaseOneUSDCBig', + 'developer', + 'innovation', + 'referralsGenericCoin', + 'usdAndUsdc', ], - cute: ['catHoldingWalletEmptyState', 'exchangeEmptyState'], - '🐱': [ - 'catHoldingWalletEmptyState', - 'serverCatSystemError', - 'emptyStateNftSoldOut', - 'emptyStateCheckBackLater', - 'emptyStateNft404Page', - 'catLostSystemError', - 'exchangeEmptyState', + magic: [ + 'referralsBitcoin', + 'referralsWalletPhones', + 'referralsAvatars', + 'referralsCoinbaseOne', + 'oracle', + 'referralsGenericCoin', ], - '🐈': [ - 'catHoldingWalletEmptyState', - 'serverCatSystemError', - 'catLostSystemError', - 'exchangeEmptyState', + network: [ + 'referralsBitcoin', + 'referralsWalletPhones', + 'powerOfCrypto', + 'networkWarning', + 'referralsAvatars', + 'referralsCoinbaseOne', + 'protocol', + 'scalable', + 'privateKey', + 'layerTwo', + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + 'referralsGenericCoin', + 'instoPrivateKey', ], - '🐈‍⬛': [ - 'catHoldingWalletEmptyState', - 'serverCatSystemError', - 'emptyStateNftSoldOut', - 'emptyStateCheckBackLater', - 'emptyStateNft404Page', - 'catLostSystemError', - 'exchangeEmptyState', + heads: ['referralsBitcoin', 'referralsAvatars', 'referralsCoinbaseOne', 'referralsGenericCoin'], + people: [ + 'referralsBitcoin', + 'yourContacts', + 'referralsAvatars', + 'referralsCoinbaseOne', + 'connectPeople', + 'referralsGenericCoin', ], - swap: ['tradeGeneral', 'realToUSDC', 'tradeImmediately', 'usdtToUSDC'], - switch: [ - 'tradeGeneral', - 'rotatingRewards', - 'realToUSDC', - 'tradeImmediately', - 'switchAdvancedToSimpleTrading', - 'usdtToUSDC', - 'advancedTrading', + profile: [ + 'referralsBitcoin', + 'accountUnderReview', + 'referralsAvatars', + 'ensProfilePic', + 'referralsCoinbaseOne', + 'innovation', + 'referralsGenericCoin', ], - fees: ['coinbaseFees', 'gasFeesNetworkFees', 'noFeesMotion', 'noFees'], - cloud: ['cloudBacking', 'cloud'], - backing: ['cloudBacking'], - data: ['cloudBacking', 'cloud', 'web3MobileSetupSuccess', 'storage', 'idVerificationSecure'], - information: ['cloudBacking', 'refresh', 'reviewInfo', 'verifyInfo', 'storage', 'processing'], - hand: [ - 'smartContract', - 'bitcoinAndOtherCrypto', - 'brdGift', - 'ethStakingUpsell', - 'addMoreCrypto', - 'gamer', - 'diamondHands', - 'paperHands', - 'receiveGift', - 'borrow', - 'coinbaseCardSpend', + pic: [ + 'referralsBitcoin', + 'referralsAvatars', + 'ensProfilePic', + 'referralsCoinbaseOne', + 'referralsGenericCoin', ], - handshake: ['smartContract'], - 'smart contracts': ['smartContract'], - contracts: ['smartContract'], - '📜': ['smartContract'], - globe: [ - 'earnGlobe', - 'dappsFinance', - 'cryptoEconomy', - 'globalTransactions', - 'secureGlobalTransactions', + PFP: [ + 'referralsBitcoin', + 'referralsAvatars', + 'ensProfilePic', + 'referralsCoinbaseOne', + 'referralsGenericCoin', ], - yield: [ - 'earnGlobe', - 'earnMore', - 'earnGrowth', - 'stakingMissedReturnsUsdc', - 'holdCrypto', - 'defiRisk', - 'stakingMissedReturns', + Bitcoin: [ + 'referralsBitcoin', + 'bigBtc', + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', ], - circle: [ - 'earnGlobe', - 'videoRequest', - 'stayInControlSelfHostedWalletsStorage', - 'lowCost', - 'dappsGaming', - 'encryptedEverything', - 'walletSecurity', - 'coinbaseOneEarn', - 'public', - 'cryptoEconomy', - 'cryptoForBeginners', - 'earnToLearn', - 'gasFeesNetworkFees', + BTC: ['referralsBitcoin', 'bigBtc', 'freeBtc', 'bitcoinGlobe'], + reward: [ + 'referralsBitcoin', + 'coinbaseOneUSDCIncentives', + 'bitcoinGlobe', + 'referralsCoinbaseOne', + 'referralsGenericCoin', + ], + 'stop watch': ['getStartedInMinutes'], + 'get started': ['getStartedInMinutes'], + umbrella: ['insuranceProtection'], + insurance: ['insuranceProtection'], + protection: [ + 'insuranceProtection', + 'idVerificationSecure', + 'web3ActivitySigned', + 'keyGeneration', + 'enableBiometrics', + ], + users: ['moneyDecentralized', 'p2pPayments'], + decentralized: [ 'moneyDecentralized', - 'decentralization', - 'noFeesMotion', - 'multiPlatformMobileAppBrowserExtension', - 'taxesDetails', - 'selfCustody', + 'decentralizedWebWeb3', + 'defiDecentralizedBorrowingLending', + 'protocol', + 'scalable', + 'dappsGeneral', 'layerTwo', - 'layerOne', - 'poweredByEthereum', - 'earn', - 'secureStorage', - 'trendingHotAssets', - 'mining', + ], + monitor: ['multiPlatformMobileAppBrowserExtension', 'browserExtension'], + multiplatform: ['multiPlatformMobileAppBrowserExtension'], + app: ['multiPlatformMobileAppBrowserExtension', 'walletUi'], + mobile: [ + 'multiPlatformMobileAppBrowserExtension', + 'buyFirstCrypto', + 'errorApp500', + 'errorRefresh', + 'coinbaseIsDownMobile', + 'error400', + 'errorMoblie', + 'errorWeb404Mobile', + 'errorRefreshWeb', + ], + extension: ['multiPlatformMobileAppBrowserExtension'], + puzzle: ['multiPlatformMobileAppBrowserExtension', 'browserExtension'], + powered: ['poweredByEthereum'], + first: ['buyFirstCrypto'], + purchase: ['buyFirstCrypto', 'coinsInWallet', 'limitOrders', 'buy'], + financial: ['buyFirstCrypto'], + freedom: ['buyFirstCrypto'], + HODL: ['buyFirstCrypto', 'holdingCrypto'], + receive: [ + 'buyFirstCrypto', + 'whyNotBoth', + 'emailNotification', + 'notificationsAndUpdates', + 'ethereumToWallet', + 'minting', + ], + phone: [ + 'buyFirstCrypto', + 'referralsWalletPhones', + 'phoneUnknown', + 'addPhoneNumber', + 'limitOrders', + 'emailNotification', + 'exploreDecentralizedApps', + 'ratingsAndReviews', + 'phoneNumber', + 'videoRequest', + 'directDepositPhone', + 'walletNotifications', + 'appTrackingTransparency', + 'cardAndPhone', + 'coinbaseOnePhoneLightning', + 'web3ActivitySigned', + 'instoPhoneUnknown', + ], + play: ['startToday', 'digitalCollectibles', 'collectingNfts', 'watchVideos'], + start: ['startToday', 'tradeImmediately', 'readyToTrade', 'usdtToUSDC', 'realToUSDC'], + today: ['startToday', 'tradeImmediately', 'usdtToUSDC', 'realToUSDC'], + arrows: [ + 'stayInControlSelfHostedWalletsStorage', + 'p2pPayments', + 'rotatingRewards', + 'defiEarn', + 'borrowWallet', + 'tradeGeneral', + ], + lock: [ + 'walletSecurity', + 'add2Fa', + 'coinbaseIsDown', + 'phoneNumber', + 'idVerificationSecure', + 'coinbaseIsDownMobile', + 'securityShield', + 'keyGeneration', + 'enableBiometrics', + 'unlockKey', + 'instoWalletSecurity', + 'instoAdd2Fa', + ], + square: [ + 'walletSecurity', + 'stressTestedColdStorage', + 'digitalCollectibles', + 'collectingNfts', + 'multicoinSupport', 'watchVideos', - 'bigError', - 'noFees', - 'portfolioPerformance', + 'dappsGaming', 'linkingYourWalletToYourCoinbaseAccount', - 'sustainable', - 'coinFifty', + 'instoWalletSecurity', ], - international: ['earnGlobe', 'remittances'], - 'multiple wallets': ['multipleAccountsWalletsForOneUser'], - safety: ['securityShield', 'unlockKey', 'add2Fa'], - padlock: ['securityShield'], - confirm: [ - 'coinCheckmark', - 'coinbaseOneCardWarning', + security: [ + 'walletSecurity', + 'add2Fa', + 'addPhoneNumber', + 'coinbaseIsDown', + 'phoneNumber', + 'idVerificationSecure', + 'coinbaseIsDownMobile', + 'securityShield', + 'web3ActivitySigned', + 'keyGeneration', + 'enableBiometrics', + 'web3MobileSetupStart', + 'unlockKey', + 'instoWalletSecurity', + 'instoAdd2Fa', + ], + method: [ + 'receivedCard', + 'coinbaseCardLock', 'addCreditCard', - 'coinbaseCard', + 'payUpFront', 'coinbaseCardPocket', - 'whyNotBoth', + 'downloadCoinbaseWallet', + 'coinbaseCard', + 'coinbaseOneCardWarning', + 'cardAndPhone', + ], + confirm: [ 'receivedCard', - 'success', - 'processing', + 'whyNotBoth', 'coinbaseCardLock', + 'coinCheckmark', 'documentSuccess', + 'addCreditCard', + 'coinbaseCardPocket', + 'coinbaseCard', 'selectCorrectCrypto', + 'coinbaseOneCardWarning', + 'processing', + 'success', + 'instoDocumentSuccess', ], - confirmation: [ - 'coinCheckmark', + '✔️': ['receivedCard', 'appTrackingTransparency'], + 'paper hands': ['paperHands'], + paper: ['paperHands', 'onTheList', 'myNameIsSatoshi'], + 'toilet paper': ['paperHands'], + 'sell off': ['paperHands'], + device: ['referralsWalletPhones'], + connection: [ + 'crossBorderPayments', + 'errorApp500', + 'errorWeb404', + 'errorWeb500', + 'errorWeb', + 'errorMoblie', + 'errorWeb404Mobile', + ], + 'cross border': ['crossBorderPayments'], + payments: ['crossBorderPayments', 'p2pPayments', 'automaticPayments'], + focus: ['stopLimitOrder', 'focusLimitOrders', 'stopLimitOrderDown'], + limit: [ + 'stopLimitOrder', + 'limitOrders', + 'transactionLimit', + 'focusLimitOrders', + 'stopLimitOrderDown', + ], + stoplimitorder: ['stopLimitOrder', 'stopLimitOrderDown'], + advancedtrading: ['stopLimitOrder', 'focusLimitOrders', 'stopLimitOrderDown'], + unknown: ['phoneUnknown', 'instoPhoneUnknown'], + 'question mark': ['phoneUnknown', 'supportAndMore', 'instoPhoneUnknown'], + '❓': ['phoneUnknown', 'supportAndMore', 'instoPhoneUnknown'], + '❔': ['phoneUnknown', 'instoPhoneUnknown'], + '': [ + 'desktopUnknown', + 'cardReloadFunds', + 'videoReview', + 'errorApp500', + 'invite', + 'recurringReward', + 'errorRefresh', + 'videoUpload', + 'errorWeb404', + 'coinbaseIsDownMobile', + 'walletLoading', + 'coinbaseFees', + 'desktopAuthorized', + 'airdrop', + 'fiat', + 'lend', + 'borrowCoins', + 'communication', + 'offChain', + 'onChain', + 'governanceMallet', + 'lightningNetwork', 'walletWarning', 'walletConfirmation', - 'success', - 'outage', - 'bigWarning', + 'errorWeb404Mobile', + 'errorRefreshWeb', + 'oilAndGold', + 'coinbaseOneZeroPortal', + 'coinbaseOneZeroPromotion', + 'vipBadge', + 'baseErrorButterfly', + 'baseCheck', + 'baseCoinCryptoLarge', + 'basePiechartLarge', + 'baseMintNftLarge', + 'baseChartLarge', + 'basePeopleLarge', + 'baseConnectLarge', + 'baseLocationLarge', + 'baseSecurityLarge', + 'baseLoadingLarge', + 'baseErrorLarge', + 'baseDecentralizationLarge', + 'baseCoinNetworkLarge', + 'baseTargetLarge', + 'baseEmptyLarge', + 'baseSendLarge', + 'baseNftLarge', + 'baseRewardsCalmLarge', + 'baseNetworkLarge', + 'basePaycoinLarge', + 'predictionsMarkets', + 'options', + 'instantUnstakingClock', + 'baseCreatorCoinEmpty', + 'baseCreatorCoin', + 'baseSocial', + 'graphChartTrading', + 'moreGains', + 'futuresExpire', + 'tradingWithLeverage', + 'futuresAndPerps', + 'futuresVsPerps', + 'test', + 'borrowCoinsBtc', + 'tradingPerpetualsUsdc', + 'instoAddBankAccount', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', ], - check: [ - 'coinCheckmark', - 'completeAQuiz', - 'routingAccount', - 'didDecentralizedIdentity', - 'web3ActivitySigned', - 'appTrackingTransparency', - 'stressTestedColdStorage', - 'settlement', - 'web3MobileSetupSuccess', - 'walletConfirmation', - 'taxesDetails', + both: ['whyNotBoth', 'coinbaseOneUSDCBig', 'usdAndUsdc'], + addresses: ['whyNotBoth', 'noLongAddresses'], + address: ['whyNotBoth'], + currency: [ 'whyNotBoth', - 'quickAndSimple', - 'platform', - 'success', - 'optInPushNotificationsEmail', - 'emailNotification', - 'selectCorrectCrypto', - 'fileYourCryptoTaxesCheck', + 'holdCrypto', + 'webRAT', + 'tradeImmediately', + 'holdingCrypto', + 'transactionLimit', + 'coinbaseCardSpendCrypto', + 'notificationsAndUpdates', + 'sendCryptoFaster', + 'currencyPairs', + 'usdtToUSDC', + 'realToUSDC', ], - mark: ['coinCheckmark', 'success', 'fileYourCryptoTaxesCheck'], - checkmark: [ - 'coinCheckmark', - 'onTheList', - 'faceMatchReal', - 'walletConfirmation', - 'verifyEmail', + make: ['whyNotBoth', 'earnIdVerification'], + sure: ['whyNotBoth', 'selectCorrectCrypto'], + certified: ['documentCertified'], + correct: [ 'documentCertified', - 'private', + 'selectCorrectCrypto', + 'faceMatchReal', + 'processing', 'success', + 'private', + 'walletConfirmation', + ], + ribbon: ['documentCertified'], + checkmark: [ + 'documentCertified', + 'coinCheckmark', + 'verifyEmail', 'documentSuccess', + 'onTheList', 'fileYourCryptoTaxesCheck', + 'faceMatchReal', + 'success', + 'private', + 'walletConfirmation', + 'instoDocumentSuccess', ], - '💎': ['quest'], - '🔍': ['quest', 'verifyIdDetails'], - diamond: ['quest', 'diamondHands'], - 'magnifying glass': ['quest', 'reviewInfo', 'catLostSystemError'], - art: [ - 'dappsArts', - 'artFrameEmptyState', - 'collectableNfts', - 'exploreDecentralizedApps', - 'emptyCollection', - 'hiddenCollection', - ], - palette: ['dappsArts'], - contacts: ['contactsListWarning', 'yourContacts'], - contact: ['contactsListWarning'], - list: ['contactsListWarning', 'onTheList', 'yourContacts'], - warning: [ - 'contactsListWarning', - 'coinbaseCardIssue', - 'restrictedCountry', - 'refresh', - 'walletWarning', - 'verifyInfo', - 'outage', - 'bigWarning', - 'networkWarning', - 'coinbaseOneDocWarning', - ], - '⚠': ['contactsListWarning'], - 'warning state': [ - 'contactsListWarning', - 'coinbaseOneCardWarning', - 'coinbaseCardIssue', - 'restrictedCountry', - 'refresh', - 'verifyInfo', - 'docError', - 'idIssue', - 'coinbaseOneDocWarning', - ], - clipboard: ['coinbaseOneWaitlist', 'onTheList', 'refresh', 'reviewInfo', 'verifyInfo'], - waitlist: ['coinbaseOneWaitlist'], - checklist: ['coinbaseOneWaitlist'], - 'waiting list': ['coinbaseOneWaitlist'], - waiting: [ - 'coinbaseOneWaitlist', - 'onTheList', - 'pending', - 'requestSent', - 'polling', - 'iceCreamMeltingSystemError', + confirmed: ['documentCertified', 'onTheList'], + reviewed: ['documentCertified', 'documentSuccess', 'instoDocumentSuccess'], + approved: ['documentCertified'], + stamped: ['documentCertified'], + papers: ['documentCertified'], + eth: [ + 'ethStakingUpsell', + 'ethereumToWallet', + 'ethStakingRewards', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', ], - '📋': ['coinbaseOneWaitlist'], - '⏱': ['coinbaseOneWaitlist'], - time: [ - 'coinbaseOneWaitlist', - 'walletAsset', - 'pending', + staking: [ + 'ethStakingUpsell', + 'governance', + 'staking', + 'ethStakingRewards', + 'defiHow', + 'stakingMissedReturns', + 'earnGlobe', 'stakingMissedReturnsUsdc', - 'quickBuy', - 'enableBiometrics', - 'requestSent', - 'automaticPayments', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + ], + upsell: ['ethStakingUpsell', 'instoEthStakingUpsell'], + interest: [ + 'ethStakingUpsell', + 'earnMore', + 'earnInterest', + 'earnGrowth', + 'earnCryptoInterest', + 'coinbaseOneRewards', + 'earnNuxHome', + 'retailUSDCRewards', 'stakingMissedReturns', - 'keyGeneration', - ], - server: ['serverCatSystemError', 'storage'], - 'get out': ['serverCatSystemError'], - 'cat being a cat': ['serverCatSystemError'], - rocket: ['rocket'], - space: ['rocket', 'spacedOutSystemError', 'alienDonutSystemError'], - blast: ['rocket'], - off: ['rocket'], - moon: ['rocket', 'cryptoAndMore', 'oilAndGold'], - party: ['rocket', 'emptyStateNft404Page'], - '🚀': ['rocket'], - celebrate: ['rocket'], - positive: ['rocket', 'faceMatchReal', 'walletConfirmation', 'private', 'success'], - excitement: ['rocket', 'coinbaseRedesigned', 'cashExcitement'], - play: ['startToday', 'digitalCollectibles', 'watchVideos', 'collectingNfts'], - today: ['startToday', 'realToUSDC', 'tradeImmediately', 'usdtToUSDC'], - send: [ - 'sendCryptoFaster', - 'yourContacts', - 'remittances', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', - 'transactionLimit', - 'ethereumToWallet', + 'stakingMissedReturnsUsdc', + 'instoEthStakingUpsell', + 'instoStakingMissedReturns', ], - faster: ['sendCryptoFaster'], - lightning: ['sendCryptoFaster'], - bolt: [ - 'sendCryptoFaster', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', + eth2: [ + 'ethStakingUpsell', + 'ethStakingRewards', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', ], - move: ['sendCryptoFaster'], - quicker: ['sendCryptoFaster'], - currency: [ - 'sendCryptoFaster', - 'coinbaseCardSpendCrypto', - 'realToUSDC', - 'notificationsAndUpdates', + '2.0': ['ethStakingUpsell', 'instoEthStakingUpsell'], + P2P: ['p2pPayments', 'sendToUsername'], + switch: [ + 'rotatingRewards', + 'switchAdvancedToSimpleTrading', + 'advancedTrading', 'tradeImmediately', - 'currencyPairs', - 'holdCrypto', - 'whyNotBoth', - 'holdingCrypto', - 'transactionLimit', 'usdtToUSDC', - 'webRAT', - ], - asset: [ - 'sendCryptoFaster', - 'bigBtc', + 'tradeGeneral', 'realToUSDC', - 'notificationsAndUpdates', - 'tradeImmediately', - 'networkWarning', - 'transactionLimit', - 'coinsInWallet', - 'usdtToUSDC', ], - '⚡️': ['sendCryptoFaster'], - insufficient: ['insufficientBalance', 'coinbaseOneInsufficientWallet'], - balance: ['insufficientBalance', 'stableValue', 'gainsAndLosses', 'futures'], - not: ['insufficientBalance', 'coinbaseCardIssue', 'errorWeb404Mobile', 'errorWeb404'], - enough: ['insufficientBalance'], - low: ['insufficientBalance'], - need: ['insufficientBalance'], - Government: ['governanceMallet'], - Policy: ['governanceMallet'], - Legislation: ['governanceMallet'], - Governance: ['governanceMallet'], - '⚖️': ['governanceMallet'], - '🏛': ['governanceMallet'], - confirmed: ['onTheList', 'documentCertified'], - on: ['onTheList', 'stakingMissedReturnsUsdc', 'stakingMissedReturns'], - notify: ['onTheList', 'emailNotification'], - paper: ['onTheList', 'myNameIsSatoshi', 'paperHands'], - conversion: ['cbxrp', 'cbdoge', 'cbltc', 'cbbtc', 'cbada'], - convert: ['cbxrp', 'realToUSDC', 'cbdoge', 'cbltc', 'cbbtc', 'usdtToUSDC', 'cbada'], - xrp: ['cbxrp'], - cbxrp: ['cbxrp'], - pencil: ['completeAQuiz'], - cross: ['completeAQuiz', 'remittances', 'dappsGaming'], - complete: ['completeAQuiz', 'documentSuccess'], - quiz: ['completeAQuiz'], - card: [ + rotate: ['rotatingRewards', 'idAngles'], + rewards: [ 'rotatingRewards', - 'coinbaseOneCardWarning', - 'earnCryptoCard', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', 'cardBoosted', - 'payUpFront', - 'receivedCard', - 'automaticPayments', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', - 'verifyCardTransactions', - 'coinbaseCardSpend', - ], - rotate: ['rotatingRewards', 'idAngles'], - person: ['yourContacts', 'web3ActivitySigned', 'enableBiometrics', 'keyGeneration'], - friends: ['yourContacts'], - family: ['yourContacts'], - associates: ['yourContacts'], - connect: [ - 'yourContacts', - 'routingAccount', - 'ledgerPlugin', - 'connectPeople', - 'coinbaseOneUSDCBig', - 'usdAndUsdc', - ], - access: ['yourContacts', 'unlockKey', 'privateKey', 'ethereumToWallet', 'ledgerAccess'], - limit: [ - 'limitOrders', - 'stopLimitOrder', - 'stopLimitOrderDown', - 'transactionLimit', - 'focusLimitOrders', - ], - orders: ['limitOrders'], - bottom: ['limitOrders'], - base: ['limitOrders', 'layerThree'], - set: ['limitOrders'], - specific: ['limitOrders', 'notificationsAndUpdates'], - price: ['limitOrders', 'notificationsAndUpdates'], - Prime: ['primeEarn', 'primeStaking', 'primeDeFi'], - Wallet: ['primeEarn', 'coinsInWallet'], - Earn: [ - 'primeEarn', - 'primeStaking', - 'earnMore', - 'earnGrowth', - 'earnSuccess', - 'earnIdVerification', - ], - Rewards: ['primeEarn'], - Coins: ['primeEarn', 'primeStaking', 'bigBtc', 'primeDeFi', 'coinsInWallet'], - Assets: ['primeEarn', 'primeStaking', 'primeDeFi'], - Coin: ['primeEarn', 'bigBtc', 'primeDeFi', 'coinsInWallet', 'coinFifty'], - Crypto: ['primeEarn', 'primeStaking', 'bigBtc', 'primeDeFi', 'coinsInWallet'], - Currency: ['primeEarn', 'bigBtc', 'coinsInWallet'], - Money: ['primeEarn'], - Cash: ['primeEarn'], - '✨': [ - 'primeEarn', - 'primeStaking', - 'bigBtc', - 'ethStakingUpsell', - 'emptyStateNftSoldOut', - 'supportAndMore', - 'emptyStateNft404Page', - 'currencyPairs', - 'primeDeFi', + 'coinbaseOneTokenRewards', + 'coinbaseOneUSDCIncentives', + 'bitcoinGlobe', ], - slippage: ['slippageTolerance'], - tolerance: ['slippageTolerance'], + barchart: ['accessToAdvancedCharts', 'earnInterest', 'anonymous'], + candle: ['accessToAdvancedCharts', 'switchAdvancedToSimpleTrading'], candlesticks: [ - 'slippageTolerance', 'accessToAdvancedCharts', 'switchAdvancedToSimpleTrading', 'advancedTrading', + 'slippageTolerance', ], - index: ['indexer'], - indexer: ['indexer'], - search: ['indexer'], - find: ['indexer', 'errorWeb404Mobile', 'errorWeb404'], - locate: ['indexer', 'errorWeb404Mobile', 'errorWeb404'], - results: ['indexer'], - info: ['indexer', 'refresh', 'reviewInfo', 'verifyInfo', 'storage', 'processing'], - record: ['videoRequest'], - message: ['videoRequest'], - 'speech bubble': ['videoRequest', 'ratingsAndReviews', 'optInPushNotificationsEmail'], - Account: ['accountUnderReview'], - under: ['accountUnderReview'], - review: ['accountUnderReview', 'reviewInfo', 'ratingsAndReviews'], - user: [ - 'accountUnderReview', - 'didDecentralizedIdentity', - 'digitalCollectibles', - 'semiCustodial', - 'selfCustody', - 'selfCustodyCrypto', - 'coinbaseOneUSDCBig', - 'usdAndUsdc', - 'collectingNfts', - 'linkingYourWalletToYourCoinbaseAccount', - ], - magnifying: ['accountUnderReview', 'errorWeb404Mobile', 'verifyIdDetails', 'errorWeb404'], - glass: ['accountUnderReview', 'errorWeb404Mobile', 'verifyIdDetails', 'errorWeb404'], - checking: ['accountUnderReview'], - confirming: ['accountUnderReview'], - pending: ['accountUnderReview', 'pending', 'requestSent', 'polling'], - credit: [ - 'coinbaseOneCardWarning', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', - 'payUpFront', - 'receivedCard', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', - 'coinbaseCardSpend', - ], - plastic: [ - 'coinbaseOneCardWarning', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', - 'payUpFront', - 'receivedCard', - 'coinbaseCardLock', - 'coinbaseCardSpend', - ], - payment: [ - 'coinbaseOneCardWarning', - 'bitcoinAndOtherCrypto', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', - 'addMoreCrypto', - 'payUpFront', - 'receivedCard', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', - 'cashExcitement', - 'coinbaseCardSpend', - ], - method: [ - 'coinbaseOneCardWarning', - 'cardAndPhone', - 'addCreditCard', - 'coinbaseCard', - 'coinbaseCardPocket', - 'payUpFront', - 'receivedCard', - 'coinbaseCardLock', - 'downloadCoinbaseWallet', - ], - routing: ['routingAccount'], - number: ['routingAccount', 'phoneNumber', 'addPhoneNumber'], - tradfi: ['routingAccount'], - old: ['routingAccount'], - school: ['routingAccount'], - boring: ['routingAccount'], - debit: ['earnCryptoCard', 'cardAndPhone', 'payUpFront'], - visa: ['earnCryptoCard', 'cardAndPhone', 'cardBoosted', 'payUpFront'], - delight: ['earnCryptoCard'], - scalable: ['scalable'], - scale: ['scalable', 'feeScale', 'stablecoin'], - cpu: ['scalable'], - decentralized: [ - 'scalable', - 'protocol', - 'defiDecentralizedBorrowingLending', - 'moneyDecentralized', - 'layerTwo', - 'dappsGeneral', - 'decentralizedWebWeb3', - ], - puzzle: ['browserExtension', 'multiPlatformMobileAppBrowserExtension'], - monitor: ['browserExtension', 'multiPlatformMobileAppBrowserExtension'], - pay: [ - 'cardAndPhone', - 'payUpFront', - 'automaticPayments', - 'fileYourCryptoTaxes', - 'fileYourCryptoTaxesCheck', - ], - mastercard: ['cardAndPhone', 'payUpFront'], - discover: ['cardAndPhone', 'earnNuxHome', 'payUpFront'], - bridge: ['bridge'], - bridging: ['bridge'], - layer: ['bridge', 'layerTwo', 'layerOne'], - one: [ - 'bridge', - 'coinbaseOneDiscountedAmount', - 'coinbaseOneInsufficientWallet', - 'coinbaseOneLogo', - 'selectReward', - 'engagement', - 'layerOne', - 'automaticPayments', - 'coinbaseOneProtectedCrypto', - 'coinbaseOneDocWarning', - ], - two: ['bridge', 'add2Fa', 'layerTwo', 'twoIdVerify'], - L2: ['bridge', 'dappsL2Support', 'layerTwo', 'powerOfCrypto'], - bridged: ['bridge'], - Staking: ['primeStaking'], - Stake: ['primeStaking'], - Interest: ['primeStaking'], - Circles: ['primeStaking', 'primeDeFi'], - Universe: ['primeStaking', 'primeDeFi'], - sparkles: ['primeStaking', 'bigBtc', 'coinbaseCardSpendCrypto', 'ethStakingUpsell'], - connection: [ - 'errorWeb500', - 'crossBorderPayments', - 'errorWeb404Mobile', - 'errorWeb', - 'errorMoblie', - 'errorWeb404', - 'errorApp500', + rat: ['accessToAdvancedCharts', 'advancedTrading'], + mic: ['mic'], + microphone: ['mic'], + talk: ['mic'], + speech: ['mic'], + voice: ['mic'], + '🎙': ['mic'], + camera: ['camera'], + flash: ['camera'], + video: ['camera'], + photo: ['camera', 'faceMatchReal', 'private'], + '📷': ['camera'], + '📸': ['camera'], + pending: ['pending', 'accountUnderReview', 'polling', 'requestSent'], + transaction: ['pending', 'transactionLimit', 'verifyBankTransactions', 'verifyCardTransactions'], + wait: ['pending'], + timing: ['pending'], + waiting: [ + 'pending', + 'coinbaseOneWaitlist', + 'onTheList', + 'iceCreamMeltingSystemError', + 'polling', + 'requestSent', ], - plug: ['errorWeb500', 'errorWeb', 'errorMoblie', 'errorApp500'], + soon: ['pending', 'requestSent'], + patient: ['pending'], + patience: ['pending'], + clipboard: ['reviewInfo', 'coinbaseOneWaitlist', 'verifyInfo', 'onTheList', 'refresh'], + review: ['reviewInfo', 'accountUnderReview', 'ratingsAndReviews'], + info: ['reviewInfo', 'verifyInfo', 'processing', 'storage', 'indexer', 'refresh'], + information: ['reviewInfo', 'verifyInfo', 'processing', 'cloudBacking', 'storage', 'refresh'], issue: [ - 'errorWeb500', - 'coinbaseCardIssue', - 'refresh', - 'errorWeb404Mobile', 'reviewInfo', 'verifyInfo', - 'errorWeb', - 'errorMoblie', + 'errorApp500', 'docError', + 'coinbaseCardIssue', 'errorWeb404', - 'errorApp500', - ], - desktop: [ 'errorWeb500', - 'coinbaseIsDown', - 'errorWeb400', - 'errorWeb404Mobile', 'errorWeb', 'errorMoblie', - 'errorWeb404', - ], - protocol: ['protocol'], - contract: ['protocol', 'lightningNetworkInvoice'], - smart: ['protocol', 'innovation'], - music: ['dappsMusic', 'digitalCollectibles', 'collectingNfts'], - 'music note': ['dappsMusic', 'collectingNfts'], - unlock: ['unlockKey'], - key: ['unlockKey', 'privateKey'], - password: ['unlockKey', 'add2Fa'], - recommend: ['recommendInvest'], - recommended: ['recommendInvest'], - recommendation: ['recommendInvest'], - invest: ['recommendInvest'], - investments: ['recommendInvest'], - choose: ['recommendInvest', 'ensProfilePic', 'claimCryptoUsername', 'selectCorrectCrypto'], - tokens: ['recommendInvest'], - star: ['basedInUsa', 'freeBtc'], - location: ['basedInUsa', 'restrictedCountry'], - USA: ['basedInUsa', 'backedByUsDollar'], - Lighting: [ - 'lightningNetworkInvoice', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', - ], - Lightingnetwork: [ - 'lightningNetworkInvoice', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'lightningNetworkSend', - ], - invoice: ['lightningNetworkInvoice'], - QR: ['lightningNetworkInvoice', 'lightningNetwork'], - code: ['lightningNetworkInvoice', 'lightningNetwork', 'developer'], - Bitcoin: [ - 'lightningNetworkInvoice', - 'bigBtc', - 'lightningNetwork', - 'lightningNetworkTransfer', - 'referralsBitcoin', - 'lightningNetworkSend', - ], - store: ['bigBtc', 'earnMore', 'earnGrowth', 'holdCrypto', 'holdingCrypto'], - bars: ['lowCost', 'coinbaseOneEarn', 'earn'], - apps: ['cryptoApps', 'cryptoAppsWallet', 'dappsGeneral', 'browseDecentralizedApps'], - ghost: ['cryptoApps', 'exploreDecentralizedApps'], - unicorn: ['cryptoApps'], - charts: ['cryptoApps'], - Coinbase: ['coinbaseCardSpendCrypto', 'coinbaseCardIssue', 'referralsCoinbaseOne'], - Card: ['coinbaseCardSpendCrypto', 'coinbaseCardIssue', 'earnIdVerification', 'idCard'], - Spend: ['coinbaseCardSpendCrypto'], - cryptocurrency: ['coinbaseCardSpendCrypto', 'holdingCrypto', 'webRAT'], - real: ['coinbaseCardSpendCrypto'], - world: ['coinbaseCardSpendCrypto'], - use: ['coinbaseCardSpendCrypto'], - case: ['coinbaseCardSpendCrypto'], - '💳': ['coinbaseCardSpendCrypto', 'coinbaseCardIssue', 'web3MobileSetupSuccess'], - MXD: ['remittances'], - Mexico: ['remittances'], - dollar: ['remittances', 'verifyBankTransactions', 'verifyCardTransactions'], - remit: ['remittances'], - remittances: ['remittances'], - border: ['remittances'], - '🇲🇽': ['remittances'], - services: ['cloud'], - gear: ['cloud', 'tools'], - storage: ['cloud', 'secureStorage', 'storage', 'hardwareWallets'], - files: ['cloud'], - platform: ['cloud', 'platform', 'powerOfCrypto'], - '2FA': ['phoneNumber', 'add2Fa'], - passcode: ['phoneNumber'], - asterisk: ['phoneNumber'], - benefits: ['coinbaseOneWelcome'], - program: ['coinbaseOneWelcome'], - Secure: ['add2Fa', 'idVerificationSecure'], - factor: ['add2Fa'], - authentication: ['add2Fa'], - safe: ['add2Fa', 'coinbaseOneSavingFunds'], - combination: ['add2Fa'], - now: ['realToUSDC', 'tradeImmediately', 'usdtToUSDC'], - usdt: ['realToUSDC', 'usdtToUSDC'], - usdc: ['realToUSDC', 'coinbaseOneUSDCBig', 'usdAndUsdc', 'usdtToUSDC'], - stablecoin: ['realToUSDC', 'stablecoin', 'usdtToUSDC'], - stable: ['realToUSDC', 'stablecoin', 'stableValue', 'usdtToUSDC'], - artwork: ['artFrameEmptyState', 'collectableNfts', 'emptyStateNftSoldOut'], - museum: ['artFrameEmptyState', 'emptyCollection'], - '🖼': [ - 'artFrameEmptyState', - 'collectableNfts', - 'emptyStateNftSoldOut', - 'exploreDecentralizedApps', - ], - ledger: ['ledgerPlugin', 'ledgerAccess'], - plugin: ['ledgerPlugin', 'ledgerAccess'], - instructional: ['ledgerPlugin', 'ledgerAccess'], - triangle: ['dappsGaming'], - square: [ - 'dappsGaming', - 'walletSecurity', - 'digitalCollectibles', - 'multicoinSupport', - 'stressTestedColdStorage', - 'watchVideos', - 'collectingNfts', - 'linkingYourWalletToYourCoinbaseAccount', + 'errorWeb404Mobile', + 'refresh', ], - tag: ['coinbaseOneDiscountedAmount', 'noFeesMotion', 'noFees'], - coinbaseone: [ - 'coinbaseOneDiscountedAmount', + 'magnifying glass': ['reviewInfo', 'catLostSystemError', 'quest'], + one: [ + 'coinbaseOneLogo', + 'selectReward', 'coinbaseOneInsufficientWallet', 'coinbaseOneProtectedCrypto', 'coinbaseOneDocWarning', + 'automaticPayments', + 'coinbaseOneDiscountedAmount', + 'layerOne', + 'bridge', + 'engagement', + 'instoCoinbaseOneProtectedCrypto', + ], + cb1: [ + 'coinbaseOneLogo', + 'selectReward', + 'coinbaseOneUSDCBig', + 'coinbaseOneAirdrop', + 'cardErrorCB1', + 'usdAndUsdc', ], - discounted: ['coinbaseOneDiscountedAmount'], - amount: ['coinbaseOneDiscountedAmount', 'estimatedAmount'], - dapps: ['dappsL2Support', 'web3MobileSetupSuccess', 'dappsGeneral', 'ethereumToWallet'], - badging: ['dappsL2Support'], - token: ['dappsL2Support', 'coinbaseOneTokenRewards', 'transactionLimit'], - support: ['dappsL2Support', 'supportAndMore'], logo: ['coinbaseOneLogo', 'selectReward'], logomark: ['coinbaseOneLogo', 'selectReward'], brand: ['coinbaseOneLogo', 'selectReward'], - Gift: ['brdGift', 'receiveGift'], - BRD: ['brdGift', 'receiveGift'], - box: ['brdGift', 'receiveGift', 'governance'], - '🎁': ['brdGift', 'coinbaseOneTokenRewards', 'receiveGift'], - nft: [ - 'brdGift', - 'collectableNfts', - 'emptyStateNftSoldOut', - 'emptyStateCheckBackLater', - 'emptyStateNft404Page', - 'exploreDecentralizedApps', - 'emptyCollection', - 'hiddenCollection', - 'receiveGift', - ], - hub: ['earnNuxHome'], - tracking: ['earnNuxHome', 'appTrackingTransparency'], - manage: ['earnNuxHome'], - precent: ['earnNuxHome'], + yield: [ + 'earnMore', + 'holdCrypto', + 'defiRisk', + 'earnGrowth', + 'stakingMissedReturns', + 'earnGlobe', + 'stakingMissedReturnsUsdc', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + ], + stake: ['earnMore', 'holdCrypto', 'holdingCrypto', 'earnGrowth'], + store: ['earnMore', 'holdCrypto', 'bigBtc', 'holdingCrypto', 'earnGrowth'], + return: ['earnMore', 'earnGrowth'], + growth: [ + 'earnMore', + 'rocket', + 'earnGrowth', + 'earnCryptoInterest', + 'coinbaseOneRewards', + 'earnNuxHome', + 'retailUSDCRewards', + ], + increase: ['earnMore', 'transactionLimit', 'earnGrowth'], + value: [ + 'earnMore', + 'feeScale', + 'bigBtc', + 'stableValue', + 'earnGrowth', + 'coinbaseOneRewards', + 'retailUSDCRewards', + ], boosted: ['cardBoosted'], chip: ['cardBoosted'], - award: ['cardBoosted'], - Face: ['faceMatchReal', 'private'], - Match: ['faceMatchReal', 'private'], - KYC: ['faceMatchReal', 'private'], - Identity: ['faceMatchReal', 'private', 'idIssue'], - ID: [ - 'faceMatchReal', - 'idAngles', - 'idBack', - 'enableBiometrics', - 'idFront', - 'verifyIdDetails', - 'private', - 'idVerificationSecure', - 'idIssue', - 'twoIdVerify', - 'japanVerifyId', - 'keyGeneration', + visa: ['cardBoosted', 'payUpFront', 'earnCryptoCard', 'cardAndPhone'], + select: [ + 'cardBoosted', + 'yourContacts', + 'claimCryptoUsername', + 'namePortfolio', + 'multiplePortfolios', ], - Person: ['faceMatchReal', 'private', 'sendToUsername'], - Human: ['faceMatchReal', 'private'], - head: ['faceMatchReal', 'private'], - photo: ['faceMatchReal', 'camera', 'private'], - correct: [ - 'faceMatchReal', - 'walletConfirmation', - 'documentCertified', - 'private', - 'success', - 'processing', - 'selectCorrectCrypto', + award: ['cardBoosted'], + graph: [ + 'switchAdvancedToSimpleTrading', + 'invest', + 'earn', + 'exploreDecentralizedApps', + 'defiEnrollBoost', + 'staking', + 'performance', + 'ethStakingRewards', + 'lowCost', + 'coinbaseOneEarn', + 'instoEthStakingRewards', + 'instoStaking', ], - camera: ['camera'], - flash: ['camera'], - video: ['camera'], - '📷': ['camera'], - '📸': ['camera'], - Wrench: ['tools'], - tool: ['tools'], - tools: ['tools'], - '🛠': ['tools'], - '⚒': ['tools'], - '🔨': ['tools'], - '🔧': ['tools'], - '🧰': ['tools'], - shield: ['secureAndTrusted', 'coinbaseOneProtectedCrypto'], - trust: ['secureAndTrusted', 'defiRisk'], - stake: ['earnMore', 'earnGrowth', 'holdCrypto', 'holdingCrypto'], - return: ['earnMore', 'earnGrowth'], - increase: ['earnMore', 'earnGrowth', 'transactionLimit'], - doge: ['cbdoge'], - cbdoge: ['cbdoge'], - encrypted: ['encryptedEverything', 'privateKey'], - everything: ['encryptedEverything'], - estimated: ['estimatedAmount'], - prices: ['estimatedAmount'], - calculation: ['estimatedAmount'], - invalid: ['coinbaseOneWalletWarning'], - 'unable to send': ['coinbaseOneWalletWarning'], + ui: ['switchAdvancedToSimpleTrading', 'orderBooks'], + change: ['switchAdvancedToSimpleTrading'], + scale: ['feeScale', 'stablecoin', 'scalable'], fee: ['feeScale'], estimate: ['feeScale'], approx: ['feeScale'], approximate: ['feeScale'], weight: ['feeScale', 'stablecoin'], costs: ['feeScale'], + gauge: [ + 'feeScale', + 'insufficientBalance', + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', + ], guess: ['feeScale'], - 'cross border': ['crossBorderPayments'], - anonymous: ['anonymous'], - barchart: ['anonymous', 'accessToAdvancedCharts', 'earnInterest'], - transfer: ['anonymous'], - '📊': ['anonymous', 'earnInterest', 'exploreDecentralizedApps', 'advancedTrading'], - '👤': ['anonymous', 'platform'], - gather: ['cryptoAppsWallet', 'browseDecentralizedApps'], - 'chicken fish': ['chickenFishSystemError'], - merge: ['chickenFishSystemError'], - wtf: ['chickenFishSystemError'], - '🐟': ['chickenFishSystemError'], - '🐠': ['chickenFishSystemError'], - '🍣': ['chickenFishSystemError'], - '🎣': ['chickenFishSystemError'], - '🐡': ['chickenFishSystemError'], - '🐓': ['chickenFishSystemError'], - '🐔': ['chickenFishSystemError'], - connections: ['defiDecentralizedBorrowingLending', 'globalTransactions', 'sidechain'], - lending: ['defiDecentralizedBorrowingLending', 'earnCryptoInterest'], - borrowing: ['defiDecentralizedBorrowingLending'], - concern: ['coinbaseCardIssue', 'refresh', 'verifyInfo', 'docError', 'errorApp500'], - something: ['coinbaseCardIssue'], - right: ['coinbaseCardIssue'], - NFTs: ['collectableNfts'], - collectable: ['collectableNfts'], - collectible: ['collectableNfts'], - nyan: ['collectableNfts'], - flowers: ['collectableNfts'], - direct: ['directDepositPhone'], - deposit: ['directDepositPhone'], + waitlist: ['coinbaseOneWaitlist'], + checklist: ['coinbaseOneWaitlist'], + 'waiting list': ['coinbaseOneWaitlist'], + '📋': ['coinbaseOneWaitlist'], + '⏱': ['coinbaseOneWaitlist'], + 'connecting dots': ['decentralization', 'layerOne', 'layerTwo', 'public', 'sustainable'], + decentralization: ['decentralization', 'layerOne', 'layerTwo', 'public', 'sustainable'], + Power: ['powerOfCrypto'], + in: ['powerOfCrypto'], + your: ['powerOfCrypto', 'walletUi'], + hands: ['powerOfCrypto', 'settlement', 'gamer'], + pattern: ['powerOfCrypto'], + tech: ['powerOfCrypto'], + platform: ['powerOfCrypto', 'platform', 'cloud'], + L1: ['powerOfCrypto'], + L2: ['powerOfCrypto', 'dappsL2Support', 'bridge', 'layerTwo'], + squares: ['decentralizedWebWeb3'], + pointer: ['decentralizedWebWeb3'], + grid: ['decentralizedWebWeb3'], + web3: ['decentralizedWebWeb3', 'minting', 'dappsGeneral', 'generative'], + 'multiple wallets': ['multipleAccountsWalletsForOneUser'], + coinbaseone: [ + 'coinbaseOneInsufficientWallet', + 'coinbaseOneProtectedCrypto', + 'coinbaseOneDocWarning', + 'coinbaseOneDiscountedAmount', + 'instoCoinbaseOneProtectedCrypto', + ], + insufficient: ['coinbaseOneInsufficientWallet', 'insufficientBalance'], + '2FA': ['add2Fa', 'phoneNumber', 'instoAdd2Fa'], + Secure: ['add2Fa', 'idVerificationSecure', 'instoAdd2Fa'], + two: ['add2Fa', 'twoIdVerify', 'bridge', 'layerTwo', 'instoAdd2Fa'], + factor: ['add2Fa', 'instoAdd2Fa'], + authentication: ['add2Fa', 'instoAdd2Fa'], + safe: ['add2Fa', 'coinbaseOneSavingFunds', 'instoAdd2Fa'], + safety: ['add2Fa', 'securityShield', 'unlockKey', 'instoAdd2Fa'], + combination: ['add2Fa', 'instoAdd2Fa'], + password: ['add2Fa', 'unlockKey', 'instoAdd2Fa'], thunderbolt: ['coinbaseWalletToTrade'], - refresh: ['errorRefresh', 'errorRefreshWeb'], - page: ['errorRefresh', 'errorRefreshWeb'], - pull: ['errorRefresh', 'errorRefreshWeb'], - try: ['errorRefresh', 'errorRefreshWeb'], - again: ['errorRefresh', 'errorRefreshWeb'], - extra: ['errorRefresh', 'errorRefreshWeb'], - life: ['errorRefresh', 'errorRefreshWeb'], - notification: ['notificationsAndUpdates', 'notificationsAlt', 'emailNotification'], - alert: ['notificationsAndUpdates', 'notificationsAlt'], - receive: [ - 'notificationsAndUpdates', - 'minting', - 'whyNotBoth', - 'buyFirstCrypto', - 'emailNotification', - 'ethereumToWallet', - ], - hit: ['notificationsAndUpdates'], - free: ['freeBtc'], - get: ['freeBtc'], - paid: ['freeBtc'], - sparkle: ['freeBtc', 'supportAndMore', 'emptyStateNft404Page'], - join: ['freeBtc'], - refer: ['freeBtc'], - hodl: ['freeBtc', 'holdCrypto'], - quick: ['walletAsset', 'quickBuy', 'quickAndSimple'], - speedy: ['walletAsset', 'quickBuy'], - currencies: ['walletAsset', 'quickBuy'], - restricted: ['restrictedCountry'], - country: ['restrictedCountry'], - map: ['restrictedCountry'], - pin: ['restrictedCountry'], - point: ['restrictedCountry'], - borrow: ['borrowWallet', 'borrow'], - eth: ['ethStakingUpsell', 'ethStakingRewards', 'ethereumToWallet'], - ethereum: [ - 'ethStakingUpsell', - 'ensProfilePic', - 'noLongAddresses', - 'claimCryptoUsername', - 'layeredNetworks', - 'poweredByEthereum', - 'ethereumToWallet', - ], - upsell: ['ethStakingUpsell'], - eth2: ['ethStakingUpsell', 'ethStakingRewards'], - '2.0': ['ethStakingUpsell'], - candle: ['accessToAdvancedCharts', 'switchAdvancedToSimpleTrading'], - rat: ['accessToAdvancedCharts', 'advancedTrading'], - 'connecting dots': ['public', 'decentralization', 'layerTwo', 'layerOne', 'sustainable'], - decentralization: ['public', 'decentralization', 'layerTwo', 'layerOne', 'sustainable'], - ens: ['ensProfilePic', 'noLongAddresses', 'claimCryptoUsername'], - service: ['ensProfilePic', 'noLongAddresses', 'claimCryptoUsername'], - username: ['ensProfilePic', 'noLongAddresses', 'claimCryptoUsername'], - robot: ['ensProfilePic', 'claimCryptoUsername'], - chain: ['blockchain', 'connectPeople', 'sidechain'], - blockchain: ['blockchain'], - hexagon: ['blockchain', 'sidechain'], - sequence: ['blockchain'], - '🔵': ['oracle'], - '📿': ['oracle'], - '🧙‍♀️': ['oracle'], - '🧙‍♂️': ['oracle'], - mystical: ['oracle'], - orb: ['oracle'], - Spell: ['oracle'], - Sorcery: ['oracle'], - 'Crystal ball': ['oracle'], - farming: ['earnCryptoInterest'], - '%': ['earnCryptoInterest'], - how: ['defiHow'], - gift: ['coinbaseOneTokenRewards'], - surprise: ['coinbaseOneTokenRewards'], - id: ['web3ActivitySigned', 'web3MobileSetupStart'], - human: ['web3ActivitySigned', 'enableBiometrics', 'keyGeneration'], - protection: [ - 'web3ActivitySigned', - 'insuranceProtection', - 'enableBiometrics', - 'idVerificationSecure', - 'keyGeneration', + speed: [ + 'coinbaseWalletToTrade', + 'coinbaseOnePhoneLightning', + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', ], - '✅': [ - 'web3ActivitySigned', - 'settlement', + piggy: ['coinbaseOneSavingFunds'], + pig: ['coinbaseOneSavingFunds'], + funds: ['coinbaseOneSavingFunds', 'coinbaseIsDown', 'coinbaseIsDownMobile'], + saving: ['coinbaseOneSavingFunds'], + '💵': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow', 'settlement'], + '💸': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow'], + '🏦': ['coinbaseOneSavingFunds', 'cashExcitement', 'japanVerifyId', 'borrow'], + '🏧': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow'], + '💴': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow'], + '💶': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow'], + '💷': ['coinbaseOneSavingFunds', 'cashExcitement', 'borrow'], + '🐖': ['coinbaseOneSavingFunds'], + '💲': ['coinbaseOneSavingFunds', 'borrow', 'commerceInvoices', 'settlement'], + confirmation: [ + 'coinCheckmark', + 'success', + 'bigWarning', + 'walletWarning', 'walletConfirmation', - 'verifyEmail', - 'platform', - 'documentSuccess', - 'fileYourCryptoTaxesCheck', + 'outage', ], - '🆔': ['web3ActivitySigned', 'web3MobileSetupStart'], - '🪪': ['web3ActivitySigned', 'web3MobileSetupStart'], - weigh: ['stablecoin'], - same: ['stablecoin'], - even: ['stablecoin'], + mark: ['coinCheckmark', 'fileYourCryptoTaxesCheck', 'success'], + hold: ['holdCrypto', 'coinsInWallet', 'diamondHands'], + hodl: ['holdCrypto', 'freeBtc'], + basket: ['holdCrypto'], + bowl: ['holdCrypto'], + connections: ['sidechain', 'defiDecentralizedBorrowingLending', 'globalTransactions'], + '📊': ['advancedTrading', 'exploreDecentralizedApps', 'earnInterest', 'anonymous'], + '📈': ['advancedTrading', 'earnInterest', 'coinbaseOneRewards', 'retailUSDCRewards'], + '📉': ['advancedTrading', 'earnInterest'], + cash: ['cashExcitement', 'borrow'], + excitement: ['cashExcitement', 'coinbaseRedesigned', 'rocket'], verify: [ - 'refresh', - 'idAngles', - 'idBack', 'verifyEmail', + 'idCard', 'verifyInfo', - 'idFront', + 'idAngles', 'verifyBankTransactions', - 'earnIdVerification', + 'verifyCardTransactions', 'twoIdVerify', + 'earnIdVerification', + 'idBack', + 'idFront', + 'refresh', + ], + email: ['verifyEmail', 'emailNotification', 'openEmail', 'instoOpenEmail'], + envelope: ['verifyEmail', 'openEmail', 'instoOpenEmail'], + nux: ['verifyEmail'], + onboarding: [ + 'verifyEmail', 'idCard', - 'verifyCardTransactions', + 'addBankAccount', + 'japanVerifyId', + 'addPhoneNumber', + 'emailNotification', + 'earnIdVerification', + 'verifyIdDetails', + 'securityShield', ], - no: ['noLongAddresses', 'governance'], - long: ['noLongAddresses'], - addresses: ['noLongAddresses', 'whyNotBoth'], - scissors: ['noLongAddresses'], - cut: ['noLongAddresses'], - exchange: ['defiDecentralizedTradingExchange', 'exchange'], - distinguished: ['emptyStateNftSoldOut'], - gallery: ['emptyStateNftSoldOut'], - painting: ['emptyStateNftSoldOut'], - moment: ['emptyStateNftSoldOut'], - 'notice me': ['emptyStateNftSoldOut'], - 'mona lisa': ['emptyStateNftSoldOut'], - 'mona cat': ['emptyStateNftSoldOut'], - 'cat in a hat': ['emptyStateNftSoldOut'], - '🎩': ['emptyStateNftSoldOut'], - '🎨': ['emptyStateNftSoldOut'], - '🖌': ['emptyStateNftSoldOut'], - '❇️': ['emptyStateNftSoldOut', 'supportAndMore', 'emptyStateNft404Page'], - '🙀': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😹': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😽': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😸': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😺': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😾': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - '😼': ['emptyStateNftSoldOut', 'emptyStateCheckBackLater', 'emptyStateNft404Page'], - transaction: ['pending', 'verifyBankTransactions', 'transactionLimit', 'verifyCardTransactions'], - wait: ['pending'], - timing: ['pending'], - timer: ['pending', 'quickAndSimple', 'getStartedInMinutes'], - soon: ['pending', 'requestSent'], - patient: ['pending'], - patience: ['pending'], - gaming: ['gamer'], - gamer: ['gamer'], - hands: ['gamer', 'settlement', 'powerOfCrypto'], - '👾': ['gamer'], - '🖥️': ['gamer'], - '🖱': ['gamer'], - lightingbolt: ['lightningNetwork', 'lightningNetworkTransfer', 'lightningNetworkSend'], - machine: ['lightningNetwork'], - factory: ['lightningNetwork'], - transparency: ['appTrackingTransparency'], - '✔️': ['appTrackingTransparency', 'receivedCard'], - identity: ['idAngles', 'idBack', 'idFront', 'verifyIdDetails', 'twoIdVerify', 'japanVerifyId'], - documents: ['idAngles', 'idBack', 'idFront', 'verifyIdDetails', 'twoIdVerify', 'japanVerifyId'], - license: ['idAngles', 'idBack', 'idFront', 'idIssue', 'twoIdVerify'], - verification: [ + '✅': [ + 'verifyEmail', + 'documentSuccess', + 'fileYourCryptoTaxesCheck', + 'web3ActivitySigned', + 'settlement', + 'platform', + 'walletConfirmation', + 'instoDocumentSuccess', + ], + economy: ['cryptoEconomy'], + defi: [ + 'defiEarn', + 'defiDecentralizedTradingExchange', + 'walletUi', + 'defiEnrollBoost', + 'defiDecentralizedBorrowingLending', + 'ethereumToWallet', + 'defiHow', + ], + percentage: [ + 'defiEarn', + 'fileYourCryptoTaxes', + 'defiEnrollBoost', + 'earnInterest', + 'fileYourCryptoTaxesCheck', + 'earnCryptoInterest', + 'coinbaseOnePercentOff', + ], + investing: ['invest'], + steps: ['invest'], + redesign: ['coinbaseRedesigned'], + new: ['coinbaseRedesigned', 'notificationsAndUpdates', 'multiplePortfolios', 'innovation'], + Id: ['idCard', 'earnIdVerification'], + Drivers: ['idCard'], + License: ['idCard', 'earnIdVerification', 'idVerificationSecure'], + Front: ['idCard', 'earnIdVerification'], + Card: ['idCard', 'earnIdVerification', 'coinbaseCardIssue', 'coinbaseCardSpendCrypto'], + documentation: ['idCard', 'docError', 'twoIdVerify', 'earnIdVerification', 'idBack', 'idFront'], + concern: ['verifyInfo', 'errorApp500', 'docError', 'coinbaseCardIssue', 'refresh'], + '⚠️': [ + 'verifyInfo', + 'errorApp500', + 'docError', + 'coinbaseIsDown', + 'coinbaseCardIssue', + 'errorRefresh', + 'errorWeb404', + 'coinbaseIsDownMobile', + 'error400', + 'idIssue', + 'errorWeb500', + 'errorWeb400', + 'walletWarning', + 'errorWeb', + 'errorMoblie', + 'errorWeb404Mobile', + 'errorRefreshWeb', + 'refresh', + ], + Documents: ['documentSuccess', 'idVerificationSecure', 'instoDocumentSuccess'], + success: [ + 'documentSuccess', + 'rocket', + 'readyToTrade', + 'success', + 'walletConfirmation', + 'instoDocumentSuccess', + ], + building: ['addBankAccount', 'japanVerifyId'], + tower: ['addBankAccount'], + columns: ['addBankAccount'], + plug: ['errorApp500', 'errorWeb500', 'errorWeb', 'errorMoblie'], + 'system error': [ + 'errorApp500', + 'coinbaseIsDown', + 'errorRefresh', + 'errorWeb404', + 'coinbaseIsDownMobile', + 'error400', + 'errorWeb500', + 'errorWeb400', + 'chickenFishSystemError', + 'serverCatSystemError', + 'iceCreamMeltingSystemError', + 'catLostSystemError', + 'alienDonutSystemError', + 'errorWeb', + 'errorMoblie', + 'errorWeb404Mobile', + 'errorRefreshWeb', + ], + list: ['onTheList', 'yourContacts', 'contactsListWarning'], + on: [ + 'onTheList', + 'stakingMissedReturns', + 'stakingMissedReturnsUsdc', + 'instoStakingMissedReturns', + ], + notify: ['onTheList', 'emailNotification'], + digital: ['collectingNfts'], + collectibles: ['collectingNfts'], + nfts: ['collectingNfts'], + ID: [ 'idAngles', + 'japanVerifyId', + 'twoIdVerify', + 'verifyIdDetails', 'idBack', + 'idVerificationSecure', + 'idIssue', 'idFront', - 'verifyIdDetails', - 'earnIdVerification', - 'twoIdVerify', + 'faceMatchReal', + 'keyGeneration', + 'enableBiometrics', + 'private', + ], + identity: ['idAngles', 'japanVerifyId', 'twoIdVerify', 'verifyIdDetails', 'idBack', 'idFront'], + documents: ['idAngles', 'japanVerifyId', 'twoIdVerify', 'verifyIdDetails', 'idBack', 'idFront'], + license: ['idAngles', 'twoIdVerify', 'idBack', 'idIssue', 'idFront'], + verification: [ + 'idAngles', 'japanVerifyId', + 'twoIdVerify', + 'earnIdVerification', + 'verifyIdDetails', + 'idBack', + 'idFront', ], angles: ['idAngles'], front: ['idAngles', 'payUpFront', 'idFront'], back: ['idAngles', 'idBack'], '3D': ['idAngles'], - swirl: ['emptyStateCheckBackLater'], - fun: ['emptyStateCheckBackLater'], - vibes: ['emptyStateCheckBackLater'], - 'big energy': ['emptyStateCheckBackLater'], - shapes: ['emptyStateCheckBackLater'], - movement: ['emptyStateCheckBackLater'], - '🔴': ['emptyStateCheckBackLater'], - immediately: ['tradeImmediately'], - notifications: ['walletNotifications'], - law: ['stableValue'], - '🌕': ['exchange'], - '🧮': ['exchange'], + support: ['supportAndMore', 'dappsL2Support'], help: ['supportAndMore'], guidance: ['supportAndMore'], - 'question mark': ['supportAndMore', 'phoneUnknown'], cog: ['supportAndMore'], aid: ['supportAndMore'], assist: ['supportAndMore'], '🙋‍♀️': ['supportAndMore'], '🙋': ['supportAndMore'], '🙋‍♂️': ['supportAndMore'], - '❓': ['supportAndMore', 'phoneUnknown'], - economy: ['cryptoEconomy'], - generative: ['generative'], - NFT: ['generative', 'minting', 'walletUi'], - web3: ['generative', 'minting', 'dappsGeneral', 'decentralizedWebWeb3'], - investing: ['invest'], - steps: ['invest'], - engagement: ['engagement'], - avatars: ['engagement', 'connectPeople'], - icons: ['engagement'], - vote: ['engagement', 'vote', 'governance'], - heart: ['engagement'], - thumb: ['engagement'], - vortex: ['emptyStateNft404Page'], - 'lets go': ['emptyStateNft404Page'], - lfg: ['emptyStateNft404Page'], - beginner: ['cryptoForBeginners'], - lines: ['cryptoForBeginners', 'taxesDetails'], - can: ['errorWeb404Mobile', 'errorWeb404'], - oil: ['oilAndGold'], - '📉': ['earnInterest', 'advancedTrading'], - settlement: ['settlement'], - '💵': ['settlement', 'coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - link: ['connectPeople'], - chainlink: ['connectPeople'], - learn: ['earnToLearn'], - bulb: ['earnToLearn'], - cbltc: ['cbltc'], - litecoin: ['cbltc'], - 'gas fees': ['gasFeesNetworkFees'], - 'fuel tank': ['gasFeesNetworkFees'], - mint: ['minting'], - minting: ['minting'], - three: ['minting', 'layerThree', 'dappsGeneral'], - pairs: ['currencyPairs'], - paring: ['currencyPairs'], - setup: ['web3MobileSetupSuccess', 'web3MobileSetupStart'], - settings: ['web3MobileSetupSuccess', 'web3MobileSetupStart'], - '👝': ['web3MobileSetupSuccess'], - '👛': ['web3MobileSetupSuccess'], - '👜': ['web3MobileSetupSuccess'], - '🖼️': ['web3MobileSetupSuccess'], - 'global transactions': ['globalTransactions'], - piggy: ['coinbaseOneSavingFunds'], - pig: ['coinbaseOneSavingFunds'], - saving: ['coinbaseOneSavingFunds'], - '💸': ['coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - '🏦': ['coinbaseOneSavingFunds', 'borrow', 'japanVerifyId', 'cashExcitement'], - '🏧': ['coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - '💴': ['coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - '💶': ['coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - '💷': ['coinbaseOneSavingFunds', 'borrow', 'cashExcitement'], - '🐖': ['coinbaseOneSavingFunds'], - picture: ['idBack', 'idFront', 'twoIdVerify'], - documentation: ['idBack', 'idFront', 'docError', 'earnIdVerification', 'twoIdVerify', 'idCard'], - would: ['stakingMissedReturnsUsdc', 'stakingMissedReturns'], - have: ['stakingMissedReturnsUsdc', 'stakingMissedReturns'], - missed: ['stakingMissedReturnsUsdc', 'stakingMissedReturns'], - out: ['stakingMissedReturnsUsdc', 'stakingMissedReturns'], - grow: ['stakingMissedReturnsUsdc', 'stakingMissedReturns'], - hold: ['holdCrypto', 'diamondHands', 'coinsInWallet'], - basket: ['holdCrypto'], - bowl: ['holdCrypto'], - pick: ['claimCryptoUsername'], - claw: ['claimCryptoUsername'], - you: ['claimCryptoUsername'], - layers: ['layerThree', 'layeredNetworks'], - 'layer three': ['layerThree'], - isometric: ['layerThree', 'layeredNetworks'], - networks: ['layerThree', 'layeredNetworks'], - umbrella: ['insuranceProtection'], - insurance: ['insuranceProtection'], - 'semi custodial': ['semiCustodial'], - 'social media': ['shareOnSocialMedia'], - fly: ['walletFlyEmptyState'], - missing: ['walletFlyEmptyState'], - '🪰': ['walletFlyEmptyState'], - cancel: ['noFeesMotion', 'noFees'], - agree: ['walletConfirmation', 'success'], - yes: ['walletConfirmation', 'success', 'processing', 'governance'], - CB1: ['referralsCoinbaseOne'], - One: ['referralsCoinbaseOne', 'coinbaseOneUSDCBig', 'coinbaseOneAirdrop', 'usdAndUsdc'], - multiplatform: ['multiPlatformMobileAppBrowserExtension'], - app: ['multiPlatformMobileAppBrowserExtension', 'walletUi'], - extension: ['multiPlatformMobileAppBrowserExtension'], - shiny: ['diamondHands'], - unknown: ['phoneUnknown'], - '❔': ['phoneUnknown'], - equal: ['taxesDetails'], - 'self custody': ['selfCustody', 'selfCustodyCrypto'], - Dollar: ['backedByUsDollar'], - email: ['verifyEmail', 'openEmail', 'emailNotification'], - envelope: ['verifyEmail', 'openEmail'], - nux: ['verifyEmail'], - congratulations: ['congratulationsOnEarningCrypto'], - prize: ['congratulationsOnEarningCrypto'], - open: ['openEmail'], - letter: ['openEmail'], - '📧 📥 📤 ✉ 📩 📨': ['openEmail'], - scan: ['enableBiometrics', 'keyGeneration', 'web3MobileSetupStart'], - biometrics: ['enableBiometrics', 'keyGeneration'], - identification: ['enableBiometrics', 'keyGeneration'], - finger: ['enableBiometrics', 'keyGeneration'], - '🗝️': ['enableBiometrics', 'keyGeneration'], - '🔑': ['enableBiometrics', 'keyGeneration', 'web3MobileSetupStart'], - '🛡️': ['enableBiometrics', 'keyGeneration'], - deFi: ['defiRisk'], - banner: ['defiRisk'], - sign: ['defiRisk'], - private: ['privateKey'], - encryption: ['privateKey'], - acces: ['privateKey'], - focus: ['stopLimitOrder', 'stopLimitOrderDown', 'focusLimitOrders'], - stoplimitorder: ['stopLimitOrder', 'stopLimitOrderDown'], - advancedtrading: ['stopLimitOrder', 'stopLimitOrderDown', 'focusLimitOrders'], - '🔜': ['requestSent'], - '⏰': ['requestSent'], - '⏱️': ['requestSent'], - '⏲️': ['requestSent'], - '⌚️': ['requestSent'], - '⏳': ['requestSent'], - '⌛️': ['requestSent'], - '🕰️': ['requestSent'], - bell: ['notificationsAlt'], - '🔔': ['notificationsAlt'], - '🔕': ['notificationsAlt'], - voting: ['vote'], - ballot: ['vote', 'governance'], - 'box. DAO': ['vote'], - cast: ['vote'], - gain: ['gainsAndLosses', 'trendingHotAssets', 'buyFirstCrypto', 'portfolioPerformance'], - loss: ['gainsAndLosses'], - rating: ['ratingsAndReviews'], - accounting: ['commerceAccounting'], - '⬇': ['commerceAccounting'], - both: ['whyNotBoth', 'coinbaseOneUSDCBig', 'usdAndUsdc'], - address: ['whyNotBoth'], - make: ['whyNotBoth', 'earnIdVerification'], - sure: ['whyNotBoth', 'selectCorrectCrypto'], + encrypted: ['encryptedEverything', 'privateKey', 'instoPrivateKey'], + everything: ['encryptedEverything'], + reviewing: ['japanVerifyId', 'verifyIdDetails', 'processing'], + japan: ['japanVerifyId'], + '🇯🇵': ['japanVerifyId'], + cryptocurrency: ['webRAT', 'holdingCrypto', 'coinbaseCardSpendCrypto'], + retail: ['webRAT'], + RAT: ['webRAT'], + education: ['webRAT'], + number: ['addPhoneNumber', 'routingAccount', 'phoneNumber'], + asset: [ + 'bigBtc', + 'tradeImmediately', + 'coinsInWallet', + 'transactionLimit', + 'networkWarning', + 'notificationsAndUpdates', + 'sendCryptoFaster', + 'usdtToUSDC', + 'realToUSDC', + ], + routing: ['routingAccount'], + connect: [ + 'routingAccount', + 'yourContacts', + 'ledgerPlugin', + 'coinbaseOneUSDCBig', + 'connectPeople', + 'usdAndUsdc', + ], + tradfi: ['routingAccount'], + old: ['routingAccount'], + school: ['routingAccount'], + boring: ['routingAccount'], + immediately: ['tradeImmediately'], + swap: ['tradeImmediately', 'usdtToUSDC', 'tradeGeneral', 'realToUSDC'], + now: ['tradeImmediately', 'usdtToUSDC', 'realToUSDC'], + cancel: ['noFees', 'noFeesMotion'], + tag: ['noFees', 'coinbaseOneDiscountedAmount', 'noFeesMotion'], + fees: ['noFees', 'gasFeesNetworkFees', 'coinbaseFees', 'noFeesMotion'], hello: ['myNameIsSatoshi'], my: ['myNameIsSatoshi'], + name: [ + 'myNameIsSatoshi', + 'claimCryptoUsername', + 'ensProfilePic', + 'noLongAddresses', + 'namePortfolio', + ], + is: ['myNameIsSatoshi', 'coinbaseIsDown', 'coinbaseCardIssue', 'coinbaseIsDownMobile'], satoshi: ['myNameIsSatoshi'], nakamoto: ['myNameIsSatoshi'], + bitcoin: ['myNameIsSatoshi', 'freeBtc', 'buy', 'cbbtc'], white: ['myNameIsSatoshi'], author: ['myNameIsSatoshi'], og: ['myNameIsSatoshi'], - powered: ['poweredByEthereum'], + taxes: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], + file: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck', 'storage'], + save: ['fileYourCryptoTaxes', 'holdingCrypto', 'fileYourCryptoTaxesCheck', 'storage'], + pay: [ + 'fileYourCryptoTaxes', + 'payUpFront', + 'automaticPayments', + 'fileYourCryptoTaxesCheck', + 'cardAndPhone', + ], + government: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], + irs: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], + tax: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], + center: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], + forms: ['fileYourCryptoTaxes', 'twoIdVerify', 'fileYourCryptoTaxesCheck'], + Deposit: ['coinsInWallet'], + Send: ['coinsInWallet', 'sendToUsername'], + Receive: ['coinsInWallet'], + eye: ['watchVideos', 'hiddenCollection'], + watch: ['watchVideos'], + videos: ['watchVideos'], + shield: ['coinbaseOneProtectedCrypto', 'secureAndTrusted', 'instoCoinbaseOneProtectedCrypto'], + protect: [ + 'coinbaseOneProtectedCrypto', + 'idVerificationSecure', + 'instoCoinbaseOneProtectedCrypto', + ], + protected: ['coinbaseOneProtectedCrypto', 'instoCoinbaseOneProtectedCrypto'], + mastercard: ['payUpFront', 'cardAndPhone'], + discover: ['payUpFront', 'cardAndPhone', 'earnNuxHome'], + debit: ['payUpFront', 'earnCryptoCard', 'cardAndPhone'], + caution: ['coinbaseOneDocWarning'], + governance: ['governance', 'instoGovernance'], + vote: ['governance', 'vote', 'engagement', 'instoGovernance'], + proposal: ['governance', 'instoGovernance'], + ballot: ['governance', 'vote', 'instoGovernance'], + yes: ['governance', 'processing', 'success', 'walletConfirmation', 'instoGovernance'], + no: ['governance', 'noLongAddresses', 'instoGovernance'], + maybe: ['governance', 'instoGovernance'], + so: ['governance', 'instoGovernance'], Hold: ['holdingCrypto'], - HODL: ['holdingCrypto', 'buyFirstCrypto'], - save: ['holdingCrypto', 'storage', 'fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - simple: ['switchAdvancedToSimpleTrading', 'quickAndSimple'], - ui: ['switchAdvancedToSimpleTrading', 'orderBooks'], - change: ['switchAdvancedToSimpleTrading'], - decent: ['dappsGeneral'], - applications: ['dappsGeneral'], + down: ['holdingCrypto', 'coinbaseIsDown', 'coinbaseIsDownMobile'], + hardware: ['hardwareWallets'], + storage: ['hardwareWallets', 'secureStorage', 'storage', 'cloud'], + orders: ['limitOrders'], + bottom: ['limitOrders'], + base: ['limitOrders', 'layerThree'], + set: ['limitOrders'], + specific: ['limitOrders', 'notificationsAndUpdates'], + price: ['limitOrders', 'notificationsAndUpdates'], + notification: ['emailNotification', 'notificationsAndUpdates', 'notificationsAlt'], + next: ['emailNotification'], + step: ['emailNotification'], + click: ['emailNotification'], + '💌': ['emailNotification'], + '📨': ['emailNotification'], + '📧': ['emailNotification'], + '📩': ['emailNotification'], + '📬': ['emailNotification'], + '✉️': ['emailNotification'], + increased: ['transactionLimit'], + send: [ + 'transactionLimit', + 'yourContacts', + 'sendCryptoFaster', + 'remittances', + 'ethereumToWallet', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + ], + token: ['transactionLimit', 'dappsL2Support', 'coinbaseOneTokenRewards'], + Document: ['docError'], + problem: ['docError'], + validation: ['docError'], + doc: ['docError'], + '📑': ['docError', 'commerceAccounting', 'commerceInvoices', 'smartContract'], + diamond: ['diamondHands', 'quest'], + shiny: ['diamondHands'], + free: ['freeBtc'], + get: ['freeBtc'], + paid: ['freeBtc'], + star: ['freeBtc', 'basedInUsa'], + join: ['freeBtc'], + refer: ['freeBtc'], + amounts: ['verifyBankTransactions', 'verifyCardTransactions'], + fiat: ['verifyBankTransactions', 'verifyCardTransactions'], + dollar: ['verifyBankTransactions', 'verifyCardTransactions', 'remittances'], + process: ['verifyBankTransactions', 'verifyCardTransactions', 'polling'], + Account: ['accountUnderReview'], + under: ['accountUnderReview'], + magnifying: ['accountUnderReview', 'verifyIdDetails', 'errorWeb404', 'errorWeb404Mobile'], + glass: ['accountUnderReview', 'verifyIdDetails', 'errorWeb404', 'errorWeb404Mobile'], + checking: ['accountUnderReview'], + confirming: ['accountUnderReview'], + picture: ['twoIdVerify', 'idBack', 'idFront'], + different: ['twoIdVerify'], + window: [ + 'coinbaseIsDown', + 'errorRefresh', + 'errorWeb404', + 'coinbaseIsDownMobile', + 'error400', + 'errorWeb500', + 'errorWeb400', + 'developer', + 'errorWeb', + 'errorRefreshWeb', + ], + generic: [ + 'coinbaseIsDown', + 'coinbaseCardIssue', + 'coinbaseIsDownMobile', + 'error400', + 'errorWeb400', + 'success', + 'bigWarning', + 'outage', + ], + general: ['coinbaseIsDown', 'coinbaseIsDownMobile', 'success', 'bigWarning', 'outage'], + secure: [ + 'coinbaseIsDown', + 'secureGlobalTransactions', + 'secureStorage', + 'secureAndTrusted', + 'coinbaseIsDownMobile', + 'securityShield', + 'privateKey', + 'instoPrivateKey', + ], + safu: ['coinbaseIsDown', 'coinbaseIsDownMobile'], + desktop: [ + 'coinbaseIsDown', + 'errorWeb404', + 'errorWeb500', + 'errorWeb400', + 'errorWeb', + 'errorMoblie', + 'errorWeb404Mobile', + ], + '🔒': [ + 'coinbaseIsDown', + 'coinbaseIsDownMobile', + 'keyGeneration', + 'enableBiometrics', + 'web3MobileSetupStart', + ], + bars: ['earn', 'lowCost', 'coinbaseOneEarn'], + contacts: ['yourContacts', 'contactsListWarning'], + person: ['yourContacts', 'web3ActivitySigned', 'keyGeneration', 'enableBiometrics'], + friends: ['yourContacts'], + family: ['yourContacts'], + associates: ['yourContacts'], + access: [ + 'yourContacts', + 'ledgerAccess', + 'ethereumToWallet', + 'unlockKey', + 'privateKey', + 'instoPrivateKey', + ], + image: ['exploreDecentralizedApps'], + ghost: ['exploreDecentralizedApps', 'cryptoApps'], + magical: ['exploreDecentralizedApps'], + '👻': ['exploreDecentralizedApps'], + '📲': ['exploreDecentralizedApps'], + exchange: ['defiDecentralizedTradingExchange', 'exchange'], + path: ['networkWarning'], + direction: ['networkWarning'], + to: ['earnIdVerification', 'coinbaseOneUSDCBig', 'usdAndUsdc'], + monnneeeyyyyy: ['earnIdVerification'], + rocket: ['rocket'], + space: ['rocket', 'spacedOutSystemError', 'alienDonutSystemError'], + blast: ['rocket'], + off: ['rocket'], + moon: ['rocket', 'cryptoAndMore', 'oilAndGold'], + '🚀': ['rocket'], + celebrate: ['rocket'], + positive: ['rocket', 'faceMatchReal', 'success', 'private', 'walletConfirmation'], + ledger: ['ledgerPlugin', 'ledgerAccess'], + plugin: ['ledgerPlugin', 'ledgerAccess'], + instructional: ['ledgerPlugin', 'ledgerAccess'], + rating: ['ratingsAndReviews'], + download: ['walletUi', 'downloadCoinbaseWallet'], + self: ['walletUi'], + custody: ['walletUi'], + NFT: ['walletUi', 'minting', 'generative'], + keys: ['walletUi'], + global: ['secureGlobalTransactions'], + transactions: ['secureGlobalTransactions'], + not: ['insufficientBalance', 'coinbaseCardIssue', 'errorWeb404', 'errorWeb404Mobile'], + enough: ['insufficientBalance'], + low: ['insufficientBalance'], + need: ['insufficientBalance'], + Coinbase: ['coinbaseCardIssue', 'coinbaseCardSpendCrypto', 'referralsCoinbaseOne'], + something: ['coinbaseCardIssue'], + right: ['coinbaseCardIssue'], + '💳': ['coinbaseCardIssue', 'coinbaseCardSpendCrypto', 'web3MobileSetupSuccess'], + borrow: ['borrowWallet', 'borrow'], + law: ['stableValue'], + stable: ['stableValue', 'usdtToUSDC', 'stablecoin', 'realToUSDC'], + passcode: ['phoneNumber'], + asterisk: ['phoneNumber'], + Spend: ['coinbaseCardSpendCrypto'], + real: ['coinbaseCardSpendCrypto'], + world: ['coinbaseCardSpendCrypto'], + use: ['coinbaseCardSpendCrypto'], + case: ['coinbaseCardSpendCrypto'], + '🔍': ['verifyIdDetails', 'quest'], + refresh: ['errorRefresh', 'errorRefreshWeb'], + page: ['errorRefresh', 'errorRefreshWeb'], + pull: ['errorRefresh', 'errorRefreshWeb'], + try: ['errorRefresh', 'errorRefreshWeb'], + again: ['errorRefresh', 'errorRefreshWeb'], + extra: ['errorRefresh', 'errorRefreshWeb'], + life: ['errorRefresh', 'errorRefreshWeb'], + books: ['orderBooks'], + buying: ['orderBooks'], + selling: ['orderBooks'], + Username: ['sendToUsername'], + Avatar: ['sendToUsername'], + Person: ['sendToUsername', 'faceMatchReal', 'private'], + Payment: ['sendToUsername'], + Arrow: ['sendToUsername'], + Direct: ['sendToUsername'], + Pay: ['sendToUsername'], + Back: ['sendToUsername'], + record: ['videoRequest'], + message: ['videoRequest'], + limitorders: ['focusLimitOrders'], + Verification: ['idVerificationSecure', 'idIssue'], + Personal: ['idVerificationSecure'], + data: ['idVerificationSecure', 'web3MobileSetupSuccess', 'cloudBacking', 'storage', 'cloud'], + triangle: ['dappsGaming'], + contact: ['contactsListWarning'], + '⚠': ['contactsListWarning'], + can: ['errorWeb404', 'errorWeb404Mobile'], + find: ['errorWeb404', 'indexer', 'errorWeb404Mobile'], + locate: ['errorWeb404', 'indexer', 'errorWeb404Mobile'], + walllet: ['downloadCoinbaseWallet'], + trust: ['secureAndTrusted', 'defiRisk'], + alert: ['notificationsAndUpdates', 'notificationsAlt'], + hit: ['notificationsAndUpdates'], + balloon: ['readyToTrade'], + welcome: ['readyToTrade', 'coinbaseOneWelcome'], + created: ['readyToTrade'], + enroll: ['defiEnrollBoost'], + boost: ['defiEnrollBoost'], + Identity: ['idIssue', 'faceMatchReal', 'private'], + Issue: ['idIssue'], + Concern: ['idIssue'], + Error: ['idIssue'], + deFi: ['defiRisk'], + banner: ['defiRisk'], + percent: ['defiRisk', 'coinbaseOnePercentOff', 'earnGlobe', 'instoEarnGlobe'], + sign: ['defiRisk'], + direct: ['directDepositPhone'], + deposit: ['directDepositPhone'], + notifications: ['walletNotifications'], + open: ['openEmail', 'instoOpenEmail'], + letter: ['openEmail', 'instoOpenEmail'], + '📧 📥 📤 ✉ 📩 📨': ['openEmail', 'instoOpenEmail'], + apps: ['cryptoApps', 'cryptoAppsWallet', 'browseDecentralizedApps', 'dappsGeneral'], + unicorn: ['cryptoApps'], + charts: ['cryptoApps'], + 'gas fees': ['gasFeesNetworkFees'], + 'fuel tank': ['gasFeesNetworkFees'], + slippage: ['slippageTolerance'], + tolerance: ['slippageTolerance'], + gather: ['cryptoAppsWallet', 'browseDecentralizedApps'], + load: ['walletLoading'], + loading: ['walletLoading', 'polling'], recurring: ['automaticPayments'], automatic: ['automaticPayments'], loan: ['automaticPayments'], calendar: ['automaticPayments'], once: ['automaticPayments'], month: ['automaticPayments'], - image: ['exploreDecentralizedApps'], - magical: ['exploreDecentralizedApps'], - '👻': ['exploreDecentralizedApps'], - '📲': ['exploreDecentralizedApps'], - download: ['walletUi', 'downloadCoinbaseWallet'], - self: ['walletUi'], - custody: ['walletUi'], - your: ['walletUi', 'powerOfCrypto'], - keys: ['walletUi'], - device: ['referralsWalletPhones'], - reviewing: ['verifyIdDetails', 'processing', 'japanVerifyId'], - futures: ['futures'], - future: ['futures'], - short: ['futures'], - hedge: ['futures'], + discounted: ['coinbaseOneDiscountedAmount'], + amount: ['coinbaseOneDiscountedAmount', 'estimatedAmount'], + bell: ['notificationsAlt'], + '🔔': ['notificationsAlt'], + '🔕': ['notificationsAlt'], + tracking: ['appTrackingTransparency', 'earnNuxHome'], + transparency: ['appTrackingTransparency'], + Select: ['selectCorrectCrypto'], + be: ['selectCorrectCrypto'], + double: ['selectCorrectCrypto'], + selection: ['selectCorrectCrypto'], + choose: ['selectCorrectCrypto', 'recommendInvest', 'claimCryptoUsername', 'ensProfilePic'], + wisely: ['selectCorrectCrypto'], + faster: ['sendCryptoFaster'], + lightning: ['sendCryptoFaster'], + bolt: [ + 'sendCryptoFaster', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + ], + move: ['sendCryptoFaster'], + quicker: ['sendCryptoFaster'], + '⚡️': ['sendCryptoFaster'], + USA: ['basedInUsa', 'backedByUsDollar'], + delight: ['earnCryptoCard'], + entry: ['ledgerAccess'], + p2p: ['p2pGifting'], + gifting: ['p2pGifting'], + cards: ['p2pGifting'], + giftcard: ['p2pGifting'], + commerce: ['commerceAccounting', 'commerceInvoices'], + accounting: ['commerceAccounting'], + '📝': ['commerceAccounting', 'commerceInvoices'], + '📄': ['commerceAccounting', 'commerceInvoices', 'smartContract'], + '📃': ['commerceAccounting', 'commerceInvoices', 'settlement', 'smartContract'], + '⬇': ['commerceAccounting'], + estimated: ['estimatedAmount'], + prices: ['estimatedAmount'], + calculation: ['estimatedAmount'], + Dollar: ['backedByUsDollar'], + bar: ['performance'], + performance: ['performance'], + MXD: ['remittances'], + Mexico: ['remittances'], + remit: ['remittances'], + remittances: ['remittances'], + international: ['remittances', 'earnGlobe', 'instoEarnGlobe'], + border: ['remittances'], + '🇲🇽': ['remittances'], + lending: ['defiDecentralizedBorrowingLending', 'earnCryptoInterest'], + borrowing: ['defiDecentralizedBorrowingLending'], + padlock: ['securityShield'], + pairs: ['currencyPairs'], + paring: ['currencyPairs'], + invoices: ['commerceInvoices'], + '➕': ['commerceInvoices'], + lighting: ['coinbaseOnePhoneLightning'], + '🔋': ['coinbaseOnePhoneLightning'], + '⚡': [ + 'coinbaseOnePhoneLightning', + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + ], + history: ['tradeHistory'], + farming: ['earnCryptoInterest'], + '%': ['earnCryptoInterest'], + NFTs: ['collectableNfts'], + collectable: ['collectableNfts'], + collectible: ['collectableNfts'], + nyan: ['collectableNfts'], + flowers: ['collectableNfts'], + Face: ['faceMatchReal', 'private'], + Match: ['faceMatchReal', 'private'], + KYC: ['faceMatchReal', 'private'], + Human: ['faceMatchReal', 'private'], + head: ['faceMatchReal', 'private'], + invalid: ['coinbaseOneWalletWarning'], + 'unable to send': ['coinbaseOneWalletWarning'], + dapps: ['dappsL2Support', 'ethereumToWallet', 'web3MobileSetupSuccess', 'dappsGeneral'], + badging: ['dappsL2Support'], + 'chicken fish': ['chickenFishSystemError'], + merge: ['chickenFishSystemError'], + 'uh oh': ['chickenFishSystemError', 'spacedOutSystemError', 'cardError', 'cardErrorCB1'], + wtf: ['chickenFishSystemError'], + '🐟': ['chickenFishSystemError'], + '🐠': ['chickenFishSystemError'], + '🍣': ['chickenFishSystemError'], + '🎣': ['chickenFishSystemError'], + '🐡': ['chickenFishSystemError'], + '🐓': ['chickenFishSystemError'], + '🐔': ['chickenFishSystemError'], + fly: ['walletFlyEmptyState'], + missing: ['walletFlyEmptyState'], + '🪰': ['walletFlyEmptyState'], + '📁': ['walletFlyEmptyState', 'squidEmptyState'], + squid: ['squidEmptyState'], + '🦑': ['squidEmptyState'], + server: ['serverCatSystemError', 'storage'], + 'get out': ['serverCatSystemError'], + 'cat being a cat': ['serverCatSystemError'], + '🐈': [ + 'serverCatSystemError', + 'exchangeEmptyState', + 'catLostSystemError', + 'catHoldingWalletEmptyState', + ], + cute: ['exchangeEmptyState', 'catHoldingWalletEmptyState'], gateway: ['spacedOutSystemError'], door: ['spacedOutSystemError'], 'empty feeling': ['spacedOutSystemError'], glimpse: ['spacedOutSystemError'], 'oh no': ['spacedOutSystemError'], '🪐': ['spacedOutSystemError', 'alienDonutSystemError'], - load: ['walletLoading'], - loading: ['walletLoading', 'polling'], - indicator: ['advancedTradingChartsIndicatorsCandles'], - candles: ['advancedTradingChartsIndicatorsCandles'], - history: ['tradeHistory'], - trending: ['trendingHotAssets'], - hot: ['trendingHotAssets'], - certified: ['documentCertified'], - ribbon: ['documentCertified'], - reviewed: ['documentCertified', 'documentSuccess'], - approved: ['documentCertified'], - stamped: ['documentCertified'], - papers: ['documentCertified'], - global: ['secureGlobalTransactions'], - transactions: ['secureGlobalTransactions'], - DeFi: ['primeDeFi'], - Decentralized: ['primeDeFi'], - Finance: ['primeDeFi'], - Explore: ['primeDeFi'], - Stars: ['primeDeFi'], - developer: ['developer'], - develop: ['developer'], - screen: ['developer'], - write: ['developer'], - eng: ['developer'], - engineering: ['developer'], - amounts: ['verifyBankTransactions', 'verifyCardTransactions'], - fiat: ['verifyBankTransactions', 'verifyCardTransactions'], - process: ['verifyBankTransactions', 'polling', 'verifyCardTransactions'], - 'paper hands': ['paperHands'], - 'toilet paper': ['paperHands'], - 'sell off': ['paperHands'], - protect: ['coinbaseOneProtectedCrypto', 'idVerificationSecure'], - protected: ['coinbaseOneProtectedCrypto'], - pillars: ['platform'], - collection: ['emptyCollection', 'hiddenCollection'], - spider: ['emptyCollection'], - folders: ['storage'], - file: ['storage', 'fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - hidden: ['hiddenCollection'], - eye: ['hiddenCollection', 'watchVideos'], - frame: ['hiddenCollection'], - blinds: ['hiddenCollection'], - mining: ['mining'], - DO: ['earnSuccess'], - NOT: ['earnSuccess'], - USE: ['earnSuccess'], - except: ['earnSuccess'], - for: ['earnSuccess'], - watch: ['watchVideos'], - videos: ['watchVideos'], - failure: ['bigError'], - mistake: ['bigError'], - wrong: ['bigError'], - animation: ['success', 'polling', 'outage', 'bigWarning'], - 'stacks of coins': ['ethStakingRewards'], - Power: ['powerOfCrypto'], - in: ['powerOfCrypto'], - pattern: ['powerOfCrypto'], - tech: ['powerOfCrypto'], - L1: ['powerOfCrypto'], - Document: ['docError'], - problem: ['docError'], - validation: ['docError'], - doc: ['docError'], - progress: ['polling'], - processing: ['polling', 'processing'], - working: ['polling'], - cash: ['borrow', 'cashExcitement'], 'ice cream': ['iceCreamMeltingSystemError'], melt: ['iceCreamMeltingSystemError'], laptop: ['iceCreamMeltingSystemError'], @@ -2631,29 +2613,6 @@ const descriptionMap: Record = { '🍦': ['iceCreamMeltingSystemError'], '🍨': ['iceCreamMeltingSystemError'], '💻': ['iceCreamMeltingSystemError'], - Send: ['sendToUsername', 'coinsInWallet'], - Username: ['sendToUsername'], - Avatar: ['sendToUsername'], - Payment: ['sendToUsername'], - Arrow: ['sendToUsername'], - Direct: ['sendToUsername'], - Pay: ['sendToUsername'], - Back: ['sendToUsername'], - License: ['idVerificationSecure', 'earnIdVerification', 'idCard'], - Verification: ['idVerificationSecure', 'idIssue'], - Documents: ['idVerificationSecure', 'documentSuccess'], - Personal: ['idVerificationSecure'], - Issue: ['idIssue'], - Concern: ['idIssue'], - Error: ['idIssue'], - squares: ['decentralizedWebWeb3'], - pointer: ['decentralizedWebWeb3'], - grid: ['decentralizedWebWeb3'], - docs: ['processing'], - received: ['processing'], - negative: ['outage', 'bigWarning'], - hardware: ['hardwareWallets'], - redesign: ['coinbaseRedesigned'], alien: ['alienDonutSystemError'], donut: ['alienDonutSystemError'], 'take a break': ['alienDonutSystemError'], @@ -2661,94 +2620,433 @@ const descriptionMap: Record = { '👽': ['alienDonutSystemError'], '🛸': ['alienDonutSystemError'], '🍩': ['alienDonutSystemError'], - first: ['buyFirstCrypto'], - financial: ['buyFirstCrypto'], - freedom: ['buyFirstCrypto'], + empty: ['catHoldingWalletEmptyState'], + benefits: ['coinbaseOneWelcome'], + program: ['coinbaseOneWelcome'], + 'credit card': ['cardError', 'cardErrorCB1'], + cbone: ['coinbaseOneRewards', 'coinbaseOneTokenRewards'], + APY: ['coinbaseOneRewards', 'retailUSDCRewards'], + rate: ['coinbaseOneRewards', 'coinbaseOnePercentOff', 'retailUSDCRewards'], + incentives: [ + 'coinbaseOneTokenRewards', + 'coinbaseOnePercentOff', + 'coinbaseOneUSDCIncentives', + 'bitcoinGlobe', + ], + gift: ['coinbaseOneTokenRewards'], + surprise: ['coinbaseOneTokenRewards'], + discount: ['coinbaseOnePercentOff'], + priceTag: ['coinbaseOnePercentOff'], + '🏷️': ['coinbaseOnePercentOff'], + USDC: ['coinbaseOneUSDCIncentives', 'retailUSDCRewards'], + crystalBall: ['coinbaseOneUSDCIncentives', 'bitcoinGlobe'], + returns: ['coinbaseOneUSDCIncentives', 'bitcoinGlobe'], + '🔮': ['coinbaseOneUSDCIncentives', 'bitcoinGlobe', 'oracle'], + hub: ['earnNuxHome'], + manage: ['earnNuxHome'], + precent: ['earnNuxHome'], + 'stacks of coins': ['ethStakingRewards', 'instoEthStakingRewards'], + docs: ['processing'], + processing: ['processing', 'polling'], + received: ['processing'], + agree: ['success', 'walletConfirmation'], + animation: ['success', 'bigWarning', 'polling', 'outage'], + failure: ['bigError'], + mistake: ['bigError'], + wrong: ['bigError'], + negative: ['bigWarning', 'outage'], + DO: ['earnSuccess'], + NOT: ['earnSuccess'], + USE: ['earnSuccess'], + except: ['earnSuccess'], + for: ['earnSuccess'], + hidden: ['hiddenCollection'], + frame: ['hiddenCollection'], + blinds: ['hiddenCollection'], + progress: ['polling'], + working: ['polling'], + recommend: ['recommendInvest'], + recommended: ['recommendInvest'], + recommendation: ['recommendInvest'], + invest: ['recommendInvest'], + investments: ['recommendInvest'], + tokens: ['recommendInvest'], + 'global transactions': ['globalTransactions'], + how: ['defiHow'], + mining: ['mining'], + DeFi: ['primeDeFi'], + Decentralized: ['primeDeFi'], + Finance: ['primeDeFi'], + Explore: ['primeDeFi'], + Stars: ['primeDeFi'], + '🔜': ['requestSent'], + '⏰': ['requestSent'], + '⏱️': ['requestSent'], + '⏲️': ['requestSent'], + '⌚️': ['requestSent'], + '⏳': ['requestSent'], + '⌛️': ['requestSent'], + '🕰️': ['requestSent'], + setup: ['web3MobileSetupSuccess', 'web3MobileSetupStart'], + settings: ['web3MobileSetupSuccess', 'web3MobileSetupStart'], + '👝': ['web3MobileSetupSuccess'], + '👛': ['web3MobileSetupSuccess'], + '👜': ['web3MobileSetupSuccess'], + '🖼️': ['web3MobileSetupSuccess'], + id: ['web3ActivitySigned', 'web3MobileSetupStart'], + human: ['web3ActivitySigned', 'keyGeneration', 'enableBiometrics'], + '🆔': ['web3ActivitySigned', 'web3MobileSetupStart'], + '🪪': ['web3ActivitySigned', 'web3MobileSetupStart'], + scan: ['keyGeneration', 'enableBiometrics', 'web3MobileSetupStart'], + biometrics: ['keyGeneration', 'enableBiometrics'], + identification: ['keyGeneration', 'enableBiometrics'], + finger: ['keyGeneration', 'enableBiometrics'], + '🗝️': ['keyGeneration', 'enableBiometrics'], + '🔑': ['keyGeneration', 'enableBiometrics', 'web3MobileSetupStart'], + '🛡️': ['keyGeneration', 'enableBiometrics'], + '🔐': ['web3MobileSetupStart'], + cloud: ['cloudBacking', 'cloud'], + backing: ['cloudBacking'], + would: ['stakingMissedReturns', 'stakingMissedReturnsUsdc', 'instoStakingMissedReturns'], + have: ['stakingMissedReturns', 'stakingMissedReturnsUsdc', 'instoStakingMissedReturns'], + missed: ['stakingMissedReturns', 'stakingMissedReturnsUsdc', 'instoStakingMissedReturns'], + out: ['stakingMissedReturns', 'stakingMissedReturnsUsdc', 'instoStakingMissedReturns'], + grow: ['stakingMissedReturns', 'stakingMissedReturnsUsdc', 'instoStakingMissedReturns'], + ens: ['claimCryptoUsername', 'ensProfilePic', 'noLongAddresses'], + username: ['claimCryptoUsername', 'ensProfilePic', 'noLongAddresses'], + pick: ['claimCryptoUsername'], + robot: ['claimCryptoUsername', 'ensProfilePic'], + claw: ['claimCryptoUsername'], + you: ['claimCryptoUsername'], + service: ['claimCryptoUsername', 'ensProfilePic', 'noLongAddresses'], + long: ['noLongAddresses'], + scissors: ['noLongAddresses'], + cut: ['noLongAddresses'], + CB1: ['referralsCoinbaseOne'], + One: ['referralsCoinbaseOne', 'coinbaseOneUSDCBig', 'coinbaseOneAirdrop', 'usdAndUsdc'], + multiple: ['namePortfolio', 'multiplePortfolios'], + multi: ['namePortfolio', 'multiplePortfolios', 'scalable'], + many: ['namePortfolio', 'multiplePortfolios', 'scalable'], + port: ['namePortfolio', 'multiplePortfolios'], + piechart: ['namePortfolio', 'multiplePortfolios'], + type: ['namePortfolio'], + usdc: ['coinbaseOneUSDCBig', 'usdtToUSDC', 'realToUSDC', 'usdAndUsdc'], USDCoin: ['coinbaseOneUSDCBig', 'usdAndUsdc'], USD: ['coinbaseOneUSDCBig', 'usdAndUsdc'], linking: ['coinbaseOneUSDCBig', 'usdAndUsdc'], - to: ['coinbaseOneUSDCBig', 'earnIdVerification', 'usdAndUsdc'], - p2p: ['p2pGifting'], - gifting: ['p2pGifting'], - cards: ['p2pGifting'], - giftcard: ['p2pGifting'], - mail: ['optInPushNotificationsEmail'], - path: ['networkWarning'], - direction: ['networkWarning'], - increased: ['transactionLimit'], - Id: ['earnIdVerification', 'idCard'], - Front: ['earnIdVerification', 'idCard'], - monnneeeyyyyy: ['earnIdVerification'], - next: ['emailNotification'], - step: ['emailNotification'], - click: ['emailNotification'], - '💌': ['emailNotification'], - '📨': ['emailNotification'], - '📧': ['emailNotification'], - '📩': ['emailNotification'], - '📬': ['emailNotification'], - '✉️': ['emailNotification'], - different: ['twoIdVerify'], - forms: ['twoIdVerify', 'fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - airdrop: ['coinbaseOneAirdrop'], - falling: ['coinbaseOneAirdrop'], - drop: ['coinbaseOneAirdrop'], - mic: ['mic'], - microphone: ['mic'], - talk: ['mic'], - speech: ['mic'], - voice: ['mic'], - '🎙': ['mic'], - 'stop watch': ['getStartedInMinutes'], - 'get started': ['getStartedInMinutes'], + usdt: ['usdtToUSDC', 'realToUSDC'], + stablecoin: ['usdtToUSDC', 'stablecoin', 'realToUSDC'], + layer: ['layerOne', 'bridge', 'layerTwo'], + voting: ['vote'], + 'box. DAO': ['vote'], + cast: ['vote'], + bridge: ['bridge'], + bridging: ['bridge'], + bridged: ['bridge'], + folders: ['storage'], + protocol: ['protocol'], + contract: ['protocol', 'lightningNetworkInvoice'], + smart: ['protocol', 'innovation'], + mint: ['minting'], + minting: ['minting'], + three: ['minting', 'dappsGeneral', 'layerThree'], + developer: ['developer'], + develop: ['developer'], + code: ['developer', 'lightningNetworkInvoice', 'lightningNetwork'], + screen: ['developer'], + write: ['developer'], + eng: ['developer'], + engineering: ['developer'], + unlock: ['unlockKey'], + key: ['unlockKey', 'privateKey', 'instoPrivateKey'], + engagement: ['engagement'], + avatars: ['engagement', 'connectPeople'], + icons: ['engagement'], + heart: ['engagement'], + thumb: ['engagement'], + link: ['connectPeople'], + chainlink: ['connectPeople'], + weigh: ['stablecoin'], + same: ['stablecoin'], + even: ['stablecoin'], + scalable: ['scalable'], + cpu: ['scalable'], + decent: ['dappsGeneral'], + applications: ['dappsGeneral'], + private: ['privateKey', 'instoPrivateKey'], + encryption: ['privateKey', 'instoPrivateKey'], + acces: ['privateKey', 'instoPrivateKey'], innovation: ['innovation'], innovate: ['innovation'], idea: ['innovation'], lightbulb: ['innovation'], face: ['innovation'], - digital: ['collectingNfts'], - collectibles: ['collectingNfts'], - nfts: ['collectingNfts'], - books: ['orderBooks'], - buying: ['orderBooks'], - selling: ['orderBooks'], - Deposit: ['coinsInWallet'], - Receive: ['coinsInWallet'], - Drivers: ['idCard'], + index: ['indexer'], + indexer: ['indexer'], + search: ['indexer'], + results: ['indexer'], + cart: ['buy'], + shopping: ['buy'], + settlement: ['settlement'], + '👤': ['platform', 'anonymous'], + pillars: ['platform'], + '🌕': ['exchange'], + '🧮': ['exchange'], + anonymous: ['anonymous'], + transfer: ['anonymous'], + gold: ['digitalGold', 'oilAndGold'], + cursor: ['digitalGold'], + bricks: ['digitalGold'], + '🥇': ['digitalGold'], + '🧱': ['digitalGold'], + '🔵': ['oracle'], + '📿': ['oracle'], + '🧙‍♀️': ['oracle'], + '🧙‍♂️': ['oracle'], + mystical: ['oracle'], + orb: ['oracle'], + Spell: ['oracle'], + Sorcery: ['oracle'], + 'Crystal ball': ['oracle'], + '💎': ['quest'], + services: ['cloud'], + gear: ['cloud', 'tools'], + files: ['cloud'], + generative: ['generative'], + handshake: ['smartContract'], + 'smart contracts': ['smartContract'], + contracts: ['smartContract'], + '📜': ['smartContract'], + Government: ['governanceMallet'], + Policy: ['governanceMallet'], + Legislation: ['governanceMallet'], + Governance: ['governanceMallet'], + '⚖️': ['governanceMallet'], + '🏛': ['governanceMallet'], + Wrench: ['tools'], + tool: ['tools'], + tools: ['tools'], + '🛠': ['tools'], + '⚒': ['tools'], + '🔨': ['tools'], + '🔧': ['tools'], + '🧰': ['tools'], + gaming: ['gamer'], + gamer: ['gamer'], + '👾': ['gamer'], + '🖥️': ['gamer'], + '🖱': ['gamer'], + Lighting: [ + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + ], + Lightingnetwork: [ + 'lightningNetworkInvoice', + 'lightningNetworkSend', + 'lightningNetwork', + 'lightningNetworkTransfer', + ], + invoice: ['lightningNetworkInvoice'], + QR: ['lightningNetworkInvoice', 'lightningNetwork'], + lightingbolt: ['lightningNetworkSend', 'lightningNetwork', 'lightningNetworkTransfer'], + machine: ['lightningNetwork'], + factory: ['lightningNetwork'], + airdrop: ['coinbaseOneAirdrop'], + falling: ['coinbaseOneAirdrop'], + drop: ['coinbaseOneAirdrop'], + oil: ['oilAndGold'], + coinfifty: ['coinFifty'], + fifty: ['coinFifty'], ccoin: ['cbbtc'], cbbtc: ['cbbtc'], - limitorders: ['focusLimitOrders'], - walllet: ['downloadCoinbaseWallet'], - taxes: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - government: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - irs: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - tax: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - center: ['fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck'], - trash: ['discardAssets'], - rubbish: ['discardAssets'], - delete: ['discardAssets'], - remove: ['discardAssets'], - Select: ['selectCorrectCrypto'], - be: ['selectCorrectCrypto'], - double: ['selectCorrectCrypto'], - selection: ['selectCorrectCrypto'], - wisely: ['selectCorrectCrypto'], - japan: ['japanVerifyId'], - '🇯🇵': ['japanVerifyId'], - caution: ['coinbaseOneDocWarning'], - governance: ['governance'], - proposal: ['governance'], - maybe: ['governance'], - so: ['governance'], - retail: ['webRAT'], - RAT: ['webRAT'], - education: ['webRAT'], + 'layer three': ['layerThree'], + liquidation: [ + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', + ], + buffer: [ + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', + ], + threshold: [ + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', + ], + derivatives: [ + 'liquidationBufferRed', + 'liquidationBufferGreen', + 'liquidationBufferRedClose', + 'liquidationBufferYellow', + ], + vip: ['vipBadge'], + badge: ['vipBadge'], + lanyard: ['vipBadge'], + xrp: ['cbxrp'], + cbxrp: ['cbxrp'], + cbltc: ['cbltc', 'flipStable'], + litecoin: ['cbltc', 'flipStable'], + doge: ['cbdoge'], + cbdoge: ['cbdoge'], ada: ['cbada'], cbada: ['cbada'], - entry: ['ledgerAccess'], - coinfifty: ['coinFifty'], - fifty: ['coinFifty'], - '🔐': ['web3MobileSetupStart'], + insto: [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], + prime: [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], + negroni: [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], + orange: [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], + institutional: [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], + 'institutional investor': [ + 'instoWalletSecurity', + 'instoAddBankAccount', + 'instoAdd2Fa', + 'instoPhoneUnknown', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoPrivateKey', + 'instoGovernance', + 'instoEthStakingUpsell', + 'instoEthStakingRewards', + 'instoStaking', + 'instoOpenEmail', + 'instoPrimeStaking', + 'instoEarnGlobe', + 'instoStakingMissedReturns', + 'instoOnChain', + 'instoSecurityKeyAuth', + 'instoWeb3MobileSetupStart', + 'instoRequestSent', + 'instoEnableBiometrics', + 'instoKeyGenerationPending', + 'instoWallet', + 'instoKeyGenerationComplete', + ], }; export default descriptionMap; diff --git a/packages/illustrations/src/__generated__/heroSquare/data/names.ts b/packages/illustrations/src/__generated__/heroSquare/data/names.ts index c0a5ba9d79..c4b5ce6c51 100644 --- a/packages/illustrations/src/__generated__/heroSquare/data/names.ts +++ b/packages/illustrations/src/__generated__/heroSquare/data/names.ts @@ -60,6 +60,7 @@ const names: HeroSquareName[] = [ 'blockchain', 'borrow', 'borrowCoins', + 'borrowCoinsBtc', 'borrowWallet', 'brdGift', 'bridge', @@ -80,6 +81,7 @@ const names: HeroSquareName[] = [ 'cbbtc', 'cbdoge', 'cbltc', + 'cbmega', 'cbxrp', 'chickenFishSystemError', 'claimCryptoUsername', @@ -136,6 +138,7 @@ const names: HeroSquareName[] = [ 'cryptoEconomy', 'cryptoForBeginners', 'cryptoPortfolio', + 'cryptoPortfolioUsdc', 'cryptoWallet', 'currencyPairs', 'dappsArts', @@ -207,6 +210,7 @@ const names: HeroSquareName[] = [ 'fiat', 'fileYourCryptoTaxes', 'fileYourCryptoTaxesCheck', + 'flipStable', 'focusLimitOrders', 'freeBtc', 'futures', @@ -236,6 +240,29 @@ const names: HeroSquareName[] = [ 'indexer', 'innovation', 'instantUnstakingClock', + 'instoAdd2Fa', + 'instoAddBankAccount', + 'instoCoinbaseOneProtectedCrypto', + 'instoDocumentSuccess', + 'instoEarnGlobe', + 'instoEnableBiometrics', + 'instoEthStakingRewards', + 'instoEthStakingUpsell', + 'instoGovernance', + 'instoKeyGenerationComplete', + 'instoKeyGenerationPending', + 'instoOnChain', + 'instoOpenEmail', + 'instoPhoneUnknown', + 'instoPrimeStaking', + 'instoPrivateKey', + 'instoRequestSent', + 'instoSecurityKeyAuth', + 'instoStaking', + 'instoStakingMissedReturns', + 'instoWallet', + 'instoWalletSecurity', + 'instoWeb3MobileSetupStart', 'insufficientBalance', 'insuranceProtection', 'invest', @@ -372,10 +399,12 @@ const names: HeroSquareName[] = [ 'sustainable', 'switchAdvancedToSimpleTrading', 'taxesDetails', + 'test', 'tools', 'tradeGeneral', 'tradeHistory', 'tradeImmediately', + 'tradingPerpetualsUsdc', 'tradingWithLeverage', 'transactionLimit', 'trendingHotAssets', diff --git a/packages/illustrations/src/__generated__/heroSquare/data/svgJsMap.ts b/packages/illustrations/src/__generated__/heroSquare/data/svgJsMap.ts index 810158296f..18f83ec079 100644 --- a/packages/illustrations/src/__generated__/heroSquare/data/svgJsMap.ts +++ b/packages/illustrations/src/__generated__/heroSquare/data/svgJsMap.ts @@ -206,6 +206,10 @@ const svgJsMap = { light: () => require('../svgJs/light/borrowCoins-2.js').content, dark: () => require('../svgJs/dark/borrowCoins-2.js').content, }, + borrowCoinsBtc: { + light: () => require('../svgJs/light/borrowCoinsBtc-0.js').content, + dark: () => require('../svgJs/dark/borrowCoinsBtc-0.js').content, + }, borrowWallet: { light: () => require('../svgJs/light/borrowWallet-5.js').content, dark: () => require('../svgJs/dark/borrowWallet-5.js').content, @@ -286,6 +290,10 @@ const svgJsMap = { light: () => require('../svgJs/light/cbltc-0.js').content, dark: () => require('../svgJs/dark/cbltc-0.js').content, }, + cbmega: { + light: () => require('../svgJs/light/cbmega-0.js').content, + dark: () => require('../svgJs/dark/cbmega-0.js').content, + }, cbxrp: { light: () => require('../svgJs/light/cbxrp-0.js').content, dark: () => require('../svgJs/dark/cbxrp-0.js').content, @@ -510,6 +518,10 @@ const svgJsMap = { light: () => require('../svgJs/light/cryptoPortfolio-4.js').content, dark: () => require('../svgJs/dark/cryptoPortfolio-4.js').content, }, + cryptoPortfolioUsdc: { + light: () => require('../svgJs/light/cryptoPortfolioUsdc-0.js').content, + dark: () => require('../svgJs/dark/cryptoPortfolioUsdc-0.js').content, + }, cryptoWallet: { light: () => require('../svgJs/light/cryptoWallet-5.js').content, dark: () => require('../svgJs/dark/cryptoWallet-5.js').content, @@ -794,6 +806,10 @@ const svgJsMap = { light: () => require('../svgJs/light/fileYourCryptoTaxesCheck-6.js').content, dark: () => require('../svgJs/dark/fileYourCryptoTaxesCheck-6.js').content, }, + flipStable: { + light: () => require('../svgJs/light/flipStable-0.js').content, + dark: () => require('../svgJs/dark/flipStable-0.js').content, + }, focusLimitOrders: { light: () => require('../svgJs/light/focusLimitOrders-4.js').content, dark: () => require('../svgJs/dark/focusLimitOrders-4.js').content, @@ -910,6 +926,98 @@ const svgJsMap = { light: () => require('../svgJs/light/instantUnstakingClock-1.js').content, dark: () => require('../svgJs/dark/instantUnstakingClock-1.js').content, }, + instoAdd2Fa: { + light: () => require('../svgJs/light/instoAdd2Fa-0.js').content, + dark: () => require('../svgJs/dark/instoAdd2Fa-0.js').content, + }, + instoAddBankAccount: { + light: () => require('../svgJs/light/instoAddBankAccount-0.js').content, + dark: () => require('../svgJs/dark/instoAddBankAccount-0.js').content, + }, + instoCoinbaseOneProtectedCrypto: { + light: () => require('../svgJs/light/instoCoinbaseOneProtectedCrypto-1.js').content, + dark: () => require('../svgJs/dark/instoCoinbaseOneProtectedCrypto-1.js').content, + }, + instoDocumentSuccess: { + light: () => require('../svgJs/light/instoDocumentSuccess-1.js').content, + dark: () => require('../svgJs/dark/instoDocumentSuccess-1.js').content, + }, + instoEarnGlobe: { + light: () => require('../svgJs/light/instoEarnGlobe-0.js').content, + dark: () => require('../svgJs/dark/instoEarnGlobe-0.js').content, + }, + instoEnableBiometrics: { + light: () => require('../svgJs/light/instoEnableBiometrics-0.js').content, + dark: () => require('../svgJs/dark/instoEnableBiometrics-0.js').content, + }, + instoEthStakingRewards: { + light: () => require('../svgJs/light/instoEthStakingRewards-0.js').content, + dark: () => require('../svgJs/dark/instoEthStakingRewards-0.js').content, + }, + instoEthStakingUpsell: { + light: () => require('../svgJs/light/instoEthStakingUpsell-0.js').content, + dark: () => require('../svgJs/dark/instoEthStakingUpsell-0.js').content, + }, + instoGovernance: { + light: () => require('../svgJs/light/instoGovernance-0.js').content, + dark: () => require('../svgJs/dark/instoGovernance-0.js').content, + }, + instoKeyGenerationComplete: { + light: () => require('../svgJs/light/instoKeyGenerationComplete-1.js').content, + dark: () => require('../svgJs/dark/instoKeyGenerationComplete-1.js').content, + }, + instoKeyGenerationPending: { + light: () => require('../svgJs/light/instoKeyGenerationPending-0.js').content, + dark: () => require('../svgJs/dark/instoKeyGenerationPending-0.js').content, + }, + instoOnChain: { + light: () => require('../svgJs/light/instoOnChain-2.js').content, + dark: () => require('../svgJs/dark/instoOnChain-2.js').content, + }, + instoOpenEmail: { + light: () => require('../svgJs/light/instoOpenEmail-1.js').content, + dark: () => require('../svgJs/dark/instoOpenEmail-1.js').content, + }, + instoPhoneUnknown: { + light: () => require('../svgJs/light/instoPhoneUnknown-0.js').content, + dark: () => require('../svgJs/dark/instoPhoneUnknown-0.js').content, + }, + instoPrimeStaking: { + light: () => require('../svgJs/light/instoPrimeStaking-0.js').content, + dark: () => require('../svgJs/dark/instoPrimeStaking-0.js').content, + }, + instoPrivateKey: { + light: () => require('../svgJs/light/instoPrivateKey-1.js').content, + dark: () => require('../svgJs/dark/instoPrivateKey-1.js').content, + }, + instoRequestSent: { + light: () => require('../svgJs/light/instoRequestSent-1.js').content, + dark: () => require('../svgJs/dark/instoRequestSent-1.js').content, + }, + instoSecurityKeyAuth: { + light: () => require('../svgJs/light/instoSecurityKeyAuth-0.js').content, + dark: () => require('../svgJs/dark/instoSecurityKeyAuth-0.js').content, + }, + instoStaking: { + light: () => require('../svgJs/light/instoStaking-0.js').content, + dark: () => require('../svgJs/dark/instoStaking-0.js').content, + }, + instoStakingMissedReturns: { + light: () => require('../svgJs/light/instoStakingMissedReturns-1.js').content, + dark: () => require('../svgJs/dark/instoStakingMissedReturns-1.js').content, + }, + instoWallet: { + light: () => require('../svgJs/light/instoWallet-0.js').content, + dark: () => require('../svgJs/dark/instoWallet-0.js').content, + }, + instoWalletSecurity: { + light: () => require('../svgJs/light/instoWalletSecurity-0.js').content, + dark: () => require('../svgJs/dark/instoWalletSecurity-0.js').content, + }, + instoWeb3MobileSetupStart: { + light: () => require('../svgJs/light/instoWeb3MobileSetupStart-0.js').content, + dark: () => require('../svgJs/dark/instoWeb3MobileSetupStart-0.js').content, + }, insufficientBalance: { light: () => require('../svgJs/light/insufficientBalance-5.js').content, dark: () => require('../svgJs/dark/insufficientBalance-5.js').content, @@ -1454,6 +1562,10 @@ const svgJsMap = { light: () => require('../svgJs/light/taxesDetails-3.js').content, dark: () => require('../svgJs/dark/taxesDetails-3.js').content, }, + test: { + light: () => require('../svgJs/light/test-0.js').content, + dark: () => require('../svgJs/dark/test-0.js').content, + }, tools: { light: () => require('../svgJs/light/tools-1.js').content, dark: () => require('../svgJs/dark/tools-1.js').content, @@ -1470,6 +1582,10 @@ const svgJsMap = { light: () => require('../svgJs/light/tradeImmediately-4.js').content, dark: () => require('../svgJs/dark/tradeImmediately-4.js').content, }, + tradingPerpetualsUsdc: { + light: () => require('../svgJs/light/tradingPerpetualsUsdc-0.js').content, + dark: () => require('../svgJs/dark/tradingPerpetualsUsdc-0.js').content, + }, tradingWithLeverage: { light: () => require('../svgJs/light/tradingWithLeverage-0.js').content, dark: () => require('../svgJs/dark/tradingWithLeverage-0.js').content, diff --git a/packages/illustrations/src/__generated__/heroSquare/data/versionMap.ts b/packages/illustrations/src/__generated__/heroSquare/data/versionMap.ts index a3a96a80f1..44edfdf5ee 100644 --- a/packages/illustrations/src/__generated__/heroSquare/data/versionMap.ts +++ b/packages/illustrations/src/__generated__/heroSquare/data/versionMap.ts @@ -415,6 +415,35 @@ const versionMap: Record = { tradingWithLeverage: 0, futuresExpire: 0, moreGains: 0, + test: 0, + borrowCoinsBtc: 0, + instoSecurityKeyAuth: 0, + instoEarnGlobe: 0, + instoGovernance: 0, + instoEthStakingUpsell: 0, + instoWalletSecurity: 0, + instoStakingMissedReturns: 1, + instoRequestSent: 1, + instoOnChain: 2, + instoPhoneUnknown: 0, + cryptoPortfolioUsdc: 0, + instoCoinbaseOneProtectedCrypto: 1, + instoDocumentSuccess: 1, + instoWeb3MobileSetupStart: 0, + instoAddBankAccount: 0, + instoWallet: 0, + instoKeyGenerationPending: 0, + instoEthStakingRewards: 0, + tradingPerpetualsUsdc: 0, + instoPrivateKey: 1, + instoPrimeStaking: 0, + instoStaking: 0, + instoOpenEmail: 1, + instoKeyGenerationComplete: 1, + instoAdd2Fa: 0, + instoEnableBiometrics: 0, + flipStable: 0, + cbmega: 0, }; export default versionMap; diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/borrowCoinsBtc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/borrowCoinsBtc-0.png new file mode 100644 index 0000000000..92d501d466 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/borrowCoinsBtc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/cbmega-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/cbmega-0.png new file mode 100644 index 0000000000..3d63ec1822 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/cbmega-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/cryptoPortfolioUsdc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/cryptoPortfolioUsdc-0.png new file mode 100644 index 0000000000..d2168ccdb0 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/cryptoPortfolioUsdc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/flipStable-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/flipStable-0.png new file mode 100644 index 0000000000..fd5b45c906 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/flipStable-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAdd2Fa-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAdd2Fa-0.png new file mode 100644 index 0000000000..5a0bf61b0a Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAdd2Fa-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAddBankAccount-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAddBankAccount-0.png new file mode 100644 index 0000000000..338aa23822 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoAddBankAccount-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoCoinbaseOneProtectedCrypto-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoCoinbaseOneProtectedCrypto-1.png new file mode 100644 index 0000000000..bfca477795 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoCoinbaseOneProtectedCrypto-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoDocumentSuccess-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoDocumentSuccess-1.png new file mode 100644 index 0000000000..c55b8ae04f Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoDocumentSuccess-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEarnGlobe-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEarnGlobe-0.png new file mode 100644 index 0000000000..470c35c9cb Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEarnGlobe-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEnableBiometrics-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEnableBiometrics-0.png new file mode 100644 index 0000000000..a1b28a0deb Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEnableBiometrics-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingRewards-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingRewards-0.png new file mode 100644 index 0000000000..24f51f39e5 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingUpsell-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingUpsell-0.png new file mode 100644 index 0000000000..c0ecb3f612 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoEthStakingUpsell-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoGovernance-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoGovernance-0.png new file mode 100644 index 0000000000..ed75351aff Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoGovernance-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationComplete-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationComplete-1.png new file mode 100644 index 0000000000..883a1962f2 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationComplete-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationPending-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationPending-0.png new file mode 100644 index 0000000000..02e802053e Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoKeyGenerationPending-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOnChain-2.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOnChain-2.png new file mode 100644 index 0000000000..7c52055dd5 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOnChain-2.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOpenEmail-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOpenEmail-1.png new file mode 100644 index 0000000000..d16ec88d24 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoOpenEmail-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPhoneUnknown-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPhoneUnknown-0.png new file mode 100644 index 0000000000..dc5910c52e Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPhoneUnknown-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrimeStaking-0.png new file mode 100644 index 0000000000..723af18378 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrivateKey-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrivateKey-1.png new file mode 100644 index 0000000000..01968c3a0e Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoPrivateKey-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoRequestSent-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoRequestSent-1.png new file mode 100644 index 0000000000..146ad33135 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoRequestSent-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoSecurityKeyAuth-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoSecurityKeyAuth-0.png new file mode 100644 index 0000000000..ebaab89602 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoSecurityKeyAuth-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStaking-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStaking-0.png new file mode 100644 index 0000000000..7cbfb0c90d Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStakingMissedReturns-1.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStakingMissedReturns-1.png new file mode 100644 index 0000000000..cc96662e17 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoStakingMissedReturns-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWallet-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWallet-0.png new file mode 100644 index 0000000000..324a32cd78 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWalletSecurity-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWalletSecurity-0.png new file mode 100644 index 0000000000..272c8c6625 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWalletSecurity-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWeb3MobileSetupStart-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWeb3MobileSetupStart-0.png new file mode 100644 index 0000000000..fc6d3b8879 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/instoWeb3MobileSetupStart-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/test-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/test-0.png new file mode 100644 index 0000000000..32e8c4525a Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/test-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/dark/tradingPerpetualsUsdc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/dark/tradingPerpetualsUsdc-0.png new file mode 100644 index 0000000000..1599125f8b Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/dark/tradingPerpetualsUsdc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/borrowCoinsBtc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/borrowCoinsBtc-0.png new file mode 100644 index 0000000000..92d501d466 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/borrowCoinsBtc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/cbmega-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/cbmega-0.png new file mode 100644 index 0000000000..ccb4c4bed6 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/cbmega-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/cryptoPortfolioUsdc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/cryptoPortfolioUsdc-0.png new file mode 100644 index 0000000000..4b5d9fb28a Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/cryptoPortfolioUsdc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/flipStable-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/flipStable-0.png new file mode 100644 index 0000000000..1b6726de50 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/flipStable-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoAdd2Fa-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoAdd2Fa-0.png new file mode 100644 index 0000000000..00438202f7 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoAdd2Fa-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoAddBankAccount-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoAddBankAccount-0.png new file mode 100644 index 0000000000..3f2b927919 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoAddBankAccount-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoCoinbaseOneProtectedCrypto-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoCoinbaseOneProtectedCrypto-1.png new file mode 100644 index 0000000000..d1a6a46362 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoCoinbaseOneProtectedCrypto-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoDocumentSuccess-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoDocumentSuccess-1.png new file mode 100644 index 0000000000..365314f6d8 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoDocumentSuccess-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoEarnGlobe-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEarnGlobe-0.png new file mode 100644 index 0000000000..dd1eabb410 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEarnGlobe-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoEnableBiometrics-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEnableBiometrics-0.png new file mode 100644 index 0000000000..8b71e3c9ac Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEnableBiometrics-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingRewards-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingRewards-0.png new file mode 100644 index 0000000000..261e77a97f Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingUpsell-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingUpsell-0.png new file mode 100644 index 0000000000..b571aea96b Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoEthStakingUpsell-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoGovernance-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoGovernance-0.png new file mode 100644 index 0000000000..93792279a1 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoGovernance-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationComplete-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationComplete-1.png new file mode 100644 index 0000000000..4af4d29722 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationComplete-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationPending-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationPending-0.png new file mode 100644 index 0000000000..0ea65383c6 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoKeyGenerationPending-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoOnChain-2.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoOnChain-2.png new file mode 100644 index 0000000000..4569698c0d Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoOnChain-2.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoOpenEmail-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoOpenEmail-1.png new file mode 100644 index 0000000000..19281330d8 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoOpenEmail-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoPhoneUnknown-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPhoneUnknown-0.png new file mode 100644 index 0000000000..f5f3eb3412 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPhoneUnknown-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrimeStaking-0.png new file mode 100644 index 0000000000..3483a601a9 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrivateKey-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrivateKey-1.png new file mode 100644 index 0000000000..fb11400b27 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoPrivateKey-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoRequestSent-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoRequestSent-1.png new file mode 100644 index 0000000000..91092cd74f Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoRequestSent-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoSecurityKeyAuth-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoSecurityKeyAuth-0.png new file mode 100644 index 0000000000..af24e34376 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoSecurityKeyAuth-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoStaking-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoStaking-0.png new file mode 100644 index 0000000000..fc35505a89 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoStakingMissedReturns-1.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoStakingMissedReturns-1.png new file mode 100644 index 0000000000..462826f22d Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoStakingMissedReturns-1.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoWallet-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWallet-0.png new file mode 100644 index 0000000000..ee9446d528 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoWalletSecurity-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWalletSecurity-0.png new file mode 100644 index 0000000000..c1d3a7cdea Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWalletSecurity-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/instoWeb3MobileSetupStart-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWeb3MobileSetupStart-0.png new file mode 100644 index 0000000000..7908262e1f Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/instoWeb3MobileSetupStart-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/test-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/test-0.png new file mode 100644 index 0000000000..502524c5e2 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/test-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/png/light/tradingPerpetualsUsdc-0.png b/packages/illustrations/src/__generated__/heroSquare/png/light/tradingPerpetualsUsdc-0.png new file mode 100644 index 0000000000..a6f416e4d0 Binary files /dev/null and b/packages/illustrations/src/__generated__/heroSquare/png/light/tradingPerpetualsUsdc-0.png differ diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/borrowCoinsBtc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/borrowCoinsBtc-0.svg new file mode 100644 index 0000000000..f287a3c03c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/borrowCoinsBtc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/cbmega-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/cbmega-0.svg new file mode 100644 index 0000000000..dba5e93e02 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/cbmega-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/cryptoPortfolioUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/cryptoPortfolioUsdc-0.svg new file mode 100644 index 0000000000..8cd35f0252 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/cryptoPortfolioUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/flipStable-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/flipStable-0.svg new file mode 100644 index 0000000000..7d44d31a0e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/flipStable-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAdd2Fa-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAdd2Fa-0.svg new file mode 100644 index 0000000000..5accfafcc2 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAdd2Fa-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAddBankAccount-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAddBankAccount-0.svg new file mode 100644 index 0000000000..7bfabd4a5e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoAddBankAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoCoinbaseOneProtectedCrypto-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoCoinbaseOneProtectedCrypto-1.svg new file mode 100644 index 0000000000..b377ed5fc0 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoCoinbaseOneProtectedCrypto-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoDocumentSuccess-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoDocumentSuccess-1.svg new file mode 100644 index 0000000000..17f01fa7fc --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoDocumentSuccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEarnGlobe-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEarnGlobe-0.svg new file mode 100644 index 0000000000..02aa24de9e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEarnGlobe-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEnableBiometrics-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEnableBiometrics-0.svg new file mode 100644 index 0000000000..44805b1a13 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEnableBiometrics-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..a5446d6fbd --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingUpsell-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingUpsell-0.svg new file mode 100644 index 0000000000..eb95d3db22 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoEthStakingUpsell-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoGovernance-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoGovernance-0.svg new file mode 100644 index 0000000000..24cf79804a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoGovernance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationComplete-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationComplete-1.svg new file mode 100644 index 0000000000..f814b0ea52 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationComplete-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationPending-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationPending-0.svg new file mode 100644 index 0000000000..6c4768ebac --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoKeyGenerationPending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOnChain-2.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOnChain-2.svg new file mode 100644 index 0000000000..be2a7ce9a2 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOnChain-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOpenEmail-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOpenEmail-1.svg new file mode 100644 index 0000000000..51170a9eef --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoOpenEmail-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPhoneUnknown-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPhoneUnknown-0.svg new file mode 100644 index 0000000000..7f420cbdb9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPhoneUnknown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..02eebfc301 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrivateKey-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrivateKey-1.svg new file mode 100644 index 0000000000..61dd752a21 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoPrivateKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoRequestSent-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoRequestSent-1.svg new file mode 100644 index 0000000000..5b4d776c63 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoRequestSent-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoSecurityKeyAuth-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoSecurityKeyAuth-0.svg new file mode 100644 index 0000000000..02bbd3f9d9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoSecurityKeyAuth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStaking-0.svg new file mode 100644 index 0000000000..a99812035b --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStakingMissedReturns-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStakingMissedReturns-1.svg new file mode 100644 index 0000000000..2e39bcd493 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoStakingMissedReturns-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWallet-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWallet-0.svg new file mode 100644 index 0000000000..83df4fd18f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWalletSecurity-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWalletSecurity-0.svg new file mode 100644 index 0000000000..bb3ffdf1d8 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWalletSecurity-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWeb3MobileSetupStart-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWeb3MobileSetupStart-0.svg new file mode 100644 index 0000000000..974f2913ba --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/instoWeb3MobileSetupStart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/test-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/test-0.svg new file mode 100644 index 0000000000..d0d00a1755 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/test-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/dark/tradingPerpetualsUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/dark/tradingPerpetualsUsdc-0.svg new file mode 100644 index 0000000000..912deff5ea --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/dark/tradingPerpetualsUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/borrowCoinsBtc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/borrowCoinsBtc-0.svg new file mode 100644 index 0000000000..f287a3c03c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/borrowCoinsBtc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/cbmega-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/cbmega-0.svg new file mode 100644 index 0000000000..c3d81346f9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/cbmega-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/cryptoPortfolioUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/cryptoPortfolioUsdc-0.svg new file mode 100644 index 0000000000..6f399175b2 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/cryptoPortfolioUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/flipStable-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/flipStable-0.svg new file mode 100644 index 0000000000..967e170db6 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/flipStable-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAdd2Fa-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAdd2Fa-0.svg new file mode 100644 index 0000000000..d6c6cbe43a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAdd2Fa-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAddBankAccount-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAddBankAccount-0.svg new file mode 100644 index 0000000000..cfcda32816 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoAddBankAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoCoinbaseOneProtectedCrypto-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoCoinbaseOneProtectedCrypto-1.svg new file mode 100644 index 0000000000..d6a66e86a8 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoCoinbaseOneProtectedCrypto-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoDocumentSuccess-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoDocumentSuccess-1.svg new file mode 100644 index 0000000000..121d881b16 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoDocumentSuccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEarnGlobe-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEarnGlobe-0.svg new file mode 100644 index 0000000000..85aab5d385 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEarnGlobe-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEnableBiometrics-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEnableBiometrics-0.svg new file mode 100644 index 0000000000..9430cf995a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEnableBiometrics-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..f3dafb583d --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingUpsell-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingUpsell-0.svg new file mode 100644 index 0000000000..28719fe656 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoEthStakingUpsell-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoGovernance-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoGovernance-0.svg new file mode 100644 index 0000000000..37890bfafd --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoGovernance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationComplete-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationComplete-1.svg new file mode 100644 index 0000000000..d9a290a59d --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationComplete-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationPending-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationPending-0.svg new file mode 100644 index 0000000000..9773a01900 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoKeyGenerationPending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOnChain-2.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOnChain-2.svg new file mode 100644 index 0000000000..b7fedddf12 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOnChain-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOpenEmail-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOpenEmail-1.svg new file mode 100644 index 0000000000..dce47ee96c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoOpenEmail-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPhoneUnknown-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPhoneUnknown-0.svg new file mode 100644 index 0000000000..66afc26831 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPhoneUnknown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..a70e203d40 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrivateKey-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrivateKey-1.svg new file mode 100644 index 0000000000..2803610f67 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoPrivateKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoRequestSent-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoRequestSent-1.svg new file mode 100644 index 0000000000..979bebde1f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoRequestSent-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoSecurityKeyAuth-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoSecurityKeyAuth-0.svg new file mode 100644 index 0000000000..847a755048 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoSecurityKeyAuth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStaking-0.svg new file mode 100644 index 0000000000..af7606d0db --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStakingMissedReturns-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStakingMissedReturns-1.svg new file mode 100644 index 0000000000..5f45db47a7 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoStakingMissedReturns-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWallet-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWallet-0.svg new file mode 100644 index 0000000000..165fd0341c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWalletSecurity-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWalletSecurity-0.svg new file mode 100644 index 0000000000..1cac777994 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWalletSecurity-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWeb3MobileSetupStart-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWeb3MobileSetupStart-0.svg new file mode 100644 index 0000000000..d464d0241f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/instoWeb3MobileSetupStart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/test-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/test-0.svg new file mode 100644 index 0000000000..0244cbbe65 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/test-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/light/tradingPerpetualsUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/light/tradingPerpetualsUsdc-0.svg new file mode 100644 index 0000000000..880e4a3795 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/light/tradingPerpetualsUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/borrowCoinsBtc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/borrowCoinsBtc-0.svg new file mode 100644 index 0000000000..f562c40970 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/borrowCoinsBtc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cbmega-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cbmega-0.svg new file mode 100644 index 0000000000..385a74f115 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cbmega-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cryptoPortfolioUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cryptoPortfolioUsdc-0.svg new file mode 100644 index 0000000000..2bef45fc64 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/cryptoPortfolioUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/flipStable-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/flipStable-0.svg new file mode 100644 index 0000000000..bf066e1936 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/flipStable-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAdd2Fa-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAdd2Fa-0.svg new file mode 100644 index 0000000000..049e3d47ae --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAdd2Fa-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAddBankAccount-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAddBankAccount-0.svg new file mode 100644 index 0000000000..5ea6af5572 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoAddBankAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoCoinbaseOneProtectedCrypto-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoCoinbaseOneProtectedCrypto-1.svg new file mode 100644 index 0000000000..8a0367938e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoCoinbaseOneProtectedCrypto-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoDocumentSuccess-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoDocumentSuccess-1.svg new file mode 100644 index 0000000000..d91ac9b9e2 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoDocumentSuccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEarnGlobe-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEarnGlobe-0.svg new file mode 100644 index 0000000000..3d0637fbac --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEarnGlobe-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEnableBiometrics-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEnableBiometrics-0.svg new file mode 100644 index 0000000000..486eba6790 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEnableBiometrics-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..3dbc37ea55 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingUpsell-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingUpsell-0.svg new file mode 100644 index 0000000000..aa5266ccf9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoEthStakingUpsell-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoGovernance-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoGovernance-0.svg new file mode 100644 index 0000000000..1268b83519 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoGovernance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationComplete-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationComplete-1.svg new file mode 100644 index 0000000000..ef6fa3d110 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationComplete-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationPending-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationPending-0.svg new file mode 100644 index 0000000000..7b002b7633 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoKeyGenerationPending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOnChain-2.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOnChain-2.svg new file mode 100644 index 0000000000..8fedde1e45 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOnChain-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOpenEmail-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOpenEmail-1.svg new file mode 100644 index 0000000000..00828ed837 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoOpenEmail-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPhoneUnknown-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPhoneUnknown-0.svg new file mode 100644 index 0000000000..62cc0a3277 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPhoneUnknown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..b8cfdd04dc --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrivateKey-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrivateKey-1.svg new file mode 100644 index 0000000000..54e51b6f93 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoPrivateKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoRequestSent-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoRequestSent-1.svg new file mode 100644 index 0000000000..4711aaf55c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoRequestSent-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoSecurityKeyAuth-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoSecurityKeyAuth-0.svg new file mode 100644 index 0000000000..bf33109590 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoSecurityKeyAuth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStaking-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStaking-0.svg new file mode 100644 index 0000000000..c1b881b9ca --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStakingMissedReturns-1.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStakingMissedReturns-1.svg new file mode 100644 index 0000000000..1049e009a0 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoStakingMissedReturns-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWallet-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWallet-0.svg new file mode 100644 index 0000000000..429277a85f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWalletSecurity-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWalletSecurity-0.svg new file mode 100644 index 0000000000..f4d1f895d4 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWalletSecurity-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWeb3MobileSetupStart-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWeb3MobileSetupStart-0.svg new file mode 100644 index 0000000000..12dd987643 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/instoWeb3MobileSetupStart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/test-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/test-0.svg new file mode 100644 index 0000000000..6dd1d93f84 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/test-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svg/themeable/tradingPerpetualsUsdc-0.svg b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/tradingPerpetualsUsdc-0.svg new file mode 100644 index 0000000000..a88c307a56 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svg/themeable/tradingPerpetualsUsdc-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/borrowCoinsBtc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/borrowCoinsBtc-0.js new file mode 100644 index 0000000000..195c5f3b1e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/borrowCoinsBtc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cbmega-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cbmega-0.js new file mode 100644 index 0000000000..d0995daf5c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cbmega-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cryptoPortfolioUsdc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cryptoPortfolioUsdc-0.js new file mode 100644 index 0000000000..2b83b784eb --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/cryptoPortfolioUsdc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/flipStable-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/flipStable-0.js new file mode 100644 index 0000000000..b77832141a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/flipStable-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAdd2Fa-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAdd2Fa-0.js new file mode 100644 index 0000000000..d48033e067 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAdd2Fa-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAddBankAccount-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAddBankAccount-0.js new file mode 100644 index 0000000000..a9981a017b --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoAddBankAccount-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoCoinbaseOneProtectedCrypto-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoCoinbaseOneProtectedCrypto-1.js new file mode 100644 index 0000000000..3d0e41de14 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoCoinbaseOneProtectedCrypto-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoDocumentSuccess-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoDocumentSuccess-1.js new file mode 100644 index 0000000000..d408308efe --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoDocumentSuccess-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEarnGlobe-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEarnGlobe-0.js new file mode 100644 index 0000000000..4e167d24c5 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEarnGlobe-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEnableBiometrics-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEnableBiometrics-0.js new file mode 100644 index 0000000000..3c2a55cd74 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEnableBiometrics-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingRewards-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingRewards-0.js new file mode 100644 index 0000000000..1e578f14d6 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingUpsell-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingUpsell-0.js new file mode 100644 index 0000000000..9f13b869d1 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoEthStakingUpsell-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoGovernance-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoGovernance-0.js new file mode 100644 index 0000000000..22dfa0ad14 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoGovernance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationComplete-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationComplete-1.js new file mode 100644 index 0000000000..8e5c734622 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationComplete-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationPending-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationPending-0.js new file mode 100644 index 0000000000..c8b6b66603 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoKeyGenerationPending-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOnChain-2.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOnChain-2.js new file mode 100644 index 0000000000..24bb23c737 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOnChain-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOpenEmail-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOpenEmail-1.js new file mode 100644 index 0000000000..ea56c13a33 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoOpenEmail-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPhoneUnknown-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPhoneUnknown-0.js new file mode 100644 index 0000000000..00c7c810f4 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPhoneUnknown-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrimeStaking-0.js new file mode 100644 index 0000000000..5574eb5951 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrivateKey-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrivateKey-1.js new file mode 100644 index 0000000000..0b73e9eb10 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoPrivateKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoRequestSent-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoRequestSent-1.js new file mode 100644 index 0000000000..0de8235267 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoRequestSent-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoSecurityKeyAuth-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoSecurityKeyAuth-0.js new file mode 100644 index 0000000000..9d31492ff6 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoSecurityKeyAuth-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStaking-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStaking-0.js new file mode 100644 index 0000000000..389bbe3408 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStakingMissedReturns-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStakingMissedReturns-1.js new file mode 100644 index 0000000000..09a02b1929 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoStakingMissedReturns-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWallet-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWallet-0.js new file mode 100644 index 0000000000..85d20d3c3d --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWalletSecurity-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWalletSecurity-0.js new file mode 100644 index 0000000000..4a248042ab --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWalletSecurity-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWeb3MobileSetupStart-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWeb3MobileSetupStart-0.js new file mode 100644 index 0000000000..a26061917d --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/instoWeb3MobileSetupStart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/test-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/test-0.js new file mode 100644 index 0000000000..b2e10648a7 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/test-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/tradingPerpetualsUsdc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/tradingPerpetualsUsdc-0.js new file mode 100644 index 0000000000..f56b566fe8 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/dark/tradingPerpetualsUsdc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/borrowCoinsBtc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/borrowCoinsBtc-0.js new file mode 100644 index 0000000000..195c5f3b1e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/borrowCoinsBtc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cbmega-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cbmega-0.js new file mode 100644 index 0000000000..96c6a5c07a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cbmega-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cryptoPortfolioUsdc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cryptoPortfolioUsdc-0.js new file mode 100644 index 0000000000..a46686b5ee --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/cryptoPortfolioUsdc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/flipStable-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/flipStable-0.js new file mode 100644 index 0000000000..ad09a0621d --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/flipStable-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAdd2Fa-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAdd2Fa-0.js new file mode 100644 index 0000000000..6e2f770a87 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAdd2Fa-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAddBankAccount-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAddBankAccount-0.js new file mode 100644 index 0000000000..87ee76b63e --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoAddBankAccount-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoCoinbaseOneProtectedCrypto-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoCoinbaseOneProtectedCrypto-1.js new file mode 100644 index 0000000000..641fd73f48 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoCoinbaseOneProtectedCrypto-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoDocumentSuccess-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoDocumentSuccess-1.js new file mode 100644 index 0000000000..344fd362cf --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoDocumentSuccess-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEarnGlobe-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEarnGlobe-0.js new file mode 100644 index 0000000000..5d37cd84c4 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEarnGlobe-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEnableBiometrics-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEnableBiometrics-0.js new file mode 100644 index 0000000000..56406151c5 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEnableBiometrics-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingRewards-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingRewards-0.js new file mode 100644 index 0000000000..29b75fbf5b --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingUpsell-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingUpsell-0.js new file mode 100644 index 0000000000..f7e48041e7 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoEthStakingUpsell-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoGovernance-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoGovernance-0.js new file mode 100644 index 0000000000..97292e612c --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoGovernance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationComplete-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationComplete-1.js new file mode 100644 index 0000000000..6fbfad3f5f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationComplete-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationPending-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationPending-0.js new file mode 100644 index 0000000000..df5f2cf329 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoKeyGenerationPending-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOnChain-2.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOnChain-2.js new file mode 100644 index 0000000000..6ed06fcf2a --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOnChain-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOpenEmail-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOpenEmail-1.js new file mode 100644 index 0000000000..46ad268ba9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoOpenEmail-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPhoneUnknown-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPhoneUnknown-0.js new file mode 100644 index 0000000000..48ee7679d9 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPhoneUnknown-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrimeStaking-0.js new file mode 100644 index 0000000000..14c2e44952 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrivateKey-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrivateKey-1.js new file mode 100644 index 0000000000..7e81b53efa --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoPrivateKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoRequestSent-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoRequestSent-1.js new file mode 100644 index 0000000000..d99ba3b9de --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoRequestSent-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoSecurityKeyAuth-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoSecurityKeyAuth-0.js new file mode 100644 index 0000000000..5082a4eb7b --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoSecurityKeyAuth-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStaking-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStaking-0.js new file mode 100644 index 0000000000..259e96975f --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStakingMissedReturns-1.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStakingMissedReturns-1.js new file mode 100644 index 0000000000..11e49ffc4b --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoStakingMissedReturns-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWallet-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWallet-0.js new file mode 100644 index 0000000000..b6e20e15c0 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWalletSecurity-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWalletSecurity-0.js new file mode 100644 index 0000000000..67582666d2 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWalletSecurity-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWeb3MobileSetupStart-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWeb3MobileSetupStart-0.js new file mode 100644 index 0000000000..cf70f38906 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/instoWeb3MobileSetupStart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/test-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/test-0.js new file mode 100644 index 0000000000..2c5f636122 --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/test-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/svgJs/light/tradingPerpetualsUsdc-0.js b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/tradingPerpetualsUsdc-0.js new file mode 100644 index 0000000000..fac8d678be --- /dev/null +++ b/packages/illustrations/src/__generated__/heroSquare/svgJs/light/tradingPerpetualsUsdc-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/heroSquare/types/HeroSquareName.ts b/packages/illustrations/src/__generated__/heroSquare/types/HeroSquareName.ts index 9c1ac3acde..b97904b92c 100644 --- a/packages/illustrations/src/__generated__/heroSquare/types/HeroSquareName.ts +++ b/packages/illustrations/src/__generated__/heroSquare/types/HeroSquareName.ts @@ -54,6 +54,7 @@ export type HeroSquareName = | 'blockchain' | 'borrow' | 'borrowCoins' + | 'borrowCoinsBtc' | 'borrowWallet' | 'brdGift' | 'bridge' @@ -74,6 +75,7 @@ export type HeroSquareName = | 'cbbtc' | 'cbdoge' | 'cbltc' + | 'cbmega' | 'cbxrp' | 'chickenFishSystemError' | 'claimCryptoUsername' @@ -130,6 +132,7 @@ export type HeroSquareName = | 'cryptoEconomy' | 'cryptoForBeginners' | 'cryptoPortfolio' + | 'cryptoPortfolioUsdc' | 'cryptoWallet' | 'currencyPairs' | 'dappsArts' @@ -201,6 +204,7 @@ export type HeroSquareName = | 'fiat' | 'fileYourCryptoTaxes' | 'fileYourCryptoTaxesCheck' + | 'flipStable' | 'focusLimitOrders' | 'freeBtc' | 'futures' @@ -230,6 +234,29 @@ export type HeroSquareName = | 'indexer' | 'innovation' | 'instantUnstakingClock' + | 'instoAdd2Fa' + | 'instoAddBankAccount' + | 'instoCoinbaseOneProtectedCrypto' + | 'instoDocumentSuccess' + | 'instoEarnGlobe' + | 'instoEnableBiometrics' + | 'instoEthStakingRewards' + | 'instoEthStakingUpsell' + | 'instoGovernance' + | 'instoKeyGenerationComplete' + | 'instoKeyGenerationPending' + | 'instoOnChain' + | 'instoOpenEmail' + | 'instoPhoneUnknown' + | 'instoPrimeStaking' + | 'instoPrivateKey' + | 'instoRequestSent' + | 'instoSecurityKeyAuth' + | 'instoStaking' + | 'instoStakingMissedReturns' + | 'instoWallet' + | 'instoWalletSecurity' + | 'instoWeb3MobileSetupStart' | 'insufficientBalance' | 'insuranceProtection' | 'invest' @@ -366,10 +393,12 @@ export type HeroSquareName = | 'sustainable' | 'switchAdvancedToSimpleTrading' | 'taxesDetails' + | 'test' | 'tools' | 'tradeGeneral' | 'tradeHistory' | 'tradeImmediately' + | 'tradingPerpetualsUsdc' | 'tradingWithLeverage' | 'transactionLimit' | 'trendingHotAssets' diff --git a/packages/illustrations/src/__generated__/pictogram/data/descriptionMap.ts b/packages/illustrations/src/__generated__/pictogram/data/descriptionMap.ts index 3b6f847798..bbc08200b8 100644 --- a/packages/illustrations/src/__generated__/pictogram/data/descriptionMap.ts +++ b/packages/illustrations/src/__generated__/pictogram/data/descriptionMap.ts @@ -9,1186 +9,869 @@ * The search query filters the shown illustrations based on matches with name or description. */ const descriptionMap: Record = { - check: [ - 'reviewAndAdd', - 'walletSuccess', - 'timingCheck', - 'manageWeb3SignersAcct', - 'cardSuccess', - 'listingFees', - 'done', - 'idVerification', - 'mobileSuccess', - 'checkmark', - 'takeQuiz', - 'tokenBaskets', - 'enableVoting', - 'coldStorageCheck', - 'delegate', - 'completeQuiz', - 'taxesArrangement', - ], - review: ['reviewAndAdd', 'positiveReviews', 'notificationHubAnalysis'], - monitor: ['reviewAndAdd', 'priceTracking', 'pluginBrowser'], - search: ['reviewAndAdd', 'priceTracking', 'newUserChecklistVerifyId', 'explore', 'noNftFound'], - 'magnifying glass': ['reviewAndAdd', 'priceTracking', 'newUserChecklistVerifyId', 'explore'], - look: ['reviewAndAdd', 'priceTracking', 'explore'], - more: [ - 'reviewAndAdd', - 'priceTracking', - 'explore', - 'coinbaseOneEarnCoinsLogo', - 'multipleAssets', - 'add', - 'moreThanBitcoin', - 'earnCoins', - 'coinbaseOneEarnCoins', - ], - add: [ - 'reviewAndAdd', - 'commerceInvoice', - 'addPayment', - 'addPhone', - 'addToWatchlist', - 'addWallet', - 'coinbaseOneEarnCoinsLogo', - 'mintedNft', - 'commerceCheckout', - 'addCard', - 'multipleAssets', - 'add', - 'newUserChecklistBuyCrypto', - 'moreThanBitcoin', - 'newUserChecklistCompleteAccount', - 'earnCoins', - 'coinbaseOneEarnCoins', - 'selectAddNft', - ], - '➕': [ - 'reviewAndAdd', - 'commerceInvoice', - 'addPayment', - 'addPhone', - 'addToWatchlist', - 'addWallet', - 'coinbaseOneEarnCoinsLogo', - 'commerceCheckout', - 'addCard', - 'multipleAssets', - 'add', - 'newUserChecklistBuyCrypto', - 'moreThanBitcoin', - 'earnCoins', - 'coinbaseOneEarnCoins', - ], - '🔎': ['reviewAndAdd', 'priceTracking', 'newUserChecklistVerifyId', 'explore'], - '🔍': ['reviewAndAdd', 'priceTracking', 'newUserChecklistVerifyId', 'explore'], - '🕵️': [ - 'reviewAndAdd', - 'priceTracking', - 'newUserChecklistVerifyId', - 'explore', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - '🕵️‍♀️': [ - 'reviewAndAdd', - 'priceTracking', - 'newUserChecklistVerifyId', - 'explore', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - '🕵️‍♂️': [ - 'reviewAndAdd', - 'priceTracking', - 'newUserChecklistVerifyId', - 'explore', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - interesting: ['predictionMarkets'], - sparkle: [ - 'predictionMarkets', - 'trusted', - 'priceTracking', - 'coinbaseOneTrusted', - 'explore', - 'learn', - 'cryptoCard', - 'easyToUse', - 'gem', - 'target', - 'bundle', - 'sparkleCoinbaseOne', - 'barChart', - 'newUserChecklistBuyCrypto', - 'notifications', - 'newUserChecklistCompleteAccount', - 'moneyCrypto', - ], - 'crystal ball': ['predictionMarkets'], - psychic: ['predictionMarkets'], - forecast: ['predictionMarkets'], - foretell: ['predictionMarkets'], - foresee: ['predictionMarkets'], - '✨': [ - 'predictionMarkets', - 'trusted', - 'priceTracking', - 'coinbaseOneTrusted', - 'explore', - 'addToWatchlist', - 'learn', - 'cryptoCard', - 'noNftFound', - 'easyToUse', - 'gem', - 'target', - 'bundle', - 'mintedNft', - 'bigBtcSend', - 'barChart', - 'newUserChecklistBuyCrypto', - 'notifications', - 'newUserChecklistCompleteAccount', - 'apartOfDropsNft', - 'moneyCrypto', - 'selectAddNft', - ], - '❇️': [ - 'predictionMarkets', - 'trusted', - 'priceTracking', - 'coinbaseOneTrusted', - 'explore', - 'addToWatchlist', - 'learn', - 'cryptoCard', - 'easyToUse', - 'gem', - 'target', - 'bundle', - 'barChart', - 'newUserChecklistBuyCrypto', - 'notifications', - 'newUserChecklistCompleteAccount', - 'moneyCrypto', - ], - '🧐': ['predictionMarkets', 'takeQuiz'], - '🔮': ['predictionMarkets'], - '🧙‍♀️': [ - 'predictionMarkets', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - '🧙': [ - 'predictionMarkets', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - '🧙‍♂️': [ - 'predictionMarkets', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', - ], - '🪄': ['predictionMarkets'], - avatar: [ - 'avatarHj', - 'avatarIi', - 'avatarJi', - 'avatarIe', - 'avatarIf', - 'avatarEc', - 'avatarFe', - 'avatarFi', - 'avatarJe', - 'avatarHg', - 'avatarJf', - 'avatarHd', - 'avatarAa', - 'avatarEb', - 'avatarIh', - 'avatarCc', - 'avatarHi', - 'avatarIb', - 'avatarIj', - 'avatarHa', - 'avatarHh', - 'avatarDj', - 'avatarJb', - 'avatarIg', - 'avatarIa', - 'avatarIc', - 'avatarDe', - 'avatarHc', - 'avatarJj', - 'avatarCd', - 'avatarHe', - 'avatarHf', - 'avatarJa', - 'avatarJd', - 'avatarJg', - 'avatarBh', - 'avatarBa', - 'avatarCf', - 'avatarFb', - 'avatarBf', - 'avatarBe', - 'avatarDf', - 'avatarEi', - 'driversLicense', - 'ssnCard', - 'avatarDh', - 'avatarFg', - 'avatarDi', - 'avatarHb', - 'avatarJh', - 'avatarGb', - 'avatarCi', - 'avatarId', - 'avatarCh', - 'avatarGc', - 'avatarAc', - 'avatarBj', - 'avatarBi', - 'avatarGe', - 'avatarEf', - 'avatarAj', - 'avatarAg', - 'agent', - 'genericCountryIDCard', - 'avatarCa', - 'avatarBc', - 'avatarGd', - 'avatarCe', - 'avatarGf', - 'avatarAd', - 'avatarGi', - 'avatarAi', - 'avatarEe', - 'avatarEj', - 'avatarEd', - 'avatarDd', - 'avatarGh', - 'avatarAb', - 'avatarFc', - 'avatarBg', - 'avatarGa', - 'avatarCg', - 'avatarFd', - 'avatarCb', - 'driversLicenseWheel', - 'avatarGj', - 'avatarFh', - 'avatarGg', - 'avatarJc', - 'avatarDb', - 'avatarFf', - 'avatarAe', - 'avatarFj', - 'avatarEh', - 'nftAvatar', - 'avatarBd', - 'avatarDa', - 'avatarDg', - 'avatarEg', - 'avatarBb', - 'avatarFa', - 'avatarCj', - 'avatarAh', - 'avatarAf', - 'avatarDc', - 'avatarEa', - ], - chart: [ - 'candleSticksGraph', - 'apyInterest', - 'riskStaking', - 'coinbaseOneEarn', - 'advancedTradingDesktop', - 'notificationHubSocial', - 'trading', - 'notificationHubPortfolio', - ], - indicator: ['candleSticksGraph'], - candles: ['candleSticksGraph', 'advancedTradingDesktop', 'trading'], - green: ['candleSticksGraph', 'done'], - red: [ - 'candleSticksGraph', - 'walletError', - 'strongWarning', - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', - ], '': [ + 'derivativesNavigation', + 'coinbaseLogoNavigation', + 'walletLogoNavigation', + 'delegateNavigation', 'payNavigation', - 'cb1BankTransfers', - 'protectionPlan', - 'participateNavigation', - 'baseCoinStack', - 'venturesNavigation', 'accountsNavigation', - 'custodyNavigation', - 'baseConnectApps', - 'advancedTradingNavigation', - 'cardNavigation', - 'baseEarnedBadge', - 'baseCoinCryptoSmall', - 'privateClientNavigation', - 'baseSendSmall', - 'baseLoadingSmall', - 'cloudNavigation', - 'baseGlobe', - 'baseChatBubbleHeart', - 'baseTile', - 'baseRockon', + 'taxCenterNavigation', + 'directDepositNavigation', 'proNavigation', - 'baseDecentralizationSmall', - 'exchangeNavigation', - 'helpCenterNavigation', - 'baseMessaging', - 'peerToPeer', - 'earnNavigation', - 'faucetNavigation', - 'basePiechartSmall', 'connectNavigation', - 'dataMarketplaceNavigation', - 'cardBlocked', - 'feesRestriction', - 'baseNetworkSmall', - 'walletAsServiceNavigation', - 'signInNavigation', - 'coinFocus', - 'basePower', - 'baseCreatorCoin', - 'baseSmile', - 'derivativesNavigation', - 'directDepositNavigation', - 'baseSignin', + 'rosettaNavigation', 'walletLinkNavigation', + 'cloudNavigation', + 'signInNavigation', + 'queryTransactNavigation', + 'venturesNavigation', + 'participateNavigation', + 'privateClientNavigation', + 'assetHubNavigation', + 'decentralizedExchange', 'decentralizedWeb3', - 'baseRocket', - 'taxCenterNavigation', - 'instantUnstakingClock', - 'borrowNavigation', - 'baseEmptySmall', - 'decentralizationEverything', - 'assetEncryption', - 'economyGlobal', - 'browserMultiPlatform', - 'rosettaNavigation', 'earnGraph', - 'coinbaseOneProductInvestWeekly', - 'decentralizedExchange', - 'globalTransactions', - 'assetHubNavigation', + 'assetEncryption', + 'earnNavigation', + 'getStarted', 'miningCoins', + 'helpCenterNavigation', + 'coinFocus', + 'feesRestriction', + 'sendPaymentToOthers', + 'securityCoinShield', + 'globalTransactions', + 'decentralizationEverything', + 'borrowingLending', + 'globalConnections', + 'walletNavigation', + 'walletPassword', + 'cardNavigation', + 'custodyNavigation', 'holdingCoin', - 'bonusFivePercent', - 'coinbaseUnlockOffers', - 'baseTargetSmall', - 'baseLocationSmall', - 'baseNftSmall', - 'coinbaseLogoAdvancedBrand', - 'baseSecuritySmall', - 'basePeopleSmall', - 'getStarted', - 'queryTransactNavigation', + 'economyGlobal', + 'exchangeNavigation', 'nftNavigation', + 'protectionPlan', + 'collectionOfAssets', + 'primeNavigation', + 'advancedTradingNavigation', + 'borrowNavigation', + 'dataMarketplaceNavigation', + 'cardBlocked', + 'ethereumFocus', + 'assetMovement', + 'trading', + 'analyticsNavigation', + 'commerceNavigation', + 'investGraph', 'rewardsNavigation', + 'institutionalNavigation', + 'linkYourAccount', + 'learningRewardsNavigation', + 'faucetNavigation', + 'walletAsServiceNavigation', + 'baseLogoNavigation', + 'calculator', + 'peerToPeer', + 'cb1BankTransfers', + 'basePiechartSmall', + 'baseChartSmall', + 'basePaycoinSmall', + 'baseCheckSmall', + 'baseErrorButterflySmall', + 'baseMintNftSmall', + 'baseCoinCryptoSmall', 'baseConnectSmall', + 'basePeopleSmall', + 'baseLocationSmall', + 'baseNetworkSmall', + 'baseSecuritySmall', + 'baseErrorSmall', + 'baseDecentralizationSmall', + 'baseLoadingSmall', + 'baseCoinNetworkSmall', + 'baseTargetSmall', + 'baseEmptySmall', + 'baseSendSmall', + 'baseNftSmall', + 'baseDiamondSmall', 'baseDiamondTrophy', - 'sendPaymentToOthers', - 'walletPassword', - 'calculator', - 'assetMovement', - 'securityCoinShield', - 'globalConnections', + 'baseCoinStack', + 'baseConnectApps', + 'baseMessaging', + 'baseSignin', 'bonusTwoPercent', - 'baseLogoNavigation', - 'baseLayout', - 'linkYourAccount', - 'baseCoinStar', + 'bonusFivePercent', + 'instantUnstakingClock', + 'coinbaseOneProductInvestWeekly', + 'coinbaseLogoAdvancedBrand', + 'tokenSales', + 'coinbaseUnlockOffers', + 'baseLightningbolt', + 'baseChatBubbleHeart', + 'basePlant', + 'baseRockon', + 'baseFire', + 'baseCertificateStar', + 'baseMedal', + 'baseRocket', + 'baseGlobe', + 'baseGem', 'baseHandStar', + 'baseEarnedBadge', + 'baseAscend', 'baseConfetti', - 'baseGem', - 'baseSaved', + 'baseTile', + 'baseSmile', 'baseStack', - 'baseCertificateStar', - 'baseLightningbolt', - 'baseStar', - 'baseAscend', - 'baseRibbon', - 'baseExchange', - 'basePaycoinSmall', - 'walletLogoNavigation', - 'ethereumFocus', - 'baseMintNftSmall', - 'baseComet', 'baseComputer', - 'commerceNavigation', - 'walletNavigation', - 'baseCoinNetworkSmall', - 'trading', - 'delegateNavigation', 'baseDoor', - 'coinbaseLogoNavigation', - 'borrowingLending', - 'institutionalNavigation', - 'collectionOfAssets', - 'baseChartSmall', - 'primeNavigation', - 'robot', - 'learningRewardsNavigation', - 'tokenSales', + 'baseRibbon', + 'baseCreatorCoin', + 'basePower', + 'baseStar', + 'baseSaved', + 'baseCoinStar', + 'baseLayout', + 'baseExchange', + 'baseComet', 'crystalBallInsight', - 'investGraph', - 'analyticsNavigation', - 'baseCheckSmall', - 'baseErrorButterflySmall', - 'baseErrorSmall', - 'baseDiamondSmall', - 'basePlant', - 'baseFire', - 'baseMedal', - ], - calendar: [ - 'startToday', - 'recurringPurchases', - 'tryAgainLater', - 'calendarHighlight', - 'taxSeason', - 'calendar', - 'calendarCaution', - 'noAnnualFee', - ], - date: ['startToday', 'recurringPurchases', 'calendarHighlight', 'calendar'], - year: ['startToday', 'recurringPurchases', 'calendar'], - month: ['startToday', 'recurringPurchases', 'calendar'], - week: ['startToday', 'recurringPurchases', 'calendar'], - confirm: ['startToday', 'calendar'], - today: ['startToday', 'videoCalendar'], - present: ['startToday', 'giftbox'], - schedule: ['startToday', 'recurringPurchases', 'taxSeason', 'calendar', 'calendarCaution'], - '📆': [ - 'startToday', - 'recurringPurchases', - 'tryAgainLater', - 'taxSeason', - 'calendar', - 'noAnnualFee', + 'robot', + 'pieChartWithArrow', + 'pieChartWithArrowBlue', + 'instoEarnGraph', + 'instoDecentralizedWeb3', + 'instoBorrowingLending', + 'instoprimeMobileApp', + 'instoEth', + 'instoAccount', + 'instoDecentralizationEverything', + 'instoTrading', + 'instoCoinFocus', + 'instoGlobalConnections', + 'instoDecentralizedExchange', ], - '📅': [ - 'startToday', - 'recurringPurchases', - 'tryAgainLater', - 'taxSeason', - 'calendar', - 'noAnnualFee', + searching: ['noNftFound'], + search: ['noNftFound', 'reviewAndAdd', 'explore', 'newUserChecklistVerifyId', 'priceTracking'], + NFT: ['noNftFound', 'mintedNft', 'selectAddNft', 'apartOfDropsNft'], + picture: ['noNftFound', 'selectAddNft'], + magnifying: ['noNftFound', 'notificationHubAnalysis'], + magnifyGlass: ['noNftFound'], + special: ['noNftFound', 'mintedNft', 'apartOfDropsNft'], + missing: ['noNftFound'], + unfound: ['noNftFound'], + clear: ['noNftFound'], + filter: ['noNftFound'], + '🖼': ['noNftFound', 'mintedNft', 'selectAddNft', 'apartOfDropsNft'], + '✨': [ + 'noNftFound', + 'gem', + 'notifications', + 'explore', + 'easyToUse', + 'trusted', + 'cryptoCard', + 'mintedNft', + 'bundle', + 'addToWatchlist', + 'barChart', + 'coinbaseOneTrusted', + 'learn', + 'target', + 'predictionMarkets', + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'selectAddNft', + 'priceTracking', + 'apartOfDropsNft', + 'bigBtcSend', + 'moneyCrypto', + 'instoGem', + 'instoEasyToUse', ], - '🗓': [ - 'startToday', - 'recurringPurchases', - 'tryAgainLater', - 'taxSeason', - 'calendar', - 'noAnnualFee', + nft: ['nftAvatar'], + avatar: [ + 'nftAvatar', + 'agent', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'avatarAa', + 'avatarAb', + 'avatarAc', + 'avatarAd', + 'avatarAe', + 'avatarAf', + 'avatarAg', + 'avatarAh', + 'avatarAi', + 'avatarAj', + 'avatarBa', + 'avatarBb', + 'avatarBc', + 'avatarBd', + 'avatarBe', + 'avatarBf', + 'avatarBg', + 'avatarBh', + 'avatarBi', + 'avatarBj', + 'avatarCa', + 'avatarCb', + 'avatarCc', + 'avatarCd', + 'avatarCe', + 'avatarCf', + 'avatarCg', + 'avatarCh', + 'avatarCi', + 'avatarCj', + 'avatarDj', + 'avatarDi', + 'avatarDh', + 'avatarDg', + 'avatarDf', + 'avatarDe', + 'avatarDd', + 'avatarDc', + 'avatarDb', + 'avatarDa', + 'avatarEa', + 'avatarEb', + 'avatarEc', + 'avatarEd', + 'avatarEe', + 'avatarEf', + 'avatarEg', + 'avatarEh', + 'avatarEi', + 'avatarEj', + 'avatarFj', + 'avatarFi', + 'avatarFh', + 'avatarFg', + 'avatarFf', + 'avatarFe', + 'avatarFd', + 'avatarGd', + 'avatarGe', + 'avatarGf', + 'avatarGg', + 'avatarGh', + 'avatarGi', + 'avatarGj', + 'avatarHe', + 'avatarGa', + 'avatarFa', + 'avatarGb', + 'avatarGc', + 'avatarFc', + 'avatarIa', + 'avatarIb', + 'avatarIc', + 'avatarId', + 'avatarIe', + 'avatarIf', + 'avatarIg', + 'avatarIh', + 'avatarIi', + 'avatarIj', + 'avatarJj', + 'avatarJi', + 'avatarJh', + 'avatarJg', + 'avatarJf', + 'avatarJe', + 'avatarJd', + 'avatarJc', + 'avatarJb', + 'avatarJa', + 'avatarHa', + 'avatarHb', + 'avatarHc', + 'avatarHd', + 'avatarHf', + 'avatarHg', + 'avatarHh', + 'avatarHi', + 'avatarHj', + 'avatarFb', ], - usdc: [ - 'twoBonus', - 'usdcLoan', - 'leadGraph', - 'usdcRewardsRibbon', - 'usdcInterest', - 'usdcLogo', + 'profile photo': ['nftAvatar'], + robot: ['nftAvatar'], + APY: ['apyInterest', 'instoApyInterest'], + interest: [ + 'apyInterest', + 'ethStakingChart', 'usdcEarn', - 'usdcRewards', - 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - USDCoin: [ - 'twoBonus', - 'usdcRewardsRibbon', 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', 'usdcLogo', - 'usdcEarn', 'usdcRewards', - 'usdcToken', + 'twoBonus', 'coinbaseOneUnlimitedRewards', + 'instoApyInterest', + 'instoEthStakingChart', + ], + growth: ['apyInterest', 'riskStaking', 'instoApyInterest', 'instoRiskStaking'], + graph: [ + 'apyInterest', + 'chart', + 'riskStaking', + 'mobileCharts', + 'taxBeta', + 'taxes', + 'stakingGraph', + 'pieChart', + 'laptopCharts', + 'trading', + 'advancedTradingDesktop', + 'notificationHubPortfolio', + 'assetManagement', + 'ethStakingRewards', + 'calculator', + 'coinbaseOneEarn', + 'pieChartData', + 'instoApyInterest', + 'instoStakingGraph', + 'instoRiskStaking', + 'instoTrading', + ], + chart: [ + 'apyInterest', + 'candleSticksGraph', + 'riskStaking', + 'trading', + 'advancedTradingDesktop', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'coinbaseOneEarn', + 'instoApyInterest', + 'instoRiskStaking', + 'instoTrading', ], + yield: ['apyInterest', 'warning', 'outage', 'instoApyInterest'], coin: [ - 'twoBonus', - 'congratulations', - 'cryptoCoins', - 'usdcLoan', - 'positiveReviews', 'apyInterest', - 'transferSend', - 'leadGraph', - 'crypto101', + 'congratulations', + 'cryptoFolder', + 'decentralizedIdentity', + 'findYourSelection', 'lightbulbLearn', - 'usdcRewardsRibbon', - 'usdcInterest', + 'positiveReviews', + 'stacking', + 'crypto101', + 'custodialJourney', 'cryptoCard', - 'usdcLogo', - 'findYourSelection', - 'usdcEarn', - 'decentralizedIdentity', - 'coinbaseOneEarnCoinsLogo', - 'cryptoFolder', - 'defiEarnMoment', - 'usdcRewards', - 'futures', + 'governance', + 'tokenBaskets', + 'walletDeposit', 'bitcoinWhitePaper', - 'futuresCoinbaseOne', + 'transferSend', + 'earnCoins', + 'defiEarnMoment', + 'moreThanBitcoin', 'bitcoinPizza', - 'walletDeposit', - 'sparkleCoinbaseOne', 'multipleAssets', - 'stacking', - 'tokenBaskets', 'securedAssets', - 'moreThanBitcoin', - 'usdcToken', - 'custodialJourney', - 'earnCoins', - 'governance', + 'coinbaseOneEarnCoins', 'bitcoin', 'winBTC', - 'coinbaseOneUnlimitedRewards', - 'coinbaseOneEarnCoins', - 'moneyCrypto', - 'podium', - ], - USD: [ - 'twoBonus', - 'usdcRewardsRibbon', - 'usdcInterest', - 'usdcLogo', + 'futures', 'usdcEarn', - 'usdcRewards', - 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - coins: [ - 'twoBonus', - 'assetMeasurements', - 'stableCoinMetaphor', - 'trendingAssets', - 'stakingGraph', - 'monitoringPerformance', - 'selfServe', - 'finance', - 'usdcRewardsRibbon', 'usdcInterest', - 'addToWatchlist', - 'usdcLogo', - 'usdcEarn', - 'borrowCoins', - 'bundle', - 'ethStakingRewards', - 'usdcRewards', - 'dollarShowcase', - 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - earn: [ - 'twoBonus', - 'trusted', - 'lightbulbLearn', 'usdcRewardsRibbon', - 'usdcInterest', - 'learn', - 'usdcLogo', - 'coinbaseOneEarn', - 'easyToUse', - 'usdcEarn', - 'gem', - 'target', - 'usdcRewards', - 'sparkleCoinbaseOne', - 'barChart', - 'notifications', 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - interest: [ - 'twoBonus', - 'apyInterest', - 'usdcRewardsRibbon', - 'usdcInterest', 'usdcLogo', - 'usdcEarn', 'usdcRewards', - 'ethStakingChart', - 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - dollar: [ + 'usdcLoan', + 'coinbaseOneEarnCoinsLogo', + 'sparkleCoinbaseOne', 'twoBonus', - 'usdcRewardsRibbon', - 'usdcInterest', - 'usdcLogo', - 'usdcEarn', - 'usdcRewards', - 'usdcToken', + 'futuresCoinbaseOne', + 'leadGraph', 'coinbaseOneUnlimitedRewards', - ], - rewards: [ - 'twoBonus', 'cryptoCoins', - 'accreditedInvestor', - 'premiumInvestor', - 'usdcRewardsRibbon', - 'usdcInterest', - 'usdcLogo', - 'usdcEarn', - 'usdcRewards', - 'giftbox', - 'bitcoinRewards', - 'ethRewards', - 'learningRewardsProduct', - 'usdcToken', - 'bitcoin', - 'winBTC', - 'coinbaseOneUnlimitedRewards', - ], - awards: [ - 'twoBonus', - 'usdcRewardsRibbon', - 'usdcInterest', - 'usdcLogo', - 'usdcEarn', - 'usdcRewards', - 'usdcToken', - 'coinbaseOneUnlimitedRewards', - ], - warning: [ - 'warning', - 'cardBlocked', - 'walletWarning', - 'idBlock', - 'cardDeclined', - 'idError', - 'outage', - 'strongWarning', - 'calendarCaution', + 'podium', + 'moneyCrypto', + 'instoApyInterest', + 'instoCrypto101', + 'instoEarnCoins', + 'instoSecuredAssets', + 'inrTrade', ], - yellow: [ - 'warning', - 'congratulations', - 'positiveReviews', + arrow: [ + 'apyInterest', + 'monitoringPerformance', + 'settled', 'trendingAssets', - 'stakingGraph', - 'selfServe', - 'coinShare', - 'walletWarning', - 'crypto101', - 'lightbulbLearn', - 'findYourSelection', - 'borrowCoins', - 'decentralizedIdentity', - 'cryptoFolder', - 'outage', - 'dollarShowcase', - 'emailAndMessages', - 'layerNetworks', - 'securedAssets', - 'multiAccountsAndCards', - 'custodialJourney', + 'lowFees', + 'applyForHigherLimits', + 'increaseLimits', + 'formDownload', + 'higherLimits', + 'moneyEarn', + 'futures', + 'derivativesProduct', + 'futuresCoinbaseOne', + 'businessProduct', + 'loop', + 'arrowsUpDown', + 'download', + 'instoApyInterest', + 'instoMonitoringPerformance', ], - triangle: ['warning', 'outage'], - error: ['warning', 'idBlock', 'idError', 'outage', 'calendarCaution'], - warn: ['warning', 'outage'], - yield: ['warning', 'apyInterest', 'outage'], - support: [ - 'supportChat', - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', - 'phone', - 'browser', - 'mobileNotifcation', - 'laptop', - 'authenticationApp', - '2fa', + trending: ['apyInterest', 'trendingAssets', 'instoApyInterest'], + value: [ + 'apyInterest', + 'stableCoinMetaphor', + 'riskStaking', + 'bigBtcSend', + 'instoApyInterest', + 'instoRiskStaking', ], - heart: ['supportChat', 'coinbaseOneTrusted'], - 'speech bubble': ['supportChat', 'positiveReviews', 'agent', 'emailAndMessages'], - speech: ['supportChat', 'coinbaseOneChat', 'chat'], - '❤️': ['supportChat', 'coinbaseOneTrusted'], - help: [ - 'supportChat', - 'strongInfo', - 'support', - 'cardBlocked', - 'walletWarning', - 'walletError', - 'cardDeclined', + increase: ['apyInterest', 'instoApyInterest'], + growing: ['apyInterest', 'instoApyInterest'], + '📈': [ + 'apyInterest', + 'chart', + 'mobileCharts', + 'taxBeta', + 'taxes', + 'pieChart', + 'commerceInvoice', + 'commerceCheckout', + 'laptopCharts', + 'trading', + 'advancedTradingDesktop', + 'calculator', + 'pieChartData', + 'instoApyInterest', + 'instoTrading', + ], + agent: ['agent', 'delegate', 'instoDelegate'], + 'speech bubble': ['agent', 'emailAndMessages', 'positiveReviews', 'supportChat'], + chat: ['agent'], + indicator: ['candleSticksGraph'], + candles: ['candleSticksGraph', 'trading', 'advancedTradingDesktop', 'instoTrading'], + green: ['candleSticksGraph', 'done'], + red: [ + 'candleSticksGraph', 'strongWarning', + 'notificationHubAnalysis', + 'walletError', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', ], - chain: ['sideChainSide', 'blockchainConnection'], - hexagon: ['sideChainSide', 'blockchainConnection'], - connections: ['sideChainSide'], + chain: ['blockchainConnection', 'sideChainSide'], + blockchain: ['blockchainConnection', 'defiEarnMoment'], + hexagon: ['blockchainConnection', 'sideChainSide'], blue: [ - 'sideChainSide', - 'strongInfo', + 'blockchainConnection', 'congratulations', + 'cryptoFolder', + 'decentralizedIdentity', + 'creative', + 'finance', + 'findYourSelection', + 'musicAndSounds', + 'lightbulbLearn', + 'emailAndMessages', + 'monitoringPerformance', 'positiveReviews', + 'nftLibrary', + 'completeQuiz', + 'globalPayments', 'assetMeasurements', + 'selfCustodyWallet', 'stableCoinMetaphor', - 'trendingAssets', + 'controlWalletStorage', + 'videoContent', + 'layerNetworks', + 'checkmark', + 'multiAccountsAndCards', + 'crypto101', 'timingCheck', - 'stakingGraph', - 'globalPayments', - 'locationUsa', - 'monitoringPerformance', - 'selfServe', - 'creative', + 'custodialJourney', 'coinShare', - 'crypto101', - 'lightbulbLearn', - 'finance', - 'findYourSelection', + 'sideChainSide', 'borrowCoins', - 'nftLibrary', - 'blockchainConnection', - 'decentralizedIdentity', - 'cryptoFolder', + 'taxesArrangement', + 'trendingAssets', + 'locationUsa', + 'passwordWalletLocked', + 'stakingGraph', + 'selfServe', + 'strongInfo', 'dollarShowcase', - 'videoCalendar', - 'emailAndMessages', - 'checkmark', - 'layerNetworks', 'pluginBrowser', 'securedAssets', - 'videoContent', 'coldStorageCheck', - 'controlWalletStorage', + 'videoCalendar', + 'instoPasswordWalletLocked', + 'browserMultiPlatform', + 'instoCrypto101', + 'instoStakingGraph', + 'instoNftLibrary', + 'instoSelfCustodyWallet', + 'instoSecuredAssets', + 'instoBorrowCoins', + 'instoMonitoringPerformance', + ], + sequence: ['blockchainConnection'], + congratulations: ['congratulations'], + prize: ['congratulations'], + yellow: [ + 'congratulations', + 'cryptoFolder', + 'decentralizedIdentity', + 'findYourSelection', + 'lightbulbLearn', + 'emailAndMessages', + 'positiveReviews', + 'layerNetworks', 'multiAccountsAndCards', - 'musicAndSounds', - 'passwordWalletLocked', + 'warning', + 'crypto101', 'custodialJourney', + 'coinShare', + 'borrowCoins', + 'trendingAssets', + 'stakingGraph', + 'selfServe', + 'dollarShowcase', + 'walletWarning', + 'securedAssets', + 'outage', + 'browserMultiPlatform', + 'instoCrypto101', + 'instoStakingGraph', + 'instoWalletWarning', + 'instoSecuredAssets', + 'instoBorrowCoins', + ], + folder: ['cryptoFolder', 'decentralizedIdentity'], + art: ['creative'], + palette: ['creative'], + circles: [ + 'creative', + 'finance', 'completeQuiz', - 'selfCustodyWallet', - 'taxesArrangement', + 'globalPayments', + 'stableCoinMetaphor', + 'coinShare', + 'stakingGraph', + 'selfServe', + 'dollarShowcase', + 'coldStorageCheck', + 'videoCalendar', + 'instoStakingGraph', ], - stand: ['standWithCryptoLogoNavigation'], - with: ['standWithCryptoLogoNavigation'], - crypto: [ - 'standWithCryptoLogoNavigation', - 'transferSend', - 'crypto101', - 'addToWatchlist', - 'advancedTradingRebates', - 'coinbaseOneEarnCoinsLogo', + coins: [ + 'finance', + 'monitoringPerformance', + 'assetMeasurements', + 'stableCoinMetaphor', + 'borrowCoins', + 'trendingAssets', 'bundle', - 'defiEarnMoment', - 'bitcoinPizza', - 'multipleAssets', - 'stacking', - 'tokenBaskets', - 'moreThanBitcoin', - 'earnCoins', - 'coinbaseOneEarnCoins', - ], - swc: ['standWithCryptoLogoNavigation'], - shield: ['standWithCryptoLogoNavigation', 'key', 'coinbaseOneShield', 'shield'], - pictogram: [ - 'standWithCryptoLogoNavigation', - 'cryptoCoins', - 'developerSDKNavigation', - 'businessProduct', - 'verifiedPools', - 'developerPlatformNavigation', - 'derivativesProduct', - 'loop', - 'bitcoinRewards', - 'ethRewards', - 'learningRewardsProduct', - 'bitcoin', - 'winBTC', - ], - logo: ['standWithCryptoLogoNavigation', 'coinbaseOneLogo', 'baseLogo'], - navigation: [ - 'standWithCryptoLogoNavigation', - 'developerSDKNavigation', - 'verifiedPools', - 'developerPlatformNavigation', + 'addToWatchlist', + 'stakingGraph', + 'selfServe', + 'dollarShowcase', + 'ethStakingRewards', + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + 'instoStakingGraph', + 'instoBorrowCoins', + 'instoMonitoringPerformance', ], - nav: ['standWithCryptoLogoNavigation'], - app: ['standWithCryptoLogoNavigation', 'primeMobileApp', 'coinbaseWalletApp'], - switcher: ['standWithCryptoLogoNavigation'], - information: ['strongInfo'], - info: ['strongInfo'], - resource: ['strongInfo'], - guide: ['strongInfo'], - details: ['strongInfo', 'addPayment'], - facts: ['strongInfo'], + globe: ['finance', 'moneySwift', 'worldwide', 'passport'], circle: [ - 'strongInfo', - 'positiveReviews', - 'trendingAssets', - 'error', - 'settled', - 'monitoringPerformance', - 'crypto101', - 'lightbulbLearn', - 'done', 'findYourSelection', + 'lightbulbLearn', 'gasFees', - 'strongWarning', + 'monitoringPerformance', + 'positiveReviews', + 'selfCustodyWallet', + 'controlWalletStorage', + 'videoContent', 'checkmark', 'add', - 'securedAssets', - 'videoContent', - 'controlWalletStorage', - 'passwordWalletLocked', - 'selfCustodyWallet', + 'error', + 'done', + 'crypto101', + 'settled', 'taxesArrangement', + 'trendingAssets', + 'passwordWalletLocked', + 'strongInfo', + 'strongWarning', + 'securedAssets', + 'instoPasswordWalletLocked', + 'browserMultiPlatform', + 'instoCrypto101', + 'instoSelfCustodyWallet', + 'instoSecuredAssets', + 'instoMonitoringPerformance', ], - ℹ️: ['strongInfo'], - congratulations: ['congratulations'], - prize: ['congratulations'], - aid: ['support'], - assist: ['support'], - buoy: ['support'], - 'life saver': ['support'], - 'crypto learning': ['cryptoCoins', 'bitcoin', 'winBTC'], - bitcoin: ['cryptoCoins', 'bitcoinRewards', 'bitcoin', 'winBTC'], - btc: ['cryptoCoins', 'btcOneHundred', 'bitcoinRewards', 'bitcoin', 'winBTC'], - satoshi: ['cryptoCoins', 'bitcoinRewards', 'bitcoin', 'winBTC'], - giveaway: ['cryptoCoins', 'bitcoinRewards', 'bitcoin', 'winBTC'], - free: ['cryptoCoins', 'bitcoinRewards', 'bitcoin', 'winBTC'], - competition: ['cryptoCoins', 'bitcoinRewards', 'bitcoin', 'winBTC'], - '2fa': [ - 'securityKey', - 'authenticatorAlt', - 'idVerification', - 'authenticationApp', - 'smsAuthenticate', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - security: [ - 'securityKey', - 'manageWeb3SignersAcct', - 'ssnCard', - 'key', - 'coinbaseOneShield', - 'safe', - 'shield', - 'ubiKey', - ], - trust: [ - 'securityKey', - 'authenticatorProgress', - 'authenticatorAlt', - 'coinbaseOneAuthenticator', - 'authenticationApp', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - true: [ - 'securityKey', - 'authenticatorProgress', - 'authenticatorAlt', - 'coinbaseOneAuthenticator', - 'authenticationApp', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - genuine: [ - 'securityKey', - 'authenticatorProgress', - 'authenticatorAlt', - 'coinbaseOneAuthenticator', - 'authenticationApp', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - actual: [ - 'securityKey', - 'authenticatorProgress', - 'authenticatorAlt', - 'coinbaseOneAuthenticator', - 'authenticationApp', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - verification: [ - 'securityKey', - 'passport', - 'newUserChecklistVerifyId', - 'authenticatorProgress', - 'authenticatorAlt', - 'coinbaseOneAuthenticator', - 'authenticationApp', - 'authenticator', - 'googleAuthenticator', - 'ubiKey', - ], - protect: ['securityKey', 'key', 'safe', 'googleAuthenticator', 'ubiKey'], - key: ['securityKey', 'key', 'ubiKey'], - '🔑': ['securityKey', 'key', 'security', 'lock', 'ubiKey'], - '🗝': ['securityKey', 'key', 'security', 'lock', 'ubiKey'], - '🔐': ['securityKey', 'key', 'security', 'lock', 'ubiKey'], - '🚨': ['securityKey', 'ubiKey'], - wallet: [ - 'walletSuccess', + music: ['musicAndSounds', 'nftLibrary', 'instoNftLibrary'], + 'music note': ['musicAndSounds', 'nftLibrary', 'instoNftLibrary'], + earn: [ + 'lightbulbLearn', + 'gem', + 'notifications', + 'easyToUse', + 'trusted', + 'barChart', + 'learn', + 'target', + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'coinbaseOneEarn', + 'sparkleCoinbaseOne', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + 'instoGem', + 'instoEasyToUse', + ], + learn: ['lightbulbLearn'], + bulb: ['lightbulbLearn'], + 'gas fees': ['gasFees'], + fees: ['gasFees', 'noAnnualFee', 'listingFees', 'assetManagementNavigation', 'calculator'], + 'fuel tank': ['gasFees'], + mail: ['emailAndMessages'], + help: [ + 'support', + 'cardDeclined', + 'supportChat', + 'strongInfo', + 'cardBlocked', 'walletWarning', + 'strongWarning', 'walletError', - 'addWallet', - 'coinbaseWalletApp', - 'wallet', - 'walletDeposit', - 'walletExchange', - 'selfCustodyWallet', - 'hardwareWallet', + 'instoWalletWarning', ], - '✅': [ - 'walletSuccess', - 'manageWeb3SignersAcct', - 'cardSuccess', - 'listingFees', - 'done', - 'idVerification', - 'mobileSuccess', - 'checkmark', - 'takeQuiz', - 'tokenBaskets', - 'enableVoting', - 'newUserChecklistBuyCrypto', - 'delegate', - 'newUserChecklistCompleteAccount', + aid: ['support'], + assist: ['support'], + buoy: ['support'], + 'life saver': ['support'], + up: [ + 'monitoringPerformance', + 'trendingAssets', + 'applyForHigherLimits', + 'increaseLimits', + 'higherLimits', + 'coinbaseOneEarn', + 'instoMonitoringPerformance', ], - 'success state': [ - 'walletSuccess', - 'successPhone', - 'cardSuccess', - 'done', - 'calendar', - 'idVerification', - 'mobileSuccess', - 'bigBtcSend', + gain: [ + 'monitoringPerformance', + 'assetMeasurements', + 'trendingAssets', + 'instoMonitoringPerformance', ], - send: ['usdcLoan', 'sellSendAnytime', 'peerToPeer', 'leadGraph', 'lightningNetworkSend', 'email'], - loan: ['usdcLoan', 'leadGraph'], - portal: ['usdcLoan', 'leadGraph'], - stars: ['usdcLoan', 'leadGraph', 'ethStakingRewards', 'bigBtcSend'], + portfolio: ['monitoringPerformance', 'notificationHubPortfolio', 'instoMonitoringPerformance'], rating: ['positiveReviews'], + review: ['positiveReviews', 'reviewAndAdd', 'notificationHubAnalysis'], phone: [ 'positiveReviews', - 'transferSend', - 'manageWeb3SignersAcct', - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + 'coinbaseWalletApp', + '2fa', 'phone', + 'laptop', + 'browserTransaction', + 'internet', 'browser', + 'addPhone', + 'authenticationApp', + 'transferSend', + 'successPhone', 'mobileNotifcation', 'mobileSuccess', - 'laptop', - 'coinbaseWalletApp', - 'authenticationApp', - '2fa', + 'manageWeb3SignersAcct', ], star: [ 'positiveReviews', 'locationUsa', - 'sparkleCoinbaseOne', - 'bitcoinRewards', - 'ethRewards', 'learningRewardsProduct', + 'ethRewards', + 'bitcoinRewards', + 'sparkleCoinbaseOne', 'podium', + 'instoEthRewards', ], - prime: ['primeMobileApp', 'businessProduct', 'derivativesProduct', 'loop'], - mobile: [ - 'primeMobileApp', - 'successPhone', - 'mobileError', - 'internet', - 'addPhone', - 'mobileWarning', - 'browserTransaction', - 'phone', - 'browser', - 'mobileCharts', - 'mobileNotifcation', - 'mobileSuccess', - 'laptop', - 'authenticationApp', - '2fa', + card: [ + 'cardDeclined', + 'cryptoCard', + 'idVerification', + 'addPayment', + 'identityCard', + 'newUserChecklistVerifyId', + 'cardBlocked', + 'contactInfo', + 'creditCard', + 'addCard', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'myNumberCard', + 'cardSuccess', + 'moneyCrypto', ], - '📱': [ - 'primeMobileApp', - 'transferSend', - 'manageWeb3SignersAcct', - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', - 'phone', - 'browser', - 'mobileCharts', - 'mobileNotifcation', - 'laptop', - 'coinbaseWalletApp', - 'authenticationApp', - 'multiPlatform', - '2fa', - 'addressBook', + cancelled: ['cardDeclined', 'cardBlocked'], + warning: [ + 'cardDeclined', + 'calendarCaution', + 'warning', + 'cardBlocked', + 'walletWarning', + 'strongWarning', + 'outage', + 'idBlock', + 'idError', + 'instoWalletWarning', ], - '📲': ['primeMobileApp', 'transferSend', 'mobileCharts', 'multiPlatform', 'addressBook'], - APY: ['apyInterest'], - growth: ['apyInterest', 'riskStaking'], - graph: [ - 'apyInterest', - 'riskStaking', - 'taxBeta', - 'stakingGraph', - 'taxes', - 'assetManagement', - 'coinbaseOneEarn', - 'pieChart', - 'laptopCharts', - 'mobileCharts', - 'ethStakingRewards', - 'chart', - 'calculator', - 'advancedTradingDesktop', - 'trading', + credit: [ + 'cardDeclined', + 'cryptoCard', + 'addPayment', + 'cardBlocked', + 'creditCard', + 'addCard', + 'cardSuccess', + 'moneyCrypto', + ], + alert: [ + 'cardDeclined', + 'cardBlocked', + 'walletWarning', + 'strongWarning', + 'notificationHubAnalysis', + 'walletError', 'notificationHubPortfolio', - 'pieChartData', + 'notificationHubSocial', + 'notificationHubNews', + 'instoWalletWarning', ], - arrow: [ - 'apyInterest', - 'businessProduct', - 'trendingAssets', - 'settled', - 'monitoringPerformance', - 'derivativesProduct', - 'higherLimits', - 'formDownload', - 'futures', - 'futuresCoinbaseOne', - 'loop', - 'lowFees', - 'applyForHigherLimits', - 'increaseLimits', - 'moneyEarn', + crucial: [ + 'cardDeclined', + 'cardBlocked', + 'walletWarning', + 'strongWarning', + 'walletError', + 'instoWalletWarning', + ], + indication: [ + 'cardDeclined', + 'cardBlocked', + 'walletWarning', + 'strongWarning', + 'walletError', + 'instoWalletWarning', + ], + emphasis: ['cardDeclined', 'cardBlocked', 'strongWarning'], + '!': ['cardDeclined', 'strongWarning'], + '💳': [ + 'cardDeclined', + 'cryptoCard', + 'addPayment', + 'newUserChecklistVerifyId', + 'cardBlocked', + 'creditCard', + 'addCard', + 'cardSuccess', + 'moneyCrypto', + ], + '⚠️': [ + 'cardDeclined', + 'walletWarning', + 'walletError', + 'idBlock', + 'idError', + 'instoWalletWarning', + ], + '❗️': ['cardDeclined', 'strongWarning'], + 'warning state': ['cardDeclined', 'mobileWarning'], + square: [ + 'nftLibrary', + 'videoContent', + 'passwordWalletLocked', + 'coldStorageCheck', + 'instoPasswordWalletLocked', + 'instoNftLibrary', ], - trending: ['apyInterest', 'trendingAssets'], - value: ['apyInterest', 'stableCoinMetaphor', 'riskStaking', 'bigBtcSend'], - increase: ['apyInterest'], - growing: ['apyInterest'], - '📈': [ - 'apyInterest', - 'taxBeta', - 'taxes', + user: [ + 'nftLibrary', + 'selfCustodyWallet', + 'custodialJourney', + 'selfServe', + 'instoNftLibrary', + 'instoSelfCustodyWallet', + ], + play: ['nftLibrary', 'videoContent', 'laptopVideo', 'videoCalendar', 'instoNftLibrary'], + document: [ + 'nftLibrary', + 'taxesArrangement', + 'formDownload', + 'bitcoinWhitePaper', 'commerceInvoice', - 'pieChart', - 'laptopCharts', - 'mobileCharts', - 'chart', 'commerceCheckout', - 'calculator', - 'advancedTradingDesktop', - 'trading', - 'pieChartData', - ], - developer: ['developerSDKNavigation', 'verifiedPools', 'developerPlatformNavigation'], - platform: [ - 'developerSDKNavigation', - 'verifiedPools', - 'developerPlatformNavigation', - 'multiPlatform', - ], - product: [ - 'developerSDKNavigation', - 'verifiedPools', - 'developerPlatformNavigation', - 'complianceNavigation', - 'coinbaseOneProductIcon', + 'orders', + 'instoNftLibrary', ], - SDK: ['developerSDKNavigation'], - reoccur: ['recurringPurchases'], - regular: ['recurringPurchases'], - organize: ['recurringPurchases', 'taxSeason'], - book: ['recurringPurchases', 'crypto101', 'addressBook'], - refresh: ['recurringPurchases', 'tryAgainLater', 'coinbaseOneRefreshed', 'restaking'], - gain: ['assetMeasurements', 'trendingAssets', 'monitoringPerformance'], - loss: ['assetMeasurements'], - balance: ['assetMeasurements', 'stableCoinMetaphor', 'futures', 'futuresCoinbaseOne'], - passport: ['passport'], - documents: ['passport'], - international: ['passport', 'internationalExchangeNavigation', 'worldwide'], - id: [ - 'passport', + digital: ['nftLibrary', 'instoNftLibrary'], + collectibles: ['nftLibrary', 'instoNftLibrary'], + nfts: ['nftLibrary', 'instoNftLibrary'], + pencil: ['completeQuiz'], + check: [ + 'completeQuiz', + 'reviewAndAdd', + 'checkmark', + 'done', + 'timingCheck', + 'taxesArrangement', + 'idVerification', + 'tokenBaskets', + 'listingFees', + 'enableVoting', + 'coldStorageCheck', + 'delegate', + 'takeQuiz', + 'walletSuccess', + 'cardSuccess', + 'mobileSuccess', 'manageWeb3SignersAcct', - 'driversLicense', - 'ssnCard', - 'idBlock', - 'idError', - 'genericCountryIDCard', - 'driversLicenseWheel', - ], - identity: [ - 'passport', - 'driversLicense', - 'ssnCard', - 'newUserChecklistVerifyId', - 'genericCountryIDCard', - 'driversLicenseWheel', + 'instoDelegate', ], - verify: ['passport', 'newUserChecklistVerifyId', 'calendar'], - globe: ['passport', 'finance', 'moneySwift', 'worldwide'], - travel: ['passport'], - customs: ['passport'], - derivatives: ['businessProduct', 'derivativesProduct', 'loop'], - leverage: ['businessProduct', 'derivativesProduct', 'loop'], - invest: ['businessProduct', 'derivativesProduct', 'coinbaseOneEarn', 'loop'], - advanced: ['businessProduct', 'derivativesProduct', 'loop'], - derive: ['businessProduct', 'derivativesProduct', 'loop'], - triangles: ['businessProduct', 'derivativesProduct', 'loop'], + cross: ['completeQuiz', 'error', 'noAnnualFee', 'cardBlocked'], + complete: ['completeQuiz'], + quiz: ['completeQuiz'], + payment: ['paypal'], + online: ['paypal', 'email'], + virtual: ['paypal'], + method: ['paypal'], + connection: ['globalPayments', 'transistor'], waiting: ['waitingForConsensus', 'tryAgainLater'], - time: ['waitingForConsensus', 'fast', 'clock', 'waiting', 'planet'], + time: ['waitingForConsensus', 'fast', 'waiting', 'clock', 'planet'], clipboard: ['waitingForConsensus', 'takeQuiz'], - clock: ['waitingForConsensus', 'timingCheck', 'futures', 'fast', 'futuresCoinbaseOne', 'clock'], + clock: ['waitingForConsensus', 'timingCheck', 'fast', 'clock', 'futures', 'futuresCoinbaseOne'], agreement: ['waitingForConsensus', 'applyForHigherLimits'], consent: ['waitingForConsensus'], record: ['waitingForConsensus', 'formDownload', 'bitcoinWhitePaper', 'clock'], @@ -1196,1823 +879,2524 @@ const descriptionMap: Record = { hour: ['waitingForConsensus', 'clock'], day: ['waitingForConsensus', 'clock'], '24 hours': ['waitingForConsensus', 'clock'], - '🕦': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕐': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕚': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕥': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕧': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕙': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕣': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕠': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕝': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕢': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕟': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕜': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕤': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕡': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕞': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕘': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕒': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕗': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕔': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕑': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕖': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕓': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕛': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '⏰': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '⏱': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '🕰': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], + '🕦': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕐': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕚': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕥': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕧': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕙': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕣': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕠': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕝': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕢': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕟': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕜': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕤': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕡': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕞': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕘': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕒': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕗': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕔': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕑': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕖': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕓': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕛': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '⏰': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '⏱': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '🕰': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], '🔄': [ 'waitingForConsensus', - 'tryAgainLater', 'coinbaseOneRefreshed', - 'fast', 'walletExchange', - 'clock', + 'tryAgainLater', + 'fast', 'waiting', + 'clock', 'restaking', + 'instoRestaking', ], - '⏳': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], - '⌛️': ['waitingForConsensus', 'tryAgainLater', 'fast', 'clock', 'waiting'], + '⏳': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], + '⌛️': ['waitingForConsensus', 'tryAgainLater', 'fast', 'waiting', 'clock'], '📋': [ 'waitingForConsensus', 'applyForHigherLimits', - 'takeQuiz', + 'newUserChecklistCompleteAccount', 'newUserChecklistBuyCrypto', + 'takeQuiz', + ], + monitor: ['reviewAndAdd', 'pluginBrowser', 'priceTracking', 'browserMultiPlatform'], + 'magnifying glass': ['reviewAndAdd', 'explore', 'newUserChecklistVerifyId', 'priceTracking'], + look: ['reviewAndAdd', 'explore', 'priceTracking'], + more: [ + 'reviewAndAdd', + 'add', + 'explore', + 'earnCoins', + 'moreThanBitcoin', + 'priceTracking', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'coinbaseOneEarnCoinsLogo', + 'instoEarnCoins', + ], + add: [ + 'reviewAndAdd', + 'add', + 'mintedNft', + 'addPhone', + 'addPayment', + 'addToWatchlist', + 'addWallet', + 'earnCoins', 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'commerceInvoice', + 'selectAddNft', + 'commerceCheckout', + 'moreThanBitcoin', + 'addCard', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'coinbaseOneEarnCoinsLogo', + 'instoEarnCoins', ], - diamond: ['trusted', 'learn', 'easyToUse', 'gem', 'target', 'barChart', 'notifications'], - reward: ['trusted', 'learn', 'easyToUse', 'gem', 'target', 'barChart', 'notifications'], - crystal: ['trusted', 'learn', 'easyToUse', 'gem', 'target', 'barChart', 'notifications'], - '💎': [ - 'trusted', + '➕': [ + 'reviewAndAdd', + 'add', + 'addPhone', 'addPayment', - 'learn', - 'easyToUse', - 'gem', - 'target', - 'barChart', - 'notifications', + 'addToWatchlist', + 'addWallet', + 'earnCoins', + 'newUserChecklistBuyCrypto', + 'commerceInvoice', + 'commerceCheckout', + 'moreThanBitcoin', + 'addCard', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'coinbaseOneEarnCoinsLogo', + 'instoEarnCoins', ], - '💍': ['trusted', 'learn', 'easyToUse', 'gem', 'target', 'barChart', 'notifications'], - circles: [ - 'stableCoinMetaphor', - 'stakingGraph', - 'globalPayments', - 'selfServe', - 'creative', - 'coinShare', - 'finance', - 'dollarShowcase', - 'videoCalendar', - 'coldStorageCheck', - 'completeQuiz', + '🔎': ['reviewAndAdd', 'explore', 'newUserChecklistVerifyId', 'priceTracking'], + '🔍': ['reviewAndAdd', 'explore', 'newUserChecklistVerifyId', 'priceTracking'], + '🕵️': [ + 'reviewAndAdd', + 'explore', + 'idVerification', + 'identityCard', + 'newUserChecklistVerifyId', + 'contactInfo', + 'priceTracking', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🕵️‍♀️': [ + 'reviewAndAdd', + 'explore', + 'idVerification', + 'identityCard', + 'newUserChecklistVerifyId', + 'contactInfo', + 'priceTracking', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🕵️‍♂️': [ + 'reviewAndAdd', + 'explore', + 'idVerification', + 'identityCard', + 'newUserChecklistVerifyId', + 'contactInfo', + 'priceTracking', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + loss: ['assetMeasurements'], + balance: ['assetMeasurements', 'stableCoinMetaphor', 'futures', 'futuresCoinbaseOne'], + wallet: [ + 'selfCustodyWallet', + 'coinbaseWalletApp', + 'wallet', + 'addWallet', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'hardwareWallet', + 'walletError', + 'walletSuccess', + 'instoWalletWarning', + 'instoSelfCustodyWallet', + ], + 'self custody': ['selfCustodyWallet', 'selfServe', 'instoSelfCustodyWallet'], + law: ['stableCoinMetaphor'], + stable: ['stableCoinMetaphor'], + arrows: ['controlWalletStorage'], + eye: ['videoContent', 'noVisibility'], + watch: ['videoContent'], + videos: ['videoContent'], + calendar: [ + 'calendarCaution', + 'calendar', + 'noAnnualFee', + 'recurringPurchases', + 'tryAgainLater', + 'startToday', + 'taxSeason', + 'calendarHighlight', + ], + caution: ['calendarCaution', 'riskStaking', 'instoRiskStaking'], + schedule: ['calendarCaution', 'calendar', 'recurringPurchases', 'startToday', 'taxSeason'], + error: ['calendarCaution', 'warning', 'outage', 'idBlock', 'idError'], + '⛔️': ['calendarCaution', 'noAnnualFee', 'noWiFi', 'noVisibility'], + 'error state': [ + 'calendarCaution', + 'noWiFi', + 'cardBlocked', + 'strongWarning', + 'walletError', + 'mobileError', + ], + seed: ['seedPhrase'], + phrase: ['seedPhrase'], + word: ['seedPhrase'], + code: ['seedPhrase'], + unique: ['seedPhrase', 'mintedNft', 'apartOfDropsNft'], + coinbase: [ + 'coinbaseOneLogo', + 'coinbaseOneChat', + 'coinbaseWalletApp', + 'coinbaseOneShield', + 'coinbaseOneRefreshed', + 'coinbaseOneTrusted', + 'coinbaseOneAuthenticator', + 'coinbaseOneFiat', + 'restaking', + 'coinbaseOneEarnCoins', + 'assetManagementNavigation', + 'coinbaseOneTrade', + 'instoRestaking', + 'instoCoinbaseOneShield', + ], + one: [ + 'coinbaseOneLogo', + 'coinbaseOneChat', + 'coinbaseOneShield', + 'coinbaseOneRefreshed', + 'coinbaseOneTrusted', + 'coinbaseOneAuthenticator', + 'coinbaseOneFiat', + 'restaking', + 'coinbaseOneEarnCoins', + 'coinbaseOneTrade', + 'instoRestaking', + 'instoCoinbaseOneShield', ], - law: ['stableCoinMetaphor'], - stable: ['stableCoinMetaphor'], - world: ['sellSendAnytime', 'moneySwift'], - sending: ['sellSendAnytime'], - anytime: ['sellSendAnytime'], - now: ['sellSendAnytime'], - money: [ - 'sellSendAnytime', - 'transferSend', - 'assetManagement', - 'taxSeason', - 'commerceInvoice', - 'higherLimits', - 'fiat', - 'listingFees', - 'moneySwift', - 'coinbaseOneEarn', - 'institutions', - 'creditCard', - 'lowFees', - 'commerceCheckout', - 'bigBtcSend', + cb1: [ + 'coinbaseOneLogo', + 'coinbaseOneChat', + 'coinbaseOneShield', + 'coinbaseOneRefreshed', + 'coinbaseOneTrusted', + 'coinbaseOneAuthenticator', 'coinbaseOneFiat', - 'moneyEarn', + 'restaking', + 'coinbaseOneEarnCoins', + 'coinbaseOneProductIcon', + 'coinbaseOneTrade', + 'instoRestaking', + 'instoCoinbaseOneShield', ], - sell: ['sellSendAnytime', 'futures', 'futuresCoinbaseOne'], - selling: ['sellSendAnytime'], - market: ['sellSendAnytime', 'riskStaking'], - staking: [ - 'sellSendAnytime', - 'riskStaking', - 'stakingGraph', + logo: ['coinbaseOneLogo', 'standWithCryptoLogoNavigation', 'baseLogo'], + logomark: ['coinbaseOneLogo'], + brand: ['coinbaseOneLogo'], + layers: ['layerNetworks'], + isometric: ['layerNetworks'], + networks: ['layerNetworks'], + ethereum: [ + 'layerNetworks', + 'wrapEth', 'ethStaking', - 'ethStakingRewards', - 'stacking', - 'ethRewards', 'ethStakingChart', - 'restaking', - 'governance', + 'ethRewards', 'ethToken', + 'instoEthRewards', + 'instoEthStakingChart', ], - later: ['tryAgainLater'], - attempt: ['tryAgainLater'], - reschedule: ['tryAgainLater'], - verified: ['verifiedPools'], - pools: ['verifiedPools'], - liquid: ['verifiedPools'], - liquidity: ['verifiedPools'], - observe: ['priceTracking'], - '🤑': ['priceTracking'], - '🏷': ['priceTracking', 'receipt', 'commerceCheckout'], - exclamation: ['riskStaking'], - risk: ['riskStaking', 'futures', 'futuresCoinbaseOne'], - caution: ['riskStaking', 'calendarCaution'], - wrapping: ['riskStaking'], - ETH: ['riskStaking'], - move: ['transferSend'], - give: ['transferSend', 'delegate'], - transmit: ['transferSend'], - '🪙': [ - 'transferSend', - 'addPayment', + crypto: [ + 'stacking', + 'crypto101', + 'bundle', 'addToWatchlist', - 'coinbaseOneEarnCoinsLogo', + 'tokenBaskets', + 'transferSend', + 'earnCoins', 'defiEarnMoment', + 'moreThanBitcoin', 'bitcoinPizza', - 'walletDeposit', 'multipleAssets', - 'tokenBaskets', - 'moreThanBitcoin', - 'earnCoins', 'coinbaseOneEarnCoins', + 'advancedTradingRebates', + 'standWithCryptoLogoNavigation', + 'coinbaseOneEarnCoinsLogo', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoEarnCoins', ], - '💸': [ - 'transferSend', - 'walletWarning', - 'assetManagement', - 'commerceInvoice', - 'higherLimits', - 'addPayment', - 'fiat', - 'walletError', - 'listingFees', - 'moneySwift', - 'addWallet', - 'institutions', - 'wallet', - 'walletDeposit', - 'lowFees', - 'commerceCheckout', - 'walletExchange', - 'coinbaseOneFiat', - 'moneyEarn', - ], - '💵': [ - 'transferSend', - 'walletWarning', - 'assetManagement', - 'higherLimits', - 'addPayment', - 'fiat', - 'walletError', - 'listingFees', - 'moneySwift', - 'addWallet', - 'institutions', - 'wallet', - 'walletDeposit', - 'lowFees', - 'walletExchange', - 'coinbaseOneFiat', - 'moneyEarn', + staking: [ + 'stacking', + 'riskStaking', + 'sellSendAnytime', + 'governance', + 'stakingGraph', + 'restaking', + 'ethStaking', + 'ethStakingChart', + 'ethRewards', + 'ethToken', + 'ethStakingRewards', + 'instoRestaking', + 'instoStakingGraph', + 'instoEthRewards', + 'instoEthStakingChart', + 'instoRiskStaking', ], - '💶': [ - 'transferSend', - 'assetManagement', - 'higherLimits', - 'addPayment', - 'fiat', + stacking: ['stacking'], + checkmark: [ + 'checkmark', + 'done', + 'idVerification', + 'tokenBaskets', 'listingFees', - 'moneySwift', - 'institutions', - 'lowFees', - 'coinbaseOneFiat', - 'moneyEarn', + 'enableVoting', + 'delegate', + 'takeQuiz', + 'cardSuccess', + 'instoDelegate', ], - '💷': [ - 'transferSend', - 'assetManagement', - 'higherLimits', - 'addPayment', - 'fiat', + tick: ['checkmark', 'done'], + confirmation: ['checkmark', 'done', 'idBlock', 'idError'], + success: ['checkmark', 'done'], + positive: ['checkmark', 'done'], + primary: ['checkmark'], + '✅': [ + 'checkmark', + 'done', + 'idVerification', + 'tokenBaskets', + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', 'listingFees', - 'moneySwift', - 'institutions', - 'lowFees', - 'coinbaseOneFiat', - 'moneyEarn', + 'enableVoting', + 'delegate', + 'takeQuiz', + 'walletSuccess', + 'cardSuccess', + 'mobileSuccess', + 'manageWeb3SignersAcct', + 'instoDelegate', ], - '💴': [ - 'transferSend', - 'assetManagement', - 'higherLimits', - 'addPayment', - 'fiat', + '✔️': [ + 'checkmark', + 'done', + 'idVerification', + 'tokenBaskets', 'listingFees', - 'moneySwift', - 'institutions', - 'lowFees', - 'coinbaseOneFiat', - 'moneyEarn', + 'enableVoting', + 'delegate', + 'takeQuiz', + 'cardSuccess', + 'instoDelegate', ], - '💰': [ - 'transferSend', - 'walletWarning', - 'assetManagement', - 'commerceInvoice', - 'higherLimits', - 'walletError', - 'listingFees', + 'multiple wallets': ['multiAccountsAndCards'], + addition: ['add', 'addPhone', 'addToWatchlist'], + plus: [ + 'add', + 'addPhone', + 'addToWatchlist', 'addWallet', - 'wallet', - 'walletDeposit', - 'lowFees', + 'earnCoins', + 'commerceInvoice', 'commerceCheckout', - 'walletExchange', - 'moneyEarn', + 'moreThanBitcoin', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'futures', + 'coinbaseOneEarnCoinsLogo', + 'futuresCoinbaseOne', + 'instoEarnCoins', ], - beta: ['taxBeta'], - taxes: ['taxBeta', 'taxes', 'taxSeason', 'calculator'], - charts: ['taxBeta', 'taxes', 'laptopCharts', 'laptopVideo', 'calculator'], - pie: ['taxBeta', 'taxes'], + close: ['error'], + decline: ['error'], + reject: ['error'], + no: ['error', 'governance', 'noAnnualFee'], + cancel: ['error', 'noAnnualFee'], + x: ['error'], + '❌': ['error', 'noAnnualFee', 'noWiFi', 'cardBlocked', 'noVisibility'], + '🙅': ['error', 'noAnnualFee', 'noWiFi', 'cardBlocked', 'noVisibility'], + '🙅‍♂️': ['error', 'noAnnualFee', 'noWiFi', 'cardBlocked', 'noVisibility'], + '🙅‍♀️': ['error', 'noAnnualFee', 'noWiFi', 'cardBlocked', 'noVisibility'], + '🚫': ['error'], + '❎': ['error'], + done: ['done'], + 'success state': [ + 'done', + 'calendar', + 'idVerification', + 'successPhone', + 'walletSuccess', + 'cardSuccess', + 'mobileSuccess', + 'bigBtcSend', + ], + triangle: ['warning', 'outage'], + warn: ['warning', 'outage'], + beginner: ['crypto101', 'instoCrypto101'], + book: ['crypto101', 'recurringPurchases', 'addressBook', 'instoCrypto101', 'instoAddressBook'], + envelope: ['envelope', 'email'], + letter: ['envelope', 'email'], + email: ['envelope'], + message: ['envelope', 'coinbaseOneChat', 'smsAuthenticate', 'email', 'chat'], + '💌': ['envelope', 'email'], + '✉️': ['envelope', 'email'], + '📨': ['envelope', 'email'], + '📩': ['envelope', 'email'], + '📧': ['envelope', 'email'], + 'chart bar': ['chart', 'mobileCharts'], data: [ + 'chart', + 'mobileCharts', 'taxBeta', 'taxes', 'pieChart', 'laptopCharts', - 'mobileCharts', - 'chart', - 'calculator', - 'advancedTradingDesktop', 'trading', + 'advancedTradingDesktop', + 'calculator', 'pieChartData', + 'instoTrading', ], visualization: [ + 'chart', + 'mobileCharts', 'taxBeta', 'taxes', 'pieChart', 'laptopCharts', - 'mobileCharts', - 'chart', - 'calculator', - 'advancedTradingDesktop', 'trading', + 'advancedTradingDesktop', + 'calculator', 'pieChartData', + 'instoTrading', ], numbers: [ + 'chart', + 'mobileCharts', 'taxBeta', 'taxes', 'pieChart', 'laptopCharts', - 'mobileCharts', - 'chart', - 'calculator', - 'advancedTradingDesktop', 'trading', + 'advancedTradingDesktop', + 'calculator', 'pieChartData', + 'instoTrading', ], '📊': [ + 'chart', + 'mobileCharts', 'taxBeta', 'taxes', 'pieChart', 'laptopCharts', - 'mobileCharts', - 'chart', - 'advancedTradingDesktop', 'trading', + 'advancedTradingDesktop', 'pieChartData', + 'instoTrading', ], '📉': [ + 'chart', + 'mobileCharts', 'taxBeta', 'taxes', - 'commerceInvoice', 'pieChart', - 'laptopCharts', - 'mobileCharts', - 'chart', + 'commerceInvoice', 'commerceCheckout', - 'calculator', - 'advancedTradingDesktop', + 'laptopCharts', 'trading', + 'advancedTradingDesktop', + 'calculator', 'pieChartData', + 'instoTrading', ], - '🥧': ['taxBeta', 'taxes', 'pieChart', 'pieChartData'], - america: ['usaProduct'], - '🇺🇸': ['usaProduct'], - flag: ['usaProduct'], - up: [ - 'trendingAssets', - 'monitoringPerformance', - 'higherLimits', - 'coinbaseOneEarn', - 'applyForHigherLimits', - 'increaseLimits', - ], - hot: ['trendingAssets'], - programming: ['typeScript'], - language: ['typeScript'], - microsoft: ['typeScript'], - typing: ['typeScript'], - peertopeer: ['peerToPeer'], - peer: ['peerToPeer'], - people: [ - 'peerToPeer', - 'newUserChecklistBuyCrypto', - 'newUserChecklistCompleteAccount', - 'addressBook', + notification: [ + 'alerts', + 'notificationHubAnalysis', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', + 'alertsCoinbaseOne', ], - transfer: ['peerToPeer'], + update: ['alerts', 'alertsCoinbaseOne'], + news: ['alerts', 'notificationHubNews', 'alertsCoinbaseOne'], + new: ['alerts', 'alertsCoinbaseOne'], + bell: ['alerts', 'alertsCoinbaseOne'], + '🔔': ['alerts', 'alertsCoinbaseOne'], + '🛎': ['alerts', 'alertsCoinbaseOne'], quick: ['timingCheck', 'fast'], simple: ['timingCheck'], timer: ['timingCheck'], - coinbase: [ - 'coinbaseOneTrusted', - 'coinbaseOneRefreshed', - 'coinbaseOneChat', - 'coinbaseOneShield', - 'coinbaseOneAuthenticator', - 'coinbaseOneTrade', - 'coinbaseWalletApp', - 'assetManagementNavigation', - 'coinbaseOneLogo', - 'coinbaseOneFiat', - 'restaking', - 'coinbaseOneEarnCoins', + 'chat bubble': ['coinbaseOneChat', 'chat'], + speech: ['coinbaseOneChat', 'supportChat', 'chat'], + communication: ['coinbaseOneChat', 'chat'], + social: ['coinbaseOneChat', 'chat', 'ssnCard', 'notificationHubSocial'], + interaction: ['coinbaseOneChat', 'chat'], + '💬': ['coinbaseOneChat', 'smsAuthenticate', 'chat'], + date: ['calendar', 'recurringPurchases', 'startToday', 'calendarHighlight'], + year: ['calendar', 'recurringPurchases', 'startToday'], + month: ['calendar', 'recurringPurchases', 'startToday'], + week: ['calendar', 'recurringPurchases', 'startToday'], + confirm: ['calendar', 'startToday'], + verify: ['calendar', 'newUserChecklistVerifyId', 'passport'], + '📆': [ + 'calendar', + 'noAnnualFee', + 'recurringPurchases', + 'tryAgainLater', + 'startToday', + 'taxSeason', ], - one: [ - 'coinbaseOneTrusted', - 'coinbaseOneRefreshed', - 'coinbaseOneChat', - 'coinbaseOneShield', - 'coinbaseOneAuthenticator', - 'coinbaseOneTrade', - 'coinbaseOneLogo', - 'coinbaseOneFiat', - 'restaking', - 'coinbaseOneEarnCoins', + '📅': [ + 'calendar', + 'noAnnualFee', + 'recurringPurchases', + 'tryAgainLater', + 'startToday', + 'taxSeason', ], - cb1: [ - 'coinbaseOneTrusted', - 'coinbaseOneRefreshed', - 'coinbaseOneChat', - 'coinbaseOneShield', - 'coinbaseOneProductIcon', - 'coinbaseOneAuthenticator', - 'coinbaseOneTrade', - 'coinbaseOneLogo', - 'coinbaseOneFiat', - 'restaking', - 'coinbaseOneEarnCoins', + '🗓': [ + 'calendar', + 'noAnnualFee', + 'recurringPurchases', + 'tryAgainLater', + 'startToday', + 'taxSeason', ], - confidence: ['coinbaseOneTrusted'], - joy: ['coinbaseOneTrusted', 'giftbox'], - care: ['coinbaseOneTrusted'], - belief: ['coinbaseOneTrusted'], - faith: ['coinbaseOneTrusted'], - '💕': ['coinbaseOneTrusted'], - '💙': ['coinbaseOneTrusted'], - '💜': ['coinbaseOneTrusted'], - '💗': ['coinbaseOneTrusted'], - '🖤': ['coinbaseOneTrusted'], - '💛': ['coinbaseOneTrusted'], - '💖': ['coinbaseOneTrusted'], - '💚': ['coinbaseOneTrusted'], - '🧡': ['coinbaseOneTrusted'], - '😍': ['coinbaseOneTrusted'], - '😻': ['coinbaseOneTrusted'], - person: [ - 'manageWeb3SignersAcct', - 'driversLicense', - 'ssnCard', - 'genericCountryIDCard', - 'driversLicenseWheel', - 'delegate', + cb: ['coinbaseWalletApp'], + app: [ + 'coinbaseWalletApp', + 'primeMobileApp', + 'standWithCryptoLogoNavigation', + 'browserMultiPlatform', ], - human: [ - 'manageWeb3SignersAcct', - 'driversLicense', - 'ssnCard', - 'genericCountryIDCard', - 'idVerification', - 'driversLicenseWheel', - 'myNumberCard', - 'identityCard', + device: [ + 'coinbaseWalletApp', + '2fa', + 'phone', + 'laptop', + 'browserTransaction', + 'internet', + 'browser', + 'addPhone', + 'laptopVideo', + 'authenticatorAlt', + 'smsAuthenticate', + 'mobileCharts', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'successPhone', + 'mobileNotifcation', + 'laptopCharts', + 'transistor', + 'hardwareWallet', + 'mobileWarning', + 'mobileError', ], - protection: [ + '📱': [ + 'coinbaseWalletApp', + '2fa', + 'phone', + 'laptop', + 'browserTransaction', + 'internet', + 'browser', + 'addPhone', + 'mobileCharts', + 'authenticationApp', + 'multiPlatform', + 'transferSend', + 'successPhone', + 'mobileNotifcation', + 'addressBook', 'manageWeb3SignersAcct', + 'primeMobileApp', + 'instoAddressBook', + ], + '🤳': [ + 'coinbaseWalletApp', + '2fa', + 'phone', + 'laptop', + 'browserTransaction', + 'internet', + 'browser', + 'addPhone', + 'mobileCharts', + 'authenticationApp', + 'multiPlatform', + 'successPhone', + 'mobileNotifcation', + 'addressBook', + 'instoAddressBook', + ], + '☎️': ['coinbaseWalletApp'], + shield: [ + 'coinbaseOneShield', 'key', + 'shield', + 'standWithCryptoLogoNavigation', + 'instoCoinbaseOneShield', + 'instoKey', + ], + protection: [ 'coinbaseOneShield', + 'key', 'idVerification', 'safe', 'shield', + 'manageWeb3SignersAcct', + 'instoCoinbaseOneShield', + 'instoKey', ], - '🆔': [ - 'manageWeb3SignersAcct', - 'newUserChecklistVerifyId', - 'cardSuccess', - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - ], - '🪪': ['manageWeb3SignersAcct'], - stake: ['wrapEth', 'ethToken'], - wrap: ['wrapEth', 'ethToken'], - ethereum: ['wrapEth', 'ethStaking', 'layerNetworks', 'ethRewards', 'ethStakingChart', 'ethToken'], - rush: ['wrapEth', 'ethToken'], - movement: ['wrapEth', 'ethToken'], - forward: ['wrapEth', 'ethToken'], - exciting: ['wrapEth', 'ethToken'], - calculator: ['taxes', 'calculator'], - '%': ['taxes', 'taxSeason'], - '🧮': ['taxes'], - connection: ['globalPayments', 'transistor'], - location: ['locationUsa'], - USA: ['locationUsa', 'dollarShowcase'], - license: ['driversLicense', 'ssnCard', 'genericCountryIDCard', 'driversLicenseWheel'], - documentation: [ - 'driversLicense', + guard: ['coinbaseOneShield', 'key', 'shield', 'instoCoinbaseOneShield', 'instoKey'], + defense: ['coinbaseOneShield', 'key', 'shield', 'instoCoinbaseOneShield', 'instoKey'], + cover: ['coinbaseOneShield', 'key', 'shield', 'instoCoinbaseOneShield', 'instoKey'], + safety: ['coinbaseOneShield', 'key', 'safe', 'shield', 'instoCoinbaseOneShield', 'instoKey'], + security: [ + 'coinbaseOneShield', + 'key', + 'safe', + 'shield', + 'ubiKey', 'ssnCard', - 'genericCountryIDCard', - 'formDownload', - 'bitcoinWhitePaper', - 'driversLicenseWheel', + 'manageWeb3SignersAcct', + 'securityKey', + 'instoCoinbaseOneShield', + 'instoKey', ], - kyc: ['driversLicense', 'ssnCard', 'genericCountryIDCard', 'driversLicenseWheel'], - identified: ['driversLicense', 'ssnCard', 'genericCountryIDCard', 'driversLicenseWheel'], - card: [ - 'driversLicense', - 'ssnCard', - 'cardBlocked', - 'newUserChecklistVerifyId', - 'cardSuccess', - 'addPayment', + diamond: [ + 'gem', + 'notifications', + 'easyToUse', + 'trusted', + 'barChart', + 'learn', + 'target', + 'instoGem', + 'instoEasyToUse', + ], + reward: [ + 'gem', + 'notifications', + 'easyToUse', + 'trusted', + 'barChart', + 'learn', + 'target', + 'instoGem', + 'instoEasyToUse', + ], + sparkle: [ + 'gem', + 'notifications', + 'explore', + 'easyToUse', + 'trusted', 'cryptoCard', - 'cardDeclined', - 'contactInfo', - 'genericCountryIDCard', - 'idVerification', - 'creditCard', - 'driversLicenseWheel', - 'addCard', - 'myNumberCard', - 'identityCard', + 'bundle', + 'barChart', + 'coinbaseOneTrusted', + 'learn', + 'target', + 'predictionMarkets', + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'priceTracking', + 'sparkleCoinbaseOne', 'moneyCrypto', + 'instoGem', + 'instoEasyToUse', ], - close: ['error'], - cross: ['error', 'cardBlocked', 'noAnnualFee', 'completeQuiz'], - decline: ['error'], - reject: ['error'], - no: ['error', 'noAnnualFee', 'governance'], - cancel: ['error', 'noAnnualFee'], - x: ['error'], - '❌': ['error', 'cardBlocked', 'noAnnualFee', 'noVisibility', 'noWiFi'], - '🙅': ['error', 'cardBlocked', 'noAnnualFee', 'noVisibility', 'noWiFi'], - '🙅‍♂️': ['error', 'cardBlocked', 'noAnnualFee', 'noVisibility', 'noWiFi'], - '🙅‍♀️': ['error', 'cardBlocked', 'noAnnualFee', 'noVisibility', 'noWiFi'], - '🚫': ['error'], - '❎': ['error'], - social: ['ssnCard', 'coinbaseOneChat', 'chat', 'notificationHubSocial'], - SSN: ['ssnCard'], - number: ['ssnCard'], - downwards: ['settled', 'lowFees', 'moneyEarn'], - down: ['settled', 'formDownload', 'lowFees', 'moneyEarn'], - direction: ['settled', 'lowFees', 'applyForHigherLimits', 'increaseLimits', 'moneyEarn'], - '👇': ['settled', 'formDownload', 'lowFees', 'moneyEarn'], - '⬇️': ['settled', 'formDownload', 'lowFees', 'enableVoting', 'moneyEarn'], - '🔻': ['settled', 'formDownload', 'lowFees', 'moneyEarn'], - portfolio: ['monitoringPerformance', 'notificationHubPortfolio'], - eth: ['ethStaking', 'ethStakingRewards', 'ethRewards', 'ethStakingChart', 'ethToken'], - cancelled: ['cardBlocked', 'cardDeclined'], - declined: ['cardBlocked'], - credit: [ - 'cardBlocked', - 'cardSuccess', + crystal: [ + 'gem', + 'notifications', + 'easyToUse', + 'trusted', + 'barChart', + 'learn', + 'target', + 'instoGem', + 'instoEasyToUse', + ], + '💎': [ + 'gem', + 'notifications', + 'easyToUse', + 'trusted', 'addPayment', + 'barChart', + 'learn', + 'target', + 'instoGem', + 'instoEasyToUse', + ], + '💍': [ + 'gem', + 'notifications', + 'easyToUse', + 'trusted', + 'barChart', + 'learn', + 'target', + 'instoGem', + 'instoEasyToUse', + ], + '❇️': [ + 'gem', + 'notifications', + 'explore', + 'easyToUse', + 'trusted', 'cryptoCard', - 'cardDeclined', - 'creditCard', - 'addCard', + 'bundle', + 'addToWatchlist', + 'barChart', + 'coinbaseOneTrusted', + 'learn', + 'target', + 'predictionMarkets', + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'priceTracking', 'moneyCrypto', + 'instoGem', + 'instoEasyToUse', ], - alert: [ - 'cardBlocked', - 'walletWarning', - 'walletError', - 'cardDeclined', - 'strongWarning', - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', + refresh: [ + 'coinbaseOneRefreshed', + 'recurringPurchases', + 'tryAgainLater', + 'restaking', + 'instoRestaking', + ], + restore: ['coinbaseOneRefreshed', 'restaking', 'instoRestaking'], + refill: ['coinbaseOneRefreshed', 'restaking', 'instoRestaking'], + exclamation: ['riskStaking', 'instoRiskStaking'], + risk: ['riskStaking', 'futures', 'futuresCoinbaseOne', 'instoRiskStaking'], + wrapping: ['riskStaking', 'instoRiskStaking'], + ETH: ['riskStaking', 'instoRiskStaking'], + market: ['riskStaking', 'sellSendAnytime', 'instoRiskStaking'], + world: ['sellSendAnytime', 'moneySwift'], + send: ['sellSendAnytime', 'email', 'lightningNetworkSend', 'peerToPeer', 'usdcLoan', 'leadGraph'], + sending: ['sellSendAnytime'], + anytime: ['sellSendAnytime'], + now: ['sellSendAnytime'], + money: [ + 'sellSendAnytime', + 'lowFees', + 'transferSend', + 'institutions', + 'listingFees', + 'commerceInvoice', + 'commerceCheckout', + 'moneySwift', + 'fiat', + 'creditCard', + 'taxSeason', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'assetManagement', + 'coinbaseOneEarn', + 'bigBtcSend', + 'download', + 'instoFiat', ], - crucial: ['cardBlocked', 'walletWarning', 'walletError', 'cardDeclined', 'strongWarning'], - indication: ['cardBlocked', 'walletWarning', 'walletError', 'cardDeclined', 'strongWarning'], - emphasis: ['cardBlocked', 'cardDeclined', 'strongWarning'], - '💳': [ - 'cardBlocked', + sell: ['sellSendAnytime', 'futures', 'futuresCoinbaseOne'], + selling: ['sellSendAnytime'], + trust: [ + 'authenticatorProgress', + 'authenticatorAlt', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'coinbaseOneAuthenticator', + 'ubiKey', + 'securityKey', + 'instoAuthenticatorProgress', + ], + true: [ + 'authenticatorProgress', + 'authenticatorAlt', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'coinbaseOneAuthenticator', + 'ubiKey', + 'securityKey', + 'instoAuthenticatorProgress', + ], + genuine: [ + 'authenticatorProgress', + 'authenticatorAlt', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'coinbaseOneAuthenticator', + 'ubiKey', + 'securityKey', + 'instoAuthenticatorProgress', + ], + actual: [ + 'authenticatorProgress', + 'authenticatorAlt', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'coinbaseOneAuthenticator', + 'ubiKey', + 'securityKey', + 'instoAuthenticatorProgress', + ], + verification: [ + 'authenticatorProgress', + 'authenticatorAlt', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', 'newUserChecklistVerifyId', - 'cardSuccess', - 'addPayment', - 'cryptoCard', - 'cardDeclined', - 'creditCard', - 'addCard', - 'moneyCrypto', + 'coinbaseOneAuthenticator', + 'ubiKey', + 'securityKey', + 'passport', + 'instoAuthenticatorProgress', ], - 'error state': [ - 'cardBlocked', - 'mobileError', - 'walletError', - 'strongWarning', - 'calendarCaution', - 'noWiFi', + 'semi custodial': ['custodialJourney'], + bank: ['custodialJourney', 'addPayment', 'institutions', 'fiat', 'coinbaseOneFiat', 'instoFiat'], + downwards: ['settled', 'lowFees', 'moneyEarn', 'download'], + down: ['settled', 'lowFees', 'formDownload', 'moneyEarn', 'download'], + direction: [ + 'settled', + 'lowFees', + 'applyForHigherLimits', + 'increaseLimits', + 'moneyEarn', + 'download', ], + '👇': ['settled', 'lowFees', 'formDownload', 'moneyEarn', 'download'], + '⬇️': ['settled', 'lowFees', 'formDownload', 'enableVoting', 'moneyEarn', 'download'], + '🔻': ['settled', 'lowFees', 'formDownload', 'moneyEarn', 'download'], two: [ - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + '2fa', 'phone', - 'browser', - 'mobileNotifcation', 'laptop', - '2fa', + 'browserTransaction', + 'internet', + 'browser', + 'addPhone', + 'successPhone', + 'mobileNotifcation', ], factor: [ - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + '2fa', 'phone', + 'laptop', + 'browserTransaction', + 'internet', 'browser', + 'addPhone', + 'successPhone', 'mobileNotifcation', - 'laptop', - '2fa', ], authentication: [ - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + '2fa', 'phone', - 'browser', - 'mobileNotifcation', 'laptop', - '2fa', - ], - device: [ - 'successPhone', - 'mobileError', - 'internet', - 'addPhone', - 'mobileWarning', 'browserTransaction', - 'phone', + 'internet', 'browser', - 'laptopCharts', - 'authenticatorAlt', - 'mobileCharts', + 'addPhone', + 'successPhone', 'mobileNotifcation', - 'laptopVideo', - 'laptop', - 'coinbaseWalletApp', - 'authenticationApp', - 'smsAuthenticate', - 'authenticator', - 'transistor', - '2fa', - 'hardwareWallet', - 'googleAuthenticator', ], - '🤳': [ - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + mobile: [ + '2fa', 'phone', + 'laptop', + 'browserTransaction', + 'internet', 'browser', + 'addPhone', 'mobileCharts', + 'authenticationApp', + 'successPhone', 'mobileNotifcation', + 'mobileWarning', + 'mobileError', + 'mobileSuccess', + 'primeMobileApp', + 'browserMultiPlatform', + ], + support: [ + '2fa', + 'phone', 'laptop', - 'coinbaseWalletApp', + 'browserTransaction', + 'internet', + 'browser', + 'addPhone', 'authenticationApp', - 'multiPlatform', - '2fa', - 'addressBook', + 'supportChat', + 'successPhone', + 'mobileNotifcation', ], '📳': [ - 'successPhone', - 'internet', - 'addPhone', - 'browserTransaction', + '2fa', 'phone', + 'laptop', + 'browserTransaction', + 'internet', 'browser', + 'addPhone', 'mobileCharts', - 'mobileNotifcation', - 'laptop', 'authenticationApp', 'multiPlatform', - '2fa', + 'successPhone', + 'mobileNotifcation', 'addressBook', + 'instoAddressBook', ], - 'self custody': ['selfServe', 'selfCustodyWallet'], - user: ['selfServe', 'nftLibrary', 'custodialJourney', 'selfCustodyWallet'], - art: ['creative'], - palette: ['creative'], - lock: ['key', 'security', 'lock', 'passwordWalletLocked'], - secure: ['key', 'cardSuccess', 'idVerification', 'securedAssets'], - guard: ['key', 'coinbaseOneShield', 'shield'], - defense: ['key', 'coinbaseOneShield', 'shield'], - cover: ['key', 'coinbaseOneShield', 'shield'], - safety: ['key', 'coinbaseOneShield', 'safe', 'shield'], - '🔒': ['key', 'security', 'lock'], share: ['coinShare'], 'social media': ['coinShare'], - storage: [ - 'walletWarning', - 'walletError', - 'addWallet', - 'wallet', - 'walletDeposit', - 'walletExchange', - 'securedAssets', - 'safe', - ], - 'crypto transactions': [ - 'walletWarning', - 'addWallet', - 'wallet', - 'walletDeposit', - 'walletExchange', - ], - pay: ['walletWarning', 'addWallet', 'creditCard', 'wallet', 'walletDeposit', 'walletExchange'], - retrieve: ['walletWarning', 'addWallet', 'wallet', 'walletDeposit', 'walletExchange'], - 'digital assets': ['walletWarning', 'addWallet', 'wallet', 'walletDeposit', 'walletExchange'], - 'exclamation mark': ['walletWarning', 'strongWarning'], - '⚠️': ['walletWarning', 'idBlock', 'walletError', 'cardDeclined', 'idError'], - Asset: ['assetManagement'], - management: ['assetManagement', 'assetManagementNavigation'], - currency: [ - 'assetManagement', - 'higherLimits', - 'fiat', - 'listingFees', - 'moneySwift', - 'institutions', - 'lowFees', - 'coinbaseOneFiat', - 'moneyEarn', - ], - beginner: ['crypto101'], - percentage: ['taxSeason', 'defiEarnMoment'], - funds: ['taxSeason'], - list: [ - 'commerceInvoice', - 'addToWatchlist', - 'orders', - 'commerceCheckout', - 'newUserChecklistBuyCrypto', - 'newUserChecklistCompleteAccount', - ], - commerce: ['commerceInvoice', 'receipt', 'commerceCheckout'], - invoice: ['commerceInvoice', 'commerceCheckout'], - receipt: ['commerceInvoice', 'commerceCheckout'], - form: [ - 'commerceInvoice', - 'orders', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'applyForHigherLimits', - ], - document: [ - 'commerceInvoice', - 'orders', - 'nftLibrary', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'taxesArrangement', - ], - report: ['commerceInvoice', 'formDownload', 'bitcoinWhitePaper', 'commerceCheckout'], - plus: [ - 'commerceInvoice', - 'addPhone', - 'addToWatchlist', - 'addWallet', - 'coinbaseOneEarnCoinsLogo', - 'futures', - 'futuresCoinbaseOne', - 'commerceCheckout', - 'multipleAssets', - 'add', - 'moreThanBitcoin', - 'earnCoins', - 'coinbaseOneEarnCoins', - ], - contract: ['commerceInvoice', 'formDownload', 'bitcoinWhitePaper', 'commerceCheckout'], - '📄': [ - 'commerceInvoice', - 'orders', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'applyForHigherLimits', + connections: ['sideChainSide'], + borrow: ['borrowCoins', 'instoBorrowCoins'], + equal: ['taxesArrangement'], + magic: ['mintedNft'], + rabbit: ['mintedNft'], + hat: ['mintedNft'], + limited: ['mintedNft', 'apartOfDropsNft'], + sparkles: ['mintedNft', 'bitcoinRewards', 'bigBtcSend'], + mint: ['mintedNft', 'selectAddNft'], + minted: ['mintedNft'], + hot: ['trendingAssets'], + laptop: ['laptopVideo', 'laptopCharts'], + computer: ['laptopVideo', 'laptopCharts', 'advancedTradingDesktop'], + charts: ['laptopVideo', 'taxBeta', 'taxes', 'laptopCharts', 'calculator'], + media: ['laptopVideo', 'notificationHubSocial'], + video: ['laptopVideo'], + '🎥': ['laptopVideo'], + '📹': ['laptopVideo'], + '▶️': ['laptopVideo'], + '💻': ['laptopVideo', 'multiPlatform', 'laptopCharts', 'advancedTradingDesktop'], + '👩‍💻': [ + 'laptopVideo', + 'idVerification', + 'identityCard', + 'multiPlatform', + 'laptopCharts', + 'contactInfo', + 'advancedTradingDesktop', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '📃': [ - 'commerceInvoice', - 'orders', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'applyForHigherLimits', + '🧑‍💻': [ + 'laptopVideo', + 'idVerification', + 'identityCard', + 'multiPlatform', + 'laptopCharts', + 'contactInfo', + 'advancedTradingDesktop', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '📜': [ - 'commerceInvoice', - 'orders', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'applyForHigherLimits', + '👨‍💻': [ + 'laptopVideo', + 'idVerification', + 'identityCard', + 'multiPlatform', + 'laptopCharts', + 'contactInfo', + 'advancedTradingDesktop', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '📑': [ - 'commerceInvoice', - 'orders', - 'formDownload', - 'bitcoinWhitePaper', - 'commerceCheckout', - 'applyForHigherLimits', + collection: ['bundle', 'addToWatchlist', 'selectAddNft'], + bulk: ['bundle'], + location: ['locationUsa'], + USA: ['locationUsa', 'dollarShowcase'], + lock: [ + 'key', + 'lock', + 'passwordWalletLocked', + 'security', + 'instoPasswordWalletLocked', + 'instoKey', ], - '🧾': ['commerceInvoice', 'receipt', 'commerceCheckout'], - confirmation: ['idBlock', 'done', 'idError', 'checkmark'], - bad: ['idBlock', 'idError'], - medal: ['accreditedInvestor', 'premiumInvestor'], - accredited: ['accreditedInvestor', 'premiumInvestor'], - investor: ['accreditedInvestor', 'premiumInvestor'], - singapore: ['accreditedInvestor', 'premiumInvestor'], - VIP: ['accreditedInvestor', 'premiumInvestor'], - award: ['accreditedInvestor', 'premiumInvestor'], - cellphone: ['mobileError', 'mobileWarning', 'mobileCharts'], - checkmark: [ + secure: [ + 'key', + 'idVerification', + 'securedAssets', 'cardSuccess', - 'listingFees', - 'done', + 'instoKey', + 'instoSecuredAssets', + ], + protect: ['key', 'safe', 'googleAuthenticator', 'ubiKey', 'securityKey', 'instoKey'], + key: ['key', 'ubiKey', 'securityKey', 'instoKey'], + '🔑': ['key', 'lock', 'security', 'ubiKey', 'securityKey', 'instoKey'], + '🗝': ['key', 'lock', 'security', 'ubiKey', 'securityKey', 'instoKey'], + '🔐': ['key', 'lock', 'security', 'ubiKey', 'securityKey', 'instoKey'], + '🔒': ['key', 'lock', 'security', 'instoKey'], + '2fa': [ + 'authenticatorAlt', 'idVerification', - 'checkmark', - 'takeQuiz', - 'tokenBaskets', - 'enableVoting', - 'delegate', + 'smsAuthenticate', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + 'ubiKey', + 'securityKey', ], - '✔️': [ - 'cardSuccess', - 'listingFees', - 'done', + authenticate: [ + 'authenticatorAlt', + 'smsAuthenticate', + 'authenticator', + 'authenticationApp', + 'googleAuthenticator', + ], + proof: ['receipt'], + commerce: ['receipt', 'commerceInvoice', 'commerceCheckout'], + purchase: ['receipt'], + stub: ['receipt'], + income: ['receipt'], + revenue: ['receipt'], + '🧾': ['receipt', 'commerceInvoice', 'commerceCheckout'], + '🏷': ['receipt', 'commerceCheckout', 'priceTracking'], + 'identity card': ['idVerification', 'identityCard', 'contactInfo', 'myNumberCard'], + profile: ['idVerification', 'identityCard', 'contactInfo', 'myNumberCard'], + personal: ['idVerification', 'identityCard', 'contactInfo', 'myNumberCard'], + ID: ['idVerification', 'identityCard', 'contactInfo', 'myNumberCard'], + human: [ 'idVerification', - 'checkmark', - 'takeQuiz', - 'tokenBaskets', - 'enableVoting', - 'delegate', + 'identityCard', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'myNumberCard', + 'manageWeb3SignersAcct', ], - learn: ['lightbulbLearn'], - bulb: ['lightbulbLearn'], - premium: ['premiumInvestor'], - cash: ['higherLimits'], - '💲': ['higherLimits'], - '👆': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - '☝️': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - '🆙': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - '⬆️': ['higherLimits', 'applyForHigherLimits', 'enableVoting', 'increaseLimits'], - '🔝': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - '🔼': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - '🔺': ['higherLimits', 'applyForHigherLimits', 'increaseLimits'], - bank: ['addPayment', 'fiat', 'institutions', 'coinbaseOneFiat', 'custodialJourney'], - '🏧': ['addPayment', 'fiat', 'institutions', 'creditCard', 'coinbaseOneFiat'], - '🏦': ['addPayment', 'fiat', 'moneySwift', 'institutions', 'creditCard', 'coinbaseOneFiat'], - fund: ['fiat', 'institutions', 'coinbaseOneFiat'], - stock: ['fiat', 'institutions', 'coinbaseOneFiat'], - building: ['fiat', 'institutions', 'coinbaseOneFiat'], - institution: ['fiat', 'institutions', 'coinbaseOneFiat'], - addition: ['addPhone', 'addToWatchlist', 'add'], - fees: ['listingFees', 'gasFees', 'assetManagementNavigation', 'calculator', 'noAnnualFee'], - listing: ['listingFees', 'addToWatchlist'], - 'warning state': ['mobileWarning', 'cardDeclined'], - tick: ['done', 'checkmark'], - success: ['done', 'checkmark'], - positive: ['done', 'checkmark'], - done: ['done'], - watching: ['addToWatchlist'], - collection: ['addToWatchlist', 'bundle', 'selectAddNft'], - order: ['orders'], - inventory: ['orders'], - records: ['orders'], - One: ['btcOneHundred', 'coinbaseOneProductIcon'], - Hundred: ['btcOneHundred'], - '🌎': ['moneySwift', 'worldwide'], - '🌍': ['moneySwift', 'worldwide'], - '🌏': ['moneySwift', 'worldwide'], - '🌐': ['moneySwift', 'worldwide'], - searching: ['noNftFound'], - NFT: ['noNftFound', 'mintedNft', 'apartOfDropsNft', 'selectAddNft'], - picture: ['noNftFound', 'selectAddNft'], - magnifying: ['noNftFound', 'notificationHubAnalysis'], - magnifyGlass: ['noNftFound'], - special: ['noNftFound', 'mintedNft', 'apartOfDropsNft'], - missing: ['noNftFound'], - unfound: ['noNftFound'], - clear: ['noNftFound'], - filter: ['noNftFound'], - '🖼': ['noNftFound', 'mintedNft', 'apartOfDropsNft', 'selectAddNft'], - additional: [ - 'addWallet', - 'coinbaseOneEarnCoinsLogo', - 'multipleAssets', - 'moreThanBitcoin', - 'earnCoins', - 'coinbaseOneEarnCoins', + '🆔': [ + 'idVerification', + 'identityCard', + 'newUserChecklistVerifyId', + 'contactInfo', + 'myNumberCard', + 'cardSuccess', + 'manageWeb3SignersAcct', ], - return: ['advancedTradingRebates'], - exchange: ['advancedTradingRebates', 'internationalExchangeNavigation'], - rebate: ['advancedTradingRebates'], - bar: ['coinbaseOneEarn'], - grow: ['coinbaseOneEarn'], - future: ['coinbaseOneEarn', 'futures', 'futuresCoinbaseOne'], - gear: ['settings'], - settings: ['settings'], - browser: ['settings'], - cog: ['settings'], - machine: ['settings'], - tool: ['settings'], - '⚙️': ['settings'], - '!': ['cardDeclined', 'strongWarning'], - '❗️': ['cardDeclined', 'strongWarning'], - 'gas fees': ['gasFees'], - 'fuel tank': ['gasFees'], - borrow: ['borrowCoins'], - 'identity card': ['contactInfo', 'idVerification', 'myNumberCard', 'identityCard'], - profile: ['contactInfo', 'idVerification', 'myNumberCard', 'identityCard'], - personal: ['contactInfo', 'idVerification', 'myNumberCard', 'identityCard'], - ID: ['contactInfo', 'idVerification', 'myNumberCard', 'identityCard'], '👶': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👧': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧒': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👦': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🦱': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🦱': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🦱': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🦰': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🦰': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🦰': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👱‍♀️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👱': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👱‍♂️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🦳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🦳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🦳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🦲': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🦲': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🦲': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧔': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👵': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧓': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👴': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👲': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👳‍♀️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👳‍♂️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧕': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👮‍♀️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👮': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👮‍♂️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👷‍♀️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👷': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👷‍♂️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '💂‍♀️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '💂': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '💂‍♂️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍⚕️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍⚕️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍⚕️': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🌾': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🌾': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🌾': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🍳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🍳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🍳': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🎓': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🎓': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🎓': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🎤': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🎤': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🎤': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🏫': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🏫': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🏫': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🏭': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🏭': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', - 'delegate', - 'addressBook', - ], - '👨‍🏭': [ 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', 'delegate', 'addressBook', - ], - '👩‍💻': [ - 'contactInfo', - 'laptopCharts', - 'idVerification', - 'laptopVideo', - 'advancedTradingDesktop', 'myNumberCard', - 'identityCard', - 'delegate', - 'multiPlatform', - 'addressBook', + 'instoDelegate', + 'instoAddressBook', ], - '🧑‍💻': [ - 'contactInfo', - 'laptopCharts', + '👨‍🏭': [ 'idVerification', - 'laptopVideo', - 'advancedTradingDesktop', - 'myNumberCard', 'identityCard', - 'delegate', - 'multiPlatform', - 'addressBook', - ], - '👨‍💻': [ 'contactInfo', - 'laptopCharts', - 'idVerification', - 'laptopVideo', - 'advancedTradingDesktop', - 'myNumberCard', - 'identityCard', 'delegate', - 'multiPlatform', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍💼': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍💼': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍💼': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🔧': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🔧': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🔧': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🔬': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🔬': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👨‍🔬': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '👩‍🎨': [ - 'contactInfo', 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], '🧑‍🎨': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👨‍🎨': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👩‍🚒': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👨‍🎨': [ + '🧑‍🚒': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👨‍🚒': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👩‍✈️': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👩‍🚒': [ + '🧑‍✈️': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👨‍✈️': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👩‍🚀': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🧑‍🚒': [ + '🧑‍🚀': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👨‍🚀': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '👩‍⚖️': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👨‍🚒': [ + '🤵‍♀️': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🤵': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🤵‍♂️': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👩‍✈️': [ + '👸': [ + 'idVerification', + 'identityCard', 'contactInfo', + 'delegate', + 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🤴': [ 'idVerification', + 'identityCard', + 'contactInfo', + 'delegate', + 'addressBook', 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + '🥷': [ + 'idVerification', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🧑‍✈️': [ - 'contactInfo', + '🦸‍♀️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👨‍✈️': [ - 'contactInfo', + '🦸': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👩‍🚀': [ - 'contactInfo', + '🦸‍♂️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🧑‍🚀': [ - 'contactInfo', + '🦹‍♀️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👨‍🚀': [ - 'contactInfo', + '🦹': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👩‍⚖️': [ - 'contactInfo', + '🦹‍♂️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🤵‍♀️': [ - 'contactInfo', + '🤶': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🤵': [ - 'contactInfo', + '🧑‍🎄': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🤵‍♂️': [ - 'contactInfo', + '🎅': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '👸': [ - 'contactInfo', + '🧙‍♀️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'predictionMarkets', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🤴': [ - 'contactInfo', + '🧙': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'predictionMarkets', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🥷': [ - 'contactInfo', + '🧙‍♂️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'predictionMarkets', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦸‍♀️': [ - 'contactInfo', + '🧝‍♀️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦸': [ - 'contactInfo', + '🧝': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦸‍♂️': [ - 'contactInfo', + '🧝‍♂️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦹‍♀️': [ - 'contactInfo', + '🧛‍♀️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦹': [ - 'contactInfo', + '🧛': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', ], - '🦹‍♂️': [ - 'contactInfo', + '🧛‍♂️': [ 'idVerification', - 'myNumberCard', 'identityCard', + 'contactInfo', 'delegate', 'addressBook', + 'myNumberCard', + 'instoDelegate', + 'instoAddressBook', + ], + text: ['smsAuthenticate'], + sms: ['smsAuthenticate'], + governance: ['governance'], + vote: ['governance'], + proposal: ['governance'], + ballot: ['governance'], + box: ['governance'], + yes: ['governance'], + maybe: ['governance'], + so: ['governance'], + details: ['addPayment', 'strongInfo'], + '🏧': ['addPayment', 'institutions', 'fiat', 'creditCard', 'coinbaseOneFiat', 'instoFiat'], + '🏦': [ + 'addPayment', + 'institutions', + 'moneySwift', + 'fiat', + 'creditCard', + 'coinbaseOneFiat', + 'instoFiat', + ], + '💸': [ + 'addPayment', + 'wallet', + 'addWallet', + 'walletExchange', + 'lowFees', + 'walletDeposit', + 'transferSend', + 'institutions', + 'listingFees', + 'commerceInvoice', + 'commerceCheckout', + 'moneySwift', + 'fiat', + 'walletWarning', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'walletError', + 'assetManagement', + 'download', + 'instoWalletWarning', + 'instoFiat', + ], + '💵': [ + 'addPayment', + 'wallet', + 'addWallet', + 'walletExchange', + 'lowFees', + 'walletDeposit', + 'transferSend', + 'institutions', + 'listingFees', + 'moneySwift', + 'fiat', + 'walletWarning', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'walletError', + 'assetManagement', + 'download', + 'instoWalletWarning', + 'instoFiat', + ], + '💶': [ + 'addPayment', + 'lowFees', + 'transferSend', + 'institutions', + 'listingFees', + 'moneySwift', + 'fiat', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'assetManagement', + 'download', + 'instoFiat', + ], + '💷': [ + 'addPayment', + 'lowFees', + 'transferSend', + 'institutions', + 'listingFees', + 'moneySwift', + 'fiat', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'assetManagement', + 'download', + 'instoFiat', + ], + '💴': [ + 'addPayment', + 'lowFees', + 'transferSend', + 'institutions', + 'listingFees', + 'moneySwift', + 'fiat', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'assetManagement', + 'download', + 'instoFiat', + ], + '🪙': [ + 'addPayment', + 'addToWatchlist', + 'tokenBaskets', + 'walletDeposit', + 'transferSend', + 'earnCoins', + 'defiEarnMoment', + 'moreThanBitcoin', + 'bitcoinPizza', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'coinbaseOneEarnCoinsLogo', + 'instoEarnCoins', + ], + cellphone: ['mobileCharts', 'mobileWarning', 'mobileError'], + '📲': [ + 'mobileCharts', + 'multiPlatform', + 'transferSend', + 'addressBook', + 'primeMobileApp', + 'instoAddressBook', + ], + some: ['partialCoins'], + part: ['partialCoins'], + divided: ['partialCoins'], + list: [ + 'addToWatchlist', + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'commerceInvoice', + 'commerceCheckout', + 'orders', + ], + watching: ['addToWatchlist'], + listing: ['addToWatchlist', 'listingFees'], + beta: ['taxBeta'], + taxes: ['taxBeta', 'taxes', 'taxSeason', 'calculator'], + pie: ['taxBeta', 'taxes'], + '🥧': ['taxBeta', 'taxes', 'pieChart', 'pieChartData'], + token: ['tokenBaskets'], + '🧺': ['tokenBaskets'], + storage: [ + 'wallet', + 'addWallet', + 'safe', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'securedAssets', + 'walletError', + 'instoWalletWarning', + 'instoSecuredAssets', ], - '🤶': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + 'crypto transactions': [ + 'wallet', + 'addWallet', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'instoWalletWarning', ], - '🧑‍🎄': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + pay: [ + 'wallet', + 'addWallet', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'creditCard', + 'instoWalletWarning', ], - '🎅': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + retrieve: [ + 'wallet', + 'addWallet', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'instoWalletWarning', ], - '🧝‍♀️': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + 'digital assets': [ + 'wallet', + 'addWallet', + 'walletExchange', + 'walletDeposit', + 'walletWarning', + 'instoWalletWarning', ], - '🧝': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + '💰': [ + 'wallet', + 'addWallet', + 'walletExchange', + 'lowFees', + 'walletDeposit', + 'transferSend', + 'listingFees', + 'commerceInvoice', + 'commerceCheckout', + 'walletWarning', + 'higherLimits', + 'moneyEarn', + 'walletError', + 'assetManagement', + 'download', + 'instoWalletWarning', ], - '🧝‍♂️': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + additional: [ + 'addWallet', + 'earnCoins', + 'moreThanBitcoin', + 'multipleAssets', + 'coinbaseOneEarnCoins', + 'coinbaseOneEarnCoinsLogo', + 'instoEarnCoins', ], - '🧛‍♀️': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + calculator: ['taxes', 'calculator'], + '%': ['taxes', 'taxSeason'], + '🧮': ['taxes'], + safe: ['safe'], + crypt: ['safe'], + below: ['lowFees', 'download'], + currency: [ + 'lowFees', + 'institutions', + 'listingFees', + 'moneySwift', + 'fiat', + 'higherLimits', + 'coinbaseOneFiat', + 'moneyEarn', + 'assetManagement', + 'download', + 'instoFiat', ], - '🧛': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + 'no access': ['lock', 'security'], + latch: ['lock', 'security'], + blocked: ['lock', 'security'], + zero: ['noAnnualFee'], + application: ['applyForHigherLimits'], + form: [ + 'applyForHigherLimits', + 'formDownload', + 'bitcoinWhitePaper', + 'commerceInvoice', + 'commerceCheckout', + 'orders', ], - '🧛‍♂️': [ - 'contactInfo', - 'idVerification', - 'myNumberCard', - 'identityCard', - 'delegate', - 'addressBook', + checklist: ['applyForHigherLimits'], + upwards: ['applyForHigherLimits', 'increaseLimits'], + '👆': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '☝️': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '🆙': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '⬆️': ['applyForHigherLimits', 'increaseLimits', 'higherLimits', 'enableVoting'], + '🔝': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '🔼': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '🔺': ['applyForHigherLimits', 'increaseLimits', 'higherLimits'], + '📄': [ + 'applyForHigherLimits', + 'formDownload', + 'bitcoinWhitePaper', + 'commerceInvoice', + 'commerceCheckout', + 'orders', ], - 'chart pie': ['pieChart', 'pieChartData'], - square: ['nftLibrary', 'videoContent', 'coldStorageCheck', 'passwordWalletLocked'], - music: ['nftLibrary', 'musicAndSounds'], - 'music note': ['nftLibrary', 'musicAndSounds'], - play: ['nftLibrary', 'laptopVideo', 'videoCalendar', 'videoContent'], - digital: ['nftLibrary'], - collectibles: ['nftLibrary'], - nfts: ['nftLibrary'], - agent: ['agent', 'delegate'], - chat: ['agent'], - envelope: ['envelope', 'email'], - letter: ['envelope', 'email'], - email: ['envelope'], - message: ['envelope', 'coinbaseOneChat', 'chat', 'smsAuthenticate', 'email'], - '💌': ['envelope', 'email'], - '✉️': ['envelope', 'email'], - '📨': ['envelope', 'email'], - '📩': ['envelope', 'email'], - '📧': ['envelope', 'email'], - blockchain: ['blockchainConnection', 'defiEarnMoment'], - sequence: ['blockchainConnection'], - compliance: ['complianceNavigation'], - regulatory: ['complianceNavigation'], - certified: ['complianceNavigation'], - ribbon: ['complianceNavigation', 'bitcoinRewards', 'ethRewards', 'learningRewardsProduct'], - bow: ['complianceNavigation'], - icon: ['complianceNavigation', 'coinbaseOneProductIcon'], - notification: [ - 'alertsCoinbaseOne', - 'alerts', - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', + '📃': [ + 'applyForHigherLimits', + 'formDownload', + 'bitcoinWhitePaper', + 'commerceInvoice', + 'commerceCheckout', + 'orders', ], - update: ['alertsCoinbaseOne', 'alerts'], - news: ['alertsCoinbaseOne', 'alerts', 'notificationHubNews'], - new: ['alertsCoinbaseOne', 'alerts'], - bell: ['alertsCoinbaseOne', 'alerts'], - '🔔': ['alertsCoinbaseOne', 'alerts'], - '🛎': ['alertsCoinbaseOne', 'alerts'], - vertical: ['genericCountryIDCard'], - state: ['genericCountryIDCard'], - restore: ['coinbaseOneRefreshed', 'restaking'], - refill: ['coinbaseOneRefreshed', 'restaking'], - laptop: ['laptopCharts', 'laptopVideo'], - computer: ['laptopCharts', 'laptopVideo', 'advancedTradingDesktop'], - '💻': ['laptopCharts', 'laptopVideo', 'advancedTradingDesktop', 'multiPlatform'], - authenticate: [ - 'authenticatorAlt', - 'authenticationApp', - 'smsAuthenticate', - 'authenticator', - 'googleAuthenticator', + '📜': [ + 'applyForHigherLimits', + 'formDownload', + 'bitcoinWhitePaper', + 'commerceInvoice', + 'commerceCheckout', + 'orders', + ], + '📑': [ + 'applyForHigherLimits', + 'formDownload', + 'bitcoinWhitePaper', + 'commerceInvoice', + 'commerceCheckout', + 'orders', ], - folder: ['decentralizedIdentity', 'cryptoFolder'], - 'chat bubble': ['coinbaseOneChat', 'chat'], - communication: ['coinbaseOneChat', 'chat'], - interaction: ['coinbaseOneChat', 'chat'], - '💬': ['coinbaseOneChat', 'chat', 'smsAuthenticate'], - Coinbase: ['coinbaseOneProductIcon'], - cb2: ['coinbaseOneProductIcon'], - cbinfinity: ['coinbaseOneProductIcon'], - 'chart bar': ['mobileCharts', 'chart'], - debit: ['creditCard'], - media: ['laptopVideo', 'notificationHubSocial'], - video: ['laptopVideo'], - '🎥': ['laptopVideo'], - '📹': ['laptopVideo'], - '▶️': ['laptopVideo'], - bulk: ['bundle'], paper: ['formDownload', 'bitcoinWhitePaper', 'notificationHubNews'], download: ['formDownload'], - trade: ['coinbaseOneTrade', 'ethStakingRewards', 'calculator'], - eth2: ['ethStakingRewards'], - 'stacks of coins': ['ethStakingRewards'], - decentralized: ['defiEarnMoment'], - finance: ['defiEarnMoment'], - exchanges: ['defiEarnMoment'], - cb: ['coinbaseWalletApp'], - '☎️': ['coinbaseWalletApp'], - asset: ['assetManagementNavigation', 'bigBtcSend', 'ethRewards', 'ethStakingChart', 'ethToken'], - aum: ['assetManagementNavigation'], - broker: ['assetManagementNavigation'], + documentation: [ + 'formDownload', + 'bitcoinWhitePaper', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + ], + report: ['formDownload', 'bitcoinWhitePaper', 'commerceInvoice', 'commerceCheckout'], + contract: ['formDownload', 'bitcoinWhitePaper', 'commerceInvoice', 'commerceCheckout'], + reoccur: ['recurringPurchases'], + regular: ['recurringPurchases'], + organize: ['recurringPurchases', 'taxSeason'], + platform: [ + 'multiPlatform', + 'developerPlatformNavigation', + 'developerSDKNavigation', + 'verifiedPools', + ], + multiple: ['multiPlatform'], + devices: ['multiPlatform'], + screens: ['multiPlatform'], + types: ['multiPlatform'], + programming: ['typeScript'], + language: ['typeScript'], + microsoft: ['typeScript'], + typing: ['typeScript'], + later: ['tryAgainLater'], + attempt: ['tryAgainLater'], + reschedule: ['tryAgainLater'], + speed: ['fast', 'lightningNetworkSend'], + lightning: ['fast'], + internet: ['noWiFi'], + disconnect: ['noWiFi'], + disconnection: ['noWiFi'], + wireless: ['noWiFi'], + today: ['startToday', 'videoCalendar'], + present: ['startToday', 'giftbox'], + heart: ['supportChat', 'coinbaseOneTrusted'], + '❤️': ['supportChat', 'coinbaseOneTrusted'], + confidence: ['coinbaseOneTrusted'], + joy: ['coinbaseOneTrusted', 'giftbox'], + care: ['coinbaseOneTrusted'], + belief: ['coinbaseOneTrusted'], + faith: ['coinbaseOneTrusted'], + '💕': ['coinbaseOneTrusted'], + '💙': ['coinbaseOneTrusted'], + '💜': ['coinbaseOneTrusted'], + '💗': ['coinbaseOneTrusted'], + '🖤': ['coinbaseOneTrusted'], + '💛': ['coinbaseOneTrusted'], + '💖': ['coinbaseOneTrusted'], + '💚': ['coinbaseOneTrusted'], + '🧡': ['coinbaseOneTrusted'], + '😍': ['coinbaseOneTrusted'], + '😻': ['coinbaseOneTrusted'], + interesting: ['predictionMarkets'], + 'crystal ball': ['predictionMarkets'], + psychic: ['predictionMarkets'], + forecast: ['predictionMarkets'], + foretell: ['predictionMarkets'], + foresee: ['predictionMarkets'], + '🧐': ['predictionMarkets', 'takeQuiz'], + '🔮': ['predictionMarkets'], + '🪄': ['predictionMarkets'], + move: ['transferSend'], + give: ['transferSend', 'delegate', 'instoDelegate'], + transmit: ['transferSend'], + information: ['strongInfo'], + info: ['strongInfo'], + resource: ['strongInfo'], + guide: ['strongInfo'], + facts: ['strongInfo'], + ℹ️: ['strongInfo'], 'gift box': ['giftbox'], + rewards: [ + 'giftbox', + 'learningRewardsProduct', + 'ethRewards', + 'bitcoin', + 'winBTC', + 'bitcoinRewards', + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'premiumInvestor', + 'accreditedInvestor', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + 'cryptoCoins', + 'instoEthRewards', + 'inrTrade', + ], contribution: ['giftbox'], perk: ['giftbox'], giving: ['giftbox'], @@ -3020,205 +3404,259 @@ const descriptionMap: Record = { '🎉': ['giftbox'], '🎊': ['giftbox'], '🥳': ['giftbox'], - magic: ['mintedNft'], - rabbit: ['mintedNft'], - hat: ['mintedNft'], - limited: ['mintedNft', 'apartOfDropsNft'], - sparkles: ['mintedNft', 'bigBtcSend', 'bitcoinRewards'], - unique: ['mintedNft', 'seedPhrase', 'apartOfDropsNft'], - mint: ['mintedNft', 'selectAddNft'], - minted: ['mintedNft'], - Dollar: ['dollarShowcase'], - futures: ['futures', 'futuresCoinbaseOne'], - trading: [ - 'futures', - 'futuresCoinbaseOne', - 'advancedTradingDesktop', - 'internationalExchangeNavigation', - 'trading', + users: ['newUserChecklistCompleteAccount', 'newUserChecklistBuyCrypto'], + people: [ + 'newUserChecklistCompleteAccount', + 'newUserChecklistBuyCrypto', + 'addressBook', + 'peerToPeer', + 'instoAddressBook', ], - buy: ['futures', 'futuresCoinbaseOne'], - put: ['futures', 'futuresCoinbaseOne'], - short: ['futures', 'futuresCoinbaseOne'], - hedge: ['futures', 'futuresCoinbaseOne'], - Lighting: ['lightningNetworkSend'], - 'Lighting network': ['lightningNetworkSend'], - network: ['lightningNetworkSend'], - fast: ['lightningNetworkSend'], - speed: ['lightningNetworkSend', 'fast'], - bolt: ['lightningNetworkSend'], - 'lighting bolt': ['lightningNetworkSend'], - '⚡': ['lightningNetworkSend'], - Bitcoin: ['lightningNetworkSend', 'bigBtcSend'], - lightning: ['fast'], - payment: ['paypal'], - online: ['paypal', 'email'], - virtual: ['paypal'], - method: ['paypal'], - proof: ['receipt'], - purchase: ['receipt'], - stub: ['receipt'], - income: ['receipt'], - revenue: ['receipt'], - food: ['bitcoinPizza', 'pizza'], - delicious: ['bitcoinPizza', 'pizza'], - slice: ['bitcoinPizza', 'pizza'], - pepperoni: ['bitcoinPizza', 'pizza'], - margherita: ['bitcoinPizza', 'pizza'], - hawaiian: ['bitcoinPizza', 'pizza'], - '🍕': ['bitcoinPizza', 'pizza'], - logomark: ['coinbaseOneLogo'], - brand: ['coinbaseOneLogo'], - serious: ['strongWarning'], - start: ['videoCalendar'], - drivers: ['driversLicenseWheel'], - driving: ['driversLicenseWheel'], - 'steering wheel': ['driversLicenseWheel'], - car: ['driversLicenseWheel'], - 'no wheels': ['driversLicenseWheel'], - 'no access': ['security', 'lock'], - latch: ['security', 'lock'], - blocked: ['security', 'lock'], - mail: ['emailAndMessages'], - primary: ['checkmark'], - seed: ['seedPhrase'], - phrase: ['seedPhrase'], - word: ['seedPhrase'], - code: ['seedPhrase'], - below: ['lowFees'], - '⛔️': ['calendarCaution', 'noAnnualFee', 'noVisibility', 'noWiFi'], + accounts: ['newUserChecklistCompleteAccount', 'newUserChecklistBuyCrypto'], + fund: ['institutions', 'fiat', 'coinbaseOneFiat', 'instoFiat'], + stock: ['institutions', 'fiat', 'coinbaseOneFiat', 'instoFiat'], + building: ['institutions', 'fiat', 'coinbaseOneFiat', 'instoFiat'], + institution: ['institutions', 'fiat', 'coinbaseOneFiat', 'instoFiat'], + gear: ['settings'], + settings: ['settings'], + browser: ['settings', 'browserMultiPlatform'], + cog: ['settings'], + machine: ['settings'], + tool: ['settings'], + '⚙️': ['settings'], + identity: [ + 'newUserChecklistVerifyId', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'passport', + ], + 'chart pie': ['pieChart', 'pieChartData'], + invoice: ['commerceInvoice', 'commerceCheckout'], + receipt: ['commerceInvoice', 'commerceCheckout'], + upload: ['selectAddNft'], + select: ['selectAddNft'], + apart: ['selectAddNft', 'apartOfDropsNft'], tag: ['commerceCheckout'], '🔖': ['commerceCheckout'], - hodl: ['sparkleCoinbaseOne'], - application: ['applyForHigherLimits'], - checklist: ['applyForHigherLimits'], - upwards: ['applyForHigherLimits', 'increaseLimits'], - estimate: ['calculator'], - 'cost 📊': ['calculator'], - Coin: ['bigBtcSend'], - Coins: ['bigBtcSend'], - Currency: ['bigBtcSend'], - Crypto: ['bigBtcSend'], - BTC: ['bigBtcSend'], - store: ['bigBtcSend'], - question: ['takeQuiz'], - interview: ['takeQuiz'], - probe: ['takeQuiz'], - examine: ['takeQuiz'], - '🤔': ['takeQuiz'], - '🤨': ['takeQuiz'], - stacking: ['stacking'], - layers: ['layerNetworks'], - isometric: ['layerNetworks'], - networks: ['layerNetworks'], - learning: ['bitcoinRewards', 'ethRewards', 'learningRewardsProduct'], - token: ['tokenBaskets'], - '🧺': ['tokenBaskets'], - desktop: ['advancedTradingDesktop'], + declined: ['cardBlocked'], + decentralized: ['defiEarnMoment'], + finance: ['defiEarnMoment'], + percentage: ['defiEarnMoment', 'taxSeason'], + exchanges: ['defiEarnMoment'], + Dollar: ['dollarShowcase'], puzzle: ['pluginBrowser'], + closed: ['noVisibility'], + unwatch: ['noVisibility'], + 'not visible': ['noVisibility'], + inactive: ['noVisibility'], + '👀': ['noVisibility'], + '👁': ['noVisibility'], + food: ['pizza', 'bitcoinPizza'], + delicious: ['pizza', 'bitcoinPizza'], + slice: ['pizza', 'bitcoinPizza'], + pepperoni: ['pizza', 'bitcoinPizza'], + margherita: ['pizza', 'bitcoinPizza'], + hawaiian: ['pizza', 'bitcoinPizza'], + '🍕': ['pizza', 'bitcoinPizza'], + '🌎': ['moneySwift', 'worldwide'], + '🌍': ['moneySwift', 'worldwide'], + '🌏': ['moneySwift', 'worldwide'], + '🌐': ['moneySwift', 'worldwide'], + stake: ['wrapEth', 'ethToken'], + wrap: ['wrapEth', 'ethToken'], + rush: ['wrapEth', 'ethToken'], + movement: ['wrapEth', 'ethToken'], + forward: ['wrapEth', 'ethToken'], + exciting: ['wrapEth', 'ethToken'], + 'exclamation mark': ['walletWarning', 'strongWarning', 'instoWalletWarning'], + debit: ['creditCard'], + america: ['usaProduct'], + '🇺🇸': ['usaProduct'], + flag: ['usaProduct'], delay: ['waiting'], intermission: ['waiting'], - hub: [ - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', - ], - notify: [ - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', - ], - ping: [ - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', - ], - dot: [ - 'notificationHubSocial', - 'notificationHubNews', - 'notificationHubAnalysis', - 'notificationHubPortfolio', + observe: ['priceTracking'], + '🤑': ['priceTracking'], + funds: ['taxSeason'], + serious: ['strongWarning'], + cash: ['higherLimits'], + '💲': ['higherLimits'], + trading: [ + 'trading', + 'advancedTradingDesktop', + 'internationalExchangeNavigation', + 'futures', + 'futuresCoinbaseOne', + 'instoTrading', ], - analysis: ['notificationHubSocial', 'notificationHubAnalysis'], - twitter: ['notificationHubSocial'], - safe: ['safe'], - crypt: ['safe'], - i18n: ['internationalExchangeNavigation'], - xchange: ['internationalExchangeNavigation'], - perps: ['internationalExchangeNavigation'], - waters: ['internationalExchangeNavigation'], - text: ['smsAuthenticate'], - sms: ['smsAuthenticate'], + '🕯': ['trading', 'instoTrading'], + '🪔': ['trading', 'instoTrading'], + earth: ['worldwide'], + international: ['worldwide', 'passport', 'internationalExchangeNavigation'], + continents: ['worldwide'], + global: ['worldwide'], elect: ['enableVoting'], choose: ['enableVoting'], pick: ['enableVoting'], adopt: ['enableVoting'], suggest: ['enableVoting'], '🗳': ['enableVoting'], - users: ['newUserChecklistBuyCrypto', 'newUserChecklistCompleteAccount'], - accounts: ['newUserChecklistBuyCrypto', 'newUserChecklistCompleteAccount'], - zero: ['noAnnualFee'], - l2: ['ethRewards', 'ethStakingChart', 'ethToken'], - eye: ['videoContent', 'noVisibility'], - watch: ['videoContent'], - videos: ['videoContent'], - returns: ['ethStakingChart'], - gains: ['ethStakingChart'], - represent: ['delegate'], - envoy: ['delegate'], - assign: ['delegate'], - entrust: ['delegate'], - '🕯': ['trading'], - '🪔': ['trading'], - arrows: ['controlWalletStorage'], - 'multiple wallets': ['multiAccountsAndCards'], - nft: ['nftAvatar'], - 'profile photo': ['nftAvatar'], - robot: ['nftAvatar'], - some: ['partialCoins'], - part: ['partialCoins'], - divided: ['partialCoins'], - closed: ['noVisibility'], - unwatch: ['noVisibility'], - 'not visible': ['noVisibility'], - inactive: ['noVisibility'], - '👀': ['noVisibility'], - '👁': ['noVisibility'], - doc: ['notificationHubNews', 'notificationHubAnalysis'], - glass: ['notificationHubAnalysis'], - 'semi custodial': ['custodialJourney'], - multiple: ['multiPlatform'], - devices: ['multiPlatform'], - screens: ['multiPlatform'], - types: ['multiPlatform'], + order: ['orders'], + inventory: ['orders'], + records: ['orders'], + '🚨': ['ubiKey', 'securityKey'], + desktop: ['advancedTradingDesktop'], + represent: ['delegate', 'instoDelegate'], + envoy: ['delegate', 'instoDelegate'], + assign: ['delegate', 'instoDelegate'], + entrust: ['delegate', 'instoDelegate'], + person: [ + 'delegate', + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'manageWeb3SignersAcct', + 'instoDelegate', + ], + drops: ['apartOfDropsNft'], + exclusive: ['apartOfDropsNft'], + release: ['apartOfDropsNft'], radio: ['transistor'], circuits: ['transistor'], '📡': ['transistor'], '📻': ['transistor'], - pencil: ['completeQuiz'], - complete: ['completeQuiz'], - quiz: ['completeQuiz'], - drops: ['apartOfDropsNft'], - apart: ['apartOfDropsNft', 'selectAddNft'], - exclusive: ['apartOfDropsNft'], - release: ['apartOfDropsNft'], - governance: ['governance'], - vote: ['governance'], - proposal: ['governance'], - ballot: ['governance'], - box: ['governance'], - yes: ['governance'], - maybe: ['governance'], - so: ['governance'], - internet: ['noWiFi'], - disconnect: ['noWiFi'], - disconnection: ['noWiFi'], - wireless: ['noWiFi'], hardware: ['hardwareWallet'], + start: ['videoCalendar'], + address: ['addressBook', 'instoAddressBook'], + contacts: ['addressBook', 'instoAddressBook'], + 'phone numbers': ['addressBook', 'instoAddressBook'], + names: ['addressBook', 'instoAddressBook'], + '📕': ['addressBook', 'instoAddressBook'], + '📗': ['addressBook', 'instoAddressBook'], + '📘': ['addressBook', 'instoAddressBook'], + '📙': ['addressBook', 'instoAddressBook'], + '📖': ['addressBook', 'instoAddressBook'], + '📚': ['addressBook', 'instoAddressBook'], + '📓': ['addressBook', 'instoAddressBook'], + '📒': ['addressBook', 'instoAddressBook'], + '📔': ['addressBook', 'instoAddressBook'], + '📇': ['addressBook', 'instoAddressBook'], + question: ['takeQuiz'], + interview: ['takeQuiz'], + probe: ['takeQuiz'], + examine: ['takeQuiz'], + '🤔': ['takeQuiz'], + '🤨': ['takeQuiz'], + hub: [ + 'notificationHubAnalysis', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', + ], + notify: [ + 'notificationHubAnalysis', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', + ], + ping: [ + 'notificationHubAnalysis', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', + ], + dot: [ + 'notificationHubAnalysis', + 'notificationHubPortfolio', + 'notificationHubSocial', + 'notificationHubNews', + ], + analysis: ['notificationHubAnalysis', 'notificationHubSocial'], + doc: ['notificationHubAnalysis', 'notificationHubNews'], + glass: ['notificationHubAnalysis'], + license: ['driversLicenseWheel', 'driversLicense', 'ssnCard', 'genericCountryIDCard'], + id: [ + 'driversLicenseWheel', + 'driversLicense', + 'ssnCard', + 'genericCountryIDCard', + 'manageWeb3SignersAcct', + 'passport', + 'idBlock', + 'idError', + ], + kyc: ['driversLicenseWheel', 'driversLicense', 'ssnCard', 'genericCountryIDCard'], + identified: ['driversLicenseWheel', 'driversLicense', 'ssnCard', 'genericCountryIDCard'], + drivers: ['driversLicenseWheel'], + driving: ['driversLicenseWheel'], + 'steering wheel': ['driversLicenseWheel'], + car: ['driversLicenseWheel'], + 'no wheels': ['driversLicenseWheel'], + SSN: ['ssnCard'], + number: ['ssnCard'], + vertical: ['genericCountryIDCard'], + state: ['genericCountryIDCard'], + return: ['advancedTradingRebates', 'instoAdvancedTradingRebates'], + exchange: [ + 'advancedTradingRebates', + 'internationalExchangeNavigation', + 'instoAdvancedTradingRebates', + ], + rebate: ['advancedTradingRebates', 'instoAdvancedTradingRebates'], spark: ['notificationHubPortfolio'], + twitter: ['notificationHubSocial'], + eth: [ + 'ethStaking', + 'ethStakingChart', + 'ethRewards', + 'ethToken', + 'ethStakingRewards', + 'instoEthRewards', + 'instoEthStakingChart', + ], + '🪪': ['manageWeb3SignersAcct'], + prime: [ + 'primeMobileApp', + 'derivativesProduct', + 'businessProduct', + 'loop', + 'arrowsUpDown', + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + Asset: ['assetManagement'], + management: ['assetManagement', 'assetManagementNavigation'], planet: ['planet'], '🪐': ['planet'], space: ['planet'], @@ -3228,30 +3666,362 @@ const descriptionMap: Record = { dreams: ['planet'], beyond: ['planet'], infinity: ['planet'], - equal: ['taxesArrangement'], + passport: ['passport'], + documents: ['passport'], + travel: ['passport'], + customs: ['passport'], + stand: ['standWithCryptoLogoNavigation'], + with: ['standWithCryptoLogoNavigation'], + swc: ['standWithCryptoLogoNavigation'], + pictogram: [ + 'standWithCryptoLogoNavigation', + 'learningRewardsProduct', + 'developerPlatformNavigation', + 'developerSDKNavigation', + 'verifiedPools', + 'ethRewards', + 'bitcoin', + 'winBTC', + 'bitcoinRewards', + 'derivativesProduct', + 'businessProduct', + 'cryptoCoins', + 'loop', + 'arrowsUpDown', + 'instoEthRewards', + 'inrTrade', + ], + navigation: [ + 'standWithCryptoLogoNavigation', + 'developerPlatformNavigation', + 'developerSDKNavigation', + 'verifiedPools', + ], + nav: ['standWithCryptoLogoNavigation'], + switcher: ['standWithCryptoLogoNavigation'], + ribbon: [ + 'learningRewardsProduct', + 'complianceNavigation', + 'ethRewards', + 'bitcoinRewards', + 'instoEthRewards', + ], + learning: ['learningRewardsProduct', 'ethRewards', 'bitcoinRewards', 'instoEthRewards'], + Lighting: ['lightningNetworkSend'], + 'Lighting network': ['lightningNetworkSend'], + network: ['lightningNetworkSend'], + fast: ['lightningNetworkSend'], + bolt: ['lightningNetworkSend'], + 'lighting bolt': ['lightningNetworkSend'], + '⚡': ['lightningNetworkSend'], + Bitcoin: ['lightningNetworkSend', 'bigBtcSend'], + i18n: ['internationalExchangeNavigation'], + xchange: ['internationalExchangeNavigation'], + perps: ['internationalExchangeNavigation'], + waters: ['internationalExchangeNavigation'], + asset: [ + 'assetManagementNavigation', + 'ethStakingChart', + 'ethRewards', + 'ethToken', + 'bigBtcSend', + 'instoEthRewards', + 'instoEthStakingChart', + ], + aum: ['assetManagementNavigation'], + broker: ['assetManagementNavigation'], + compliance: ['complianceNavigation'], + regulatory: ['complianceNavigation'], + certified: ['complianceNavigation'], + bow: ['complianceNavigation'], + product: [ + 'complianceNavigation', + 'coinbaseOneProductIcon', + 'developerPlatformNavigation', + 'developerSDKNavigation', + 'verifiedPools', + ], + icon: ['complianceNavigation', 'coinbaseOneProductIcon'], + Coinbase: ['coinbaseOneProductIcon'], + One: ['coinbaseOneProductIcon', 'btcOneHundred'], + cb2: ['coinbaseOneProductIcon'], + cbinfinity: ['coinbaseOneProductIcon'], + developer: ['developerPlatformNavigation', 'developerSDKNavigation', 'verifiedPools'], + SDK: ['developerSDKNavigation'], + verified: ['verifiedPools'], + pools: ['verifiedPools'], + liquid: ['verifiedPools'], + liquidity: ['verifiedPools'], + l2: ['ethStakingChart', 'ethRewards', 'ethToken', 'instoEthRewards', 'instoEthStakingChart'], + returns: ['ethStakingChart', 'instoEthStakingChart'], + gains: ['ethStakingChart', 'instoEthStakingChart'], + 'crypto learning': ['bitcoin', 'winBTC', 'cryptoCoins', 'inrTrade'], + bitcoin: ['bitcoin', 'winBTC', 'bitcoinRewards', 'cryptoCoins', 'inrTrade'], + btc: ['bitcoin', 'winBTC', 'bitcoinRewards', 'btcOneHundred', 'cryptoCoins', 'inrTrade'], + satoshi: ['bitcoin', 'winBTC', 'bitcoinRewards', 'cryptoCoins', 'inrTrade'], + giveaway: ['bitcoin', 'winBTC', 'bitcoinRewards', 'cryptoCoins', 'inrTrade'], + free: ['bitcoin', 'winBTC', 'bitcoinRewards', 'cryptoCoins', 'inrTrade'], + competition: ['bitcoin', 'winBTC', 'bitcoinRewards', 'cryptoCoins', 'inrTrade'], + trade: ['ethStakingRewards', 'coinbaseOneTrade', 'calculator'], + stars: ['ethStakingRewards', 'usdcLoan', 'leadGraph', 'bigBtcSend'], + eth2: ['ethStakingRewards'], + 'stacks of coins': ['ethStakingRewards'], + estimate: ['calculator'], + 'cost 📊': ['calculator'], + peertopeer: ['peerToPeer'], + peer: ['peerToPeer'], + transfer: ['peerToPeer'], + futures: ['futures', 'futuresCoinbaseOne'], + future: ['futures', 'coinbaseOneEarn', 'futuresCoinbaseOne'], + buy: ['futures', 'futuresCoinbaseOne'], + put: ['futures', 'futuresCoinbaseOne'], + short: ['futures', 'futuresCoinbaseOne'], + hedge: ['futures', 'futuresCoinbaseOne'], + derivatives: ['derivativesProduct', 'businessProduct', 'loop'], + leverage: ['derivativesProduct', 'businessProduct', 'loop', 'arrowsUpDown'], + invest: ['derivativesProduct', 'coinbaseOneEarn', 'businessProduct', 'loop', 'arrowsUpDown'], + advanced: ['derivativesProduct', 'businessProduct', 'loop', 'arrowsUpDown'], + derive: ['derivativesProduct', 'businessProduct', 'loop', 'arrowsUpDown'], + triangles: ['derivativesProduct', 'businessProduct', 'loop', 'arrowsUpDown'], + usdc: [ + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'usdcLoan', + 'twoBonus', + 'leadGraph', + 'coinbaseOneUnlimitedRewards', + ], + USDCoin: [ + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + ], + USD: [ + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + ], + dollar: [ + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + ], + awards: [ + 'usdcEarn', + 'usdcInterest', + 'usdcRewardsRibbon', + 'usdcToken', + 'usdcLogo', + 'usdcRewards', + 'twoBonus', + 'coinbaseOneUnlimitedRewards', + ], + bar: ['coinbaseOneEarn'], + grow: ['coinbaseOneEarn'], + medal: ['premiumInvestor', 'accreditedInvestor'], + accredited: ['premiumInvestor', 'accreditedInvestor'], + investor: ['premiumInvestor', 'accreditedInvestor'], + singapore: ['premiumInvestor', 'accreditedInvestor'], + VIP: ['premiumInvestor', 'accreditedInvestor'], + award: ['premiumInvestor', 'accreditedInvestor'], + premium: ['premiumInvestor'], + loan: ['usdcLoan', 'leadGraph'], + portal: ['usdcLoan', 'leadGraph'], base: ['baseLogo'], baselogo: ['baseLogo'], - earth: ['worldwide'], - continents: ['worldwide'], - global: ['worldwide'], + hodl: ['sparkleCoinbaseOne'], + bad: ['idBlock', 'idError'], + Coin: ['bigBtcSend'], + Coins: ['bigBtcSend'], + Currency: ['bigBtcSend'], + Crypto: ['bigBtcSend'], + BTC: ['bigBtcSend'], + store: ['bigBtcSend'], + Hundred: ['btcOneHundred'], podium: ['podium'], crystalball: ['crystalBallInsight'], - upload: ['selectAddNft'], - select: ['selectAddNft'], - address: ['addressBook'], - contacts: ['addressBook'], - 'phone numbers': ['addressBook'], - names: ['addressBook'], - '📕': ['addressBook'], - '📗': ['addressBook'], - '📘': ['addressBook'], - '📙': ['addressBook'], - '📖': ['addressBook'], - '📚': ['addressBook'], - '📓': ['addressBook'], - '📒': ['addressBook'], - '📔': ['addressBook'], - '📇': ['addressBook'], + commodities: ['commodities'], + insto: [ + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + negroni: [ + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + orange: [ + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + institutional: [ + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + 'institutional investor': [ + 'instoAuthenticatorProgress', + 'instoEarnGraph', + 'instoPasswordWalletLocked', + 'instoDecentralizedWeb3', + 'instoApyInterest', + 'instoRestaking', + 'browserMultiPlatform', + 'instoCoinbaseOneShield', + 'instoBorrowingLending', + 'instoAdvancedTradingRebates', + 'instoCrypto101', + 'instoDelegate', + 'instoStakingGraph', + 'instoGem', + 'instoprimeMobileApp', + 'instoEthRewards', + 'instoEth', + 'instoAccount', + 'instoAddressBook', + 'instoEthStakingChart', + 'instoNftLibrary', + 'instoDecentralizationEverything', + 'instoWalletWarning', + 'instoKey', + 'instoRiskStaking', + 'instoEarnCoins', + 'instoFiat', + 'instoTrading', + 'instoSelfCustodyWallet', + 'instoDecentralizedExchange', + ], + multiplatform: ['browserMultiPlatform'], + extension: ['browserMultiPlatform'], }; export default descriptionMap; diff --git a/packages/illustrations/src/__generated__/pictogram/data/names.ts b/packages/illustrations/src/__generated__/pictogram/data/names.ts index ba86c17723..4ba57c619d 100644 --- a/packages/illustrations/src/__generated__/pictogram/data/names.ts +++ b/packages/illustrations/src/__generated__/pictogram/data/names.ts @@ -30,6 +30,7 @@ const names: PictogramName[] = [ 'apartOfDropsNft', 'applyForHigherLimits', 'apyInterest', + 'arrowsUpDown', 'assetEncryption', 'assetHubNavigation', 'assetManagement', @@ -254,6 +255,7 @@ const names: PictogramName[] = [ 'commerceCheckout', 'commerceInvoice', 'commerceNavigation', + 'commodities', 'completeQuiz', 'complianceNavigation', 'congratulations', @@ -284,6 +286,7 @@ const names: PictogramName[] = [ 'directDepositNavigation', 'dollarShowcase', 'done', + 'download', 'driversLicense', 'driversLicenseWheel', 'earnCoins', @@ -332,9 +335,45 @@ const names: PictogramName[] = [ 'idVerification', 'identityCard', 'increaseLimits', + 'inrTrade', 'instantUnstakingClock', 'institutionalNavigation', 'institutions', + 'instoAccount', + 'instoAddressBook', + 'instoAdvancedTradingRebates', + 'instoApyInterest', + 'instoAuthenticatorProgress', + 'instoBorrowCoins', + 'instoBorrowingLending', + 'instoCoinFocus', + 'instoCoinbaseOneShield', + 'instoCrypto101', + 'instoDecentralizationEverything', + 'instoDecentralizedExchange', + 'instoDecentralizedWeb3', + 'instoDelegate', + 'instoEarnCoins', + 'instoEarnGraph', + 'instoEasyToUse', + 'instoEth', + 'instoEthRewards', + 'instoEthStakingChart', + 'instoFiat', + 'instoGem', + 'instoGlobalConnections', + 'instoKey', + 'instoMonitoringPerformance', + 'instoNftLibrary', + 'instoPasswordWalletLocked', + 'instoRestaking', + 'instoRiskStaking', + 'instoSecuredAssets', + 'instoSelfCustodyWallet', + 'instoStakingGraph', + 'instoTrading', + 'instoWalletWarning', + 'instoprimeMobileApp', 'internationalExchangeNavigation', 'internet', 'investGraph', @@ -400,6 +439,8 @@ const names: PictogramName[] = [ 'phone', 'pieChart', 'pieChartData', + 'pieChartWithArrow', + 'pieChartWithArrowBlue', 'pizza', 'planet', 'pluginBrowser', diff --git a/packages/illustrations/src/__generated__/pictogram/data/svgJsMap.ts b/packages/illustrations/src/__generated__/pictogram/data/svgJsMap.ts index 69b9f01751..08de47f99e 100644 --- a/packages/illustrations/src/__generated__/pictogram/data/svgJsMap.ts +++ b/packages/illustrations/src/__generated__/pictogram/data/svgJsMap.ts @@ -86,6 +86,10 @@ const svgJsMap = { light: () => require('../svgJs/light/apyInterest-5.js').content, dark: () => require('../svgJs/dark/apyInterest-5.js').content, }, + arrowsUpDown: { + light: () => require('../svgJs/light/arrowsUpDown-0.js').content, + dark: () => require('../svgJs/dark/arrowsUpDown-0.js').content, + }, assetEncryption: { light: () => require('../svgJs/light/assetEncryption-5.js').content, dark: () => require('../svgJs/dark/assetEncryption-5.js').content, @@ -547,8 +551,8 @@ const svgJsMap = { dark: () => require('../svgJs/dark/baseChatBubbleHeart-1.js').content, }, baseCheckSmall: { - light: () => require('../svgJs/light/baseCheckSmall-0.js').content, - dark: () => require('../svgJs/dark/baseCheckSmall-0.js').content, + light: () => require('../svgJs/light/baseCheckSmall-1.js').content, + dark: () => require('../svgJs/dark/baseCheckSmall-1.js').content, }, baseCoinCryptoSmall: { light: () => require('../svgJs/light/baseCoinCryptoSmall-0.js').content, @@ -803,8 +807,8 @@ const svgJsMap = { dark: () => require('../svgJs/dark/browser-3.js').content, }, browserMultiPlatform: { - light: () => require('../svgJs/light/browserMultiPlatform-6.js').content, - dark: () => require('../svgJs/dark/browserMultiPlatform-6.js').content, + light: () => require('../svgJs/light/browserMultiPlatform-7.js').content, + dark: () => require('../svgJs/dark/browserMultiPlatform-7.js').content, }, browserTransaction: { light: () => require('../svgJs/light/browserTransaction-3.js').content, @@ -982,6 +986,10 @@ const svgJsMap = { light: () => require('../svgJs/light/commerceNavigation-6.js').content, dark: () => require('../svgJs/dark/commerceNavigation-6.js').content, }, + commodities: { + light: () => require('../svgJs/light/commodities-0.js').content, + dark: () => require('../svgJs/dark/commodities-0.js').content, + }, completeQuiz: { light: () => require('../svgJs/light/completeQuiz-5.js').content, dark: () => require('../svgJs/dark/completeQuiz-5.js').content, @@ -1102,6 +1110,10 @@ const svgJsMap = { light: () => require('../svgJs/light/done-4.js').content, dark: () => require('../svgJs/dark/done-4.js').content, }, + download: { + light: () => require('../svgJs/light/download-1.js').content, + dark: () => require('../svgJs/dark/download-1.js').content, + }, driversLicense: { light: () => require('../svgJs/light/driversLicense-3.js').content, dark: () => require('../svgJs/dark/driversLicense-3.js').content, @@ -1294,6 +1306,10 @@ const svgJsMap = { light: () => require('../svgJs/light/increaseLimits-3.js').content, dark: () => require('../svgJs/dark/increaseLimits-3.js').content, }, + inrTrade: { + light: () => require('../svgJs/light/inrTrade-0.js').content, + dark: () => require('../svgJs/dark/inrTrade-0.js').content, + }, instantUnstakingClock: { light: () => require('../svgJs/light/instantUnstakingClock-1.js').content, dark: () => require('../svgJs/dark/instantUnstakingClock-1.js').content, @@ -1306,6 +1322,146 @@ const svgJsMap = { light: () => require('../svgJs/light/institutions-3.js').content, dark: () => require('../svgJs/dark/institutions-3.js').content, }, + instoAccount: { + light: () => require('../svgJs/light/instoAccount-0.js').content, + dark: () => require('../svgJs/dark/instoAccount-0.js').content, + }, + instoAddressBook: { + light: () => require('../svgJs/light/instoAddressBook-0.js').content, + dark: () => require('../svgJs/dark/instoAddressBook-0.js').content, + }, + instoAdvancedTradingRebates: { + light: () => require('../svgJs/light/instoAdvancedTradingRebates-0.js').content, + dark: () => require('../svgJs/dark/instoAdvancedTradingRebates-0.js').content, + }, + instoApyInterest: { + light: () => require('../svgJs/light/instoApyInterest-2.js').content, + dark: () => require('../svgJs/dark/instoApyInterest-2.js').content, + }, + instoAuthenticatorProgress: { + light: () => require('../svgJs/light/instoAuthenticatorProgress-0.js').content, + dark: () => require('../svgJs/dark/instoAuthenticatorProgress-0.js').content, + }, + instoBorrowCoins: { + light: () => require('../svgJs/light/instoBorrowCoins-0.js').content, + dark: () => require('../svgJs/dark/instoBorrowCoins-0.js').content, + }, + instoBorrowingLending: { + light: () => require('../svgJs/light/instoBorrowingLending-0.js').content, + dark: () => require('../svgJs/dark/instoBorrowingLending-0.js').content, + }, + instoCoinbaseOneShield: { + light: () => require('../svgJs/light/instoCoinbaseOneShield-0.js').content, + dark: () => require('../svgJs/dark/instoCoinbaseOneShield-0.js').content, + }, + instoCoinFocus: { + light: () => require('../svgJs/light/instoCoinFocus-0.js').content, + dark: () => require('../svgJs/dark/instoCoinFocus-0.js').content, + }, + instoCrypto101: { + light: () => require('../svgJs/light/instoCrypto101-0.js').content, + dark: () => require('../svgJs/dark/instoCrypto101-0.js').content, + }, + instoDecentralizationEverything: { + light: () => require('../svgJs/light/instoDecentralizationEverything-0.js').content, + dark: () => require('../svgJs/dark/instoDecentralizationEverything-0.js').content, + }, + instoDecentralizedExchange: { + light: () => require('../svgJs/light/instoDecentralizedExchange-1.js').content, + dark: () => require('../svgJs/dark/instoDecentralizedExchange-1.js').content, + }, + instoDecentralizedWeb3: { + light: () => require('../svgJs/light/instoDecentralizedWeb3-1.js').content, + dark: () => require('../svgJs/dark/instoDecentralizedWeb3-1.js').content, + }, + instoDelegate: { + light: () => require('../svgJs/light/instoDelegate-0.js').content, + dark: () => require('../svgJs/dark/instoDelegate-0.js').content, + }, + instoEarnCoins: { + light: () => require('../svgJs/light/instoEarnCoins-0.js').content, + dark: () => require('../svgJs/dark/instoEarnCoins-0.js').content, + }, + instoEarnGraph: { + light: () => require('../svgJs/light/instoEarnGraph-0.js').content, + dark: () => require('../svgJs/dark/instoEarnGraph-0.js').content, + }, + instoEasyToUse: { + light: () => require('../svgJs/light/instoEasyToUse-0.js').content, + dark: () => require('../svgJs/dark/instoEasyToUse-0.js').content, + }, + instoEth: { + light: () => require('../svgJs/light/instoEth-0.js').content, + dark: () => require('../svgJs/dark/instoEth-0.js').content, + }, + instoEthRewards: { + light: () => require('../svgJs/light/instoEthRewards-0.js').content, + dark: () => require('../svgJs/dark/instoEthRewards-0.js').content, + }, + instoEthStakingChart: { + light: () => require('../svgJs/light/instoEthStakingChart-0.js').content, + dark: () => require('../svgJs/dark/instoEthStakingChart-0.js').content, + }, + instoFiat: { + light: () => require('../svgJs/light/instoFiat-0.js').content, + dark: () => require('../svgJs/dark/instoFiat-0.js').content, + }, + instoGem: { + light: () => require('../svgJs/light/instoGem-0.js').content, + dark: () => require('../svgJs/dark/instoGem-0.js').content, + }, + instoGlobalConnections: { + light: () => require('../svgJs/light/instoGlobalConnections-0.js').content, + dark: () => require('../svgJs/dark/instoGlobalConnections-0.js').content, + }, + instoKey: { + light: () => require('../svgJs/light/instoKey-1.js').content, + dark: () => require('../svgJs/dark/instoKey-1.js').content, + }, + instoMonitoringPerformance: { + light: () => require('../svgJs/light/instoMonitoringPerformance-0.js').content, + dark: () => require('../svgJs/dark/instoMonitoringPerformance-0.js').content, + }, + instoNftLibrary: { + light: () => require('../svgJs/light/instoNftLibrary-0.js').content, + dark: () => require('../svgJs/dark/instoNftLibrary-0.js').content, + }, + instoPasswordWalletLocked: { + light: () => require('../svgJs/light/instoPasswordWalletLocked-0.js').content, + dark: () => require('../svgJs/dark/instoPasswordWalletLocked-0.js').content, + }, + instoprimeMobileApp: { + light: () => require('../svgJs/light/instoprimeMobileApp-0.js').content, + dark: () => require('../svgJs/dark/instoprimeMobileApp-0.js').content, + }, + instoRestaking: { + light: () => require('../svgJs/light/instoRestaking-2.js').content, + dark: () => require('../svgJs/dark/instoRestaking-2.js').content, + }, + instoRiskStaking: { + light: () => require('../svgJs/light/instoRiskStaking-0.js').content, + dark: () => require('../svgJs/dark/instoRiskStaking-0.js').content, + }, + instoSecuredAssets: { + light: () => require('../svgJs/light/instoSecuredAssets-0.js').content, + dark: () => require('../svgJs/dark/instoSecuredAssets-0.js').content, + }, + instoSelfCustodyWallet: { + light: () => require('../svgJs/light/instoSelfCustodyWallet-0.js').content, + dark: () => require('../svgJs/dark/instoSelfCustodyWallet-0.js').content, + }, + instoStakingGraph: { + light: () => require('../svgJs/light/instoStakingGraph-0.js').content, + dark: () => require('../svgJs/dark/instoStakingGraph-0.js').content, + }, + instoTrading: { + light: () => require('../svgJs/light/instoTrading-0.js').content, + dark: () => require('../svgJs/dark/instoTrading-0.js').content, + }, + instoWalletWarning: { + light: () => require('../svgJs/light/instoWalletWarning-0.js').content, + dark: () => require('../svgJs/dark/instoWalletWarning-0.js').content, + }, internationalExchangeNavigation: { light: () => require('../svgJs/light/internationalExchangeNavigation-1.js').content, dark: () => require('../svgJs/dark/internationalExchangeNavigation-1.js').content, @@ -1566,6 +1722,14 @@ const svgJsMap = { light: () => require('../svgJs/light/pieChartData-0.js').content, dark: () => require('../svgJs/dark/pieChartData-0.js').content, }, + pieChartWithArrow: { + light: () => require('../svgJs/light/pieChartWithArrow-0.js').content, + dark: () => require('../svgJs/dark/pieChartWithArrow-0.js').content, + }, + pieChartWithArrowBlue: { + light: () => require('../svgJs/light/pieChartWithArrowBlue-0.js').content, + dark: () => require('../svgJs/dark/pieChartWithArrowBlue-0.js').content, + }, pizza: { light: () => require('../svgJs/light/pizza-3.js').content, dark: () => require('../svgJs/dark/pizza-3.js').content, diff --git a/packages/illustrations/src/__generated__/pictogram/data/versionMap.ts b/packages/illustrations/src/__generated__/pictogram/data/versionMap.ts index 519dbd410c..051428cbee 100644 --- a/packages/illustrations/src/__generated__/pictogram/data/versionMap.ts +++ b/packages/illustrations/src/__generated__/pictogram/data/versionMap.ts @@ -147,7 +147,7 @@ const versionMap: Record = { coinbaseOneFiat: 3, waitingForConsensus: 3, reviewAndAdd: 3, - browserMultiPlatform: 6, + browserMultiPlatform: 7, finance: 3, crypto101: 4, walletPassword: 4, @@ -436,7 +436,7 @@ const versionMap: Record = { baseNetworkSmall: 0, baseCoinCryptoSmall: 0, basePiechartSmall: 1, - baseCheckSmall: 0, + baseCheckSmall: 1, baseErrorButterflySmall: 0, baseMintNftSmall: 1, baseTargetSmall: 1, @@ -505,6 +505,47 @@ const versionMap: Record = { loop: 0, podium: 0, robot: 0, + commodities: 0, + arrowsUpDown: 0, + pieChartWithArrow: 0, + pieChartWithArrowBlue: 0, + download: 1, + instoEarnCoins: 0, + instoAdvancedTradingRebates: 0, + instoRiskStaking: 0, + instoWalletWarning: 0, + instoDecentralizationEverything: 0, + instoNftLibrary: 0, + instoGem: 0, + instoEthStakingChart: 0, + instoStakingGraph: 0, + instoAccount: 0, + instoDecentralizedExchange: 1, + instoSelfCustodyWallet: 0, + instoTrading: 0, + instoKey: 1, + instoBorrowingLending: 0, + instoEarnGraph: 0, + instoRestaking: 2, + instoPasswordWalletLocked: 0, + instoEth: 0, + instoEthRewards: 0, + instoAddressBook: 0, + instoDelegate: 0, + instoCrypto101: 0, + instoDecentralizedWeb3: 1, + instoCoinbaseOneShield: 0, + instoApyInterest: 2, + instoAuthenticatorProgress: 0, + instoprimeMobileApp: 0, + instoFiat: 0, + instoMonitoringPerformance: 0, + instoGlobalConnections: 0, + instoCoinFocus: 0, + instoEasyToUse: 0, + instoSecuredAssets: 0, + instoBorrowCoins: 0, + inrTrade: 0, }; export default versionMap; diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/arrowsUpDown-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/arrowsUpDown-0.png new file mode 100644 index 0000000000..abb0368a20 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/arrowsUpDown-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-0.png deleted file mode 100644 index 40eafa3b65..0000000000 Binary files a/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-0.png and /dev/null differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-1.png b/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-1.png new file mode 100644 index 0000000000..bff9bf16e5 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/baseCheckSmall-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-6.png b/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-6.png deleted file mode 100644 index eea3a34bfa..0000000000 Binary files a/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-6.png and /dev/null differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-7.png b/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-7.png new file mode 100644 index 0000000000..0a22b07ca6 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/browserMultiPlatform-7.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/commodities-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/commodities-0.png new file mode 100644 index 0000000000..dafd66bbd7 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/commodities-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/download-1.png b/packages/illustrations/src/__generated__/pictogram/png/dark/download-1.png new file mode 100644 index 0000000000..5044c44b04 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/download-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/inrTrade-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/inrTrade-0.png new file mode 100644 index 0000000000..ee9d9cb2d6 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/inrTrade-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoAccount-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAccount-0.png new file mode 100644 index 0000000000..5d01d7ff7f Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAccount-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoAddressBook-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAddressBook-0.png new file mode 100644 index 0000000000..2a3a7db076 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAddressBook-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoAdvancedTradingRebates-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAdvancedTradingRebates-0.png new file mode 100644 index 0000000000..6d155bbe7e Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAdvancedTradingRebates-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoApyInterest-2.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoApyInterest-2.png new file mode 100644 index 0000000000..c0f9fa8615 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoApyInterest-2.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoAuthenticatorProgress-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAuthenticatorProgress-0.png new file mode 100644 index 0000000000..56f2ccd712 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoAuthenticatorProgress-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowCoins-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowCoins-0.png new file mode 100644 index 0000000000..0f4afed6c8 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowCoins-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowingLending-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowingLending-0.png new file mode 100644 index 0000000000..76e5742fdc Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoBorrowingLending-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinFocus-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinFocus-0.png new file mode 100644 index 0000000000..303602b5f5 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinFocus-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinbaseOneShield-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinbaseOneShield-0.png new file mode 100644 index 0000000000..038fa2e02f Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCoinbaseOneShield-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoCrypto101-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCrypto101-0.png new file mode 100644 index 0000000000..910476fa14 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoCrypto101-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizationEverything-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizationEverything-0.png new file mode 100644 index 0000000000..437fc3415a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizationEverything-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedExchange-1.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedExchange-1.png new file mode 100644 index 0000000000..00a028dd74 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedExchange-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedWeb3-1.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedWeb3-1.png new file mode 100644 index 0000000000..bab449b16e Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDecentralizedWeb3-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoDelegate-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDelegate-0.png new file mode 100644 index 0000000000..ec8c1ef291 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoDelegate-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnCoins-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnCoins-0.png new file mode 100644 index 0000000000..cea45b852d Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnCoins-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnGraph-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnGraph-0.png new file mode 100644 index 0000000000..c22d234244 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEarnGraph-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEasyToUse-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEasyToUse-0.png new file mode 100644 index 0000000000..dadc5ae9bd Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEasyToUse-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEth-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEth-0.png new file mode 100644 index 0000000000..45dab990c0 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEth-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthRewards-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthRewards-0.png new file mode 100644 index 0000000000..89cb54b5f6 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthStakingChart-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthStakingChart-0.png new file mode 100644 index 0000000000..1a1066dd78 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoEthStakingChart-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoFiat-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoFiat-0.png new file mode 100644 index 0000000000..c267848531 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoFiat-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoGem-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoGem-0.png new file mode 100644 index 0000000000..d7747f02f2 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoGem-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoGlobalConnections-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoGlobalConnections-0.png new file mode 100644 index 0000000000..a6ed5973ac Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoGlobalConnections-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoKey-1.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoKey-1.png new file mode 100644 index 0000000000..3150ce6a6a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoKey-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoMonitoringPerformance-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoMonitoringPerformance-0.png new file mode 100644 index 0000000000..8e2997b74d Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoMonitoringPerformance-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoNftLibrary-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoNftLibrary-0.png new file mode 100644 index 0000000000..e96ef77fbb Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoNftLibrary-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoPasswordWalletLocked-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoPasswordWalletLocked-0.png new file mode 100644 index 0000000000..efccb5c95d Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoPasswordWalletLocked-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoRestaking-2.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoRestaking-2.png new file mode 100644 index 0000000000..cf01423a38 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoRestaking-2.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoRiskStaking-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoRiskStaking-0.png new file mode 100644 index 0000000000..25380eea26 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoRiskStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoSecuredAssets-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoSecuredAssets-0.png new file mode 100644 index 0000000000..b045c753e9 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoSecuredAssets-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoSelfCustodyWallet-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoSelfCustodyWallet-0.png new file mode 100644 index 0000000000..a7c4c6f5e0 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoSelfCustodyWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoStakingGraph-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoStakingGraph-0.png new file mode 100644 index 0000000000..efd275897a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoStakingGraph-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoTrading-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoTrading-0.png new file mode 100644 index 0000000000..5d1c58858e Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoTrading-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoWalletWarning-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoWalletWarning-0.png new file mode 100644 index 0000000000..e46e454de2 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoWalletWarning-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/instoprimeMobileApp-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/instoprimeMobileApp-0.png new file mode 100644 index 0000000000..f20088739a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/instoprimeMobileApp-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrow-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrow-0.png new file mode 100644 index 0000000000..7eb1500451 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrow-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrowBlue-0.png b/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrowBlue-0.png new file mode 100644 index 0000000000..76b022aacb Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/dark/pieChartWithArrowBlue-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/arrowsUpDown-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/arrowsUpDown-0.png new file mode 100644 index 0000000000..68cf04579a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/arrowsUpDown-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-0.png deleted file mode 100644 index 40eafa3b65..0000000000 Binary files a/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-0.png and /dev/null differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-1.png b/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-1.png new file mode 100644 index 0000000000..bff9bf16e5 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/baseCheckSmall-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-6.png b/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-6.png deleted file mode 100644 index da69c683a7..0000000000 Binary files a/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-6.png and /dev/null differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-7.png b/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-7.png new file mode 100644 index 0000000000..a9e3b6d15d Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/browserMultiPlatform-7.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/commodities-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/commodities-0.png new file mode 100644 index 0000000000..ef850c912d Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/commodities-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/download-1.png b/packages/illustrations/src/__generated__/pictogram/png/light/download-1.png new file mode 100644 index 0000000000..05a3a25327 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/download-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/inrTrade-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/inrTrade-0.png new file mode 100644 index 0000000000..e010c0ac56 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/inrTrade-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoAccount-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoAccount-0.png new file mode 100644 index 0000000000..e7e069efb0 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoAccount-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoAddressBook-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoAddressBook-0.png new file mode 100644 index 0000000000..578e4b1f43 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoAddressBook-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoAdvancedTradingRebates-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoAdvancedTradingRebates-0.png new file mode 100644 index 0000000000..e30d8fc2d4 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoAdvancedTradingRebates-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoApyInterest-2.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoApyInterest-2.png new file mode 100644 index 0000000000..0536f54407 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoApyInterest-2.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoAuthenticatorProgress-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoAuthenticatorProgress-0.png new file mode 100644 index 0000000000..5fa8e86821 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoAuthenticatorProgress-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowCoins-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowCoins-0.png new file mode 100644 index 0000000000..f95e6d1f4a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowCoins-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowingLending-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowingLending-0.png new file mode 100644 index 0000000000..56cff6df7a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoBorrowingLending-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinFocus-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinFocus-0.png new file mode 100644 index 0000000000..44c152dca2 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinFocus-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinbaseOneShield-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinbaseOneShield-0.png new file mode 100644 index 0000000000..2f5a2f8f55 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoCoinbaseOneShield-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoCrypto101-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoCrypto101-0.png new file mode 100644 index 0000000000..1a0ae89733 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoCrypto101-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizationEverything-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizationEverything-0.png new file mode 100644 index 0000000000..0749a68b62 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizationEverything-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedExchange-1.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedExchange-1.png new file mode 100644 index 0000000000..bd505a351f Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedExchange-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedWeb3-1.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedWeb3-1.png new file mode 100644 index 0000000000..71692d9c42 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoDecentralizedWeb3-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoDelegate-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoDelegate-0.png new file mode 100644 index 0000000000..5e34f7c64f Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoDelegate-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnCoins-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnCoins-0.png new file mode 100644 index 0000000000..8000fd830b Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnCoins-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnGraph-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnGraph-0.png new file mode 100644 index 0000000000..825fd0af70 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEarnGraph-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEasyToUse-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEasyToUse-0.png new file mode 100644 index 0000000000..03ffc95a68 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEasyToUse-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEth-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEth-0.png new file mode 100644 index 0000000000..4bc3ddbadc Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEth-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEthRewards-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEthRewards-0.png new file mode 100644 index 0000000000..d85ddacbcb Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEthRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoEthStakingChart-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoEthStakingChart-0.png new file mode 100644 index 0000000000..b7ef59a28e Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoEthStakingChart-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoFiat-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoFiat-0.png new file mode 100644 index 0000000000..73e553d10c Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoFiat-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoGem-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoGem-0.png new file mode 100644 index 0000000000..61c6756f74 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoGem-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoGlobalConnections-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoGlobalConnections-0.png new file mode 100644 index 0000000000..e364de4361 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoGlobalConnections-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoKey-1.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoKey-1.png new file mode 100644 index 0000000000..58529b242c Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoKey-1.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoMonitoringPerformance-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoMonitoringPerformance-0.png new file mode 100644 index 0000000000..fee7bdba37 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoMonitoringPerformance-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoNftLibrary-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoNftLibrary-0.png new file mode 100644 index 0000000000..a9214df04c Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoNftLibrary-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoPasswordWalletLocked-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoPasswordWalletLocked-0.png new file mode 100644 index 0000000000..4e036da1b0 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoPasswordWalletLocked-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoRestaking-2.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoRestaking-2.png new file mode 100644 index 0000000000..f71e02b8dd Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoRestaking-2.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoRiskStaking-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoRiskStaking-0.png new file mode 100644 index 0000000000..43c26c9111 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoRiskStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoSecuredAssets-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoSecuredAssets-0.png new file mode 100644 index 0000000000..9383cc4e9e Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoSecuredAssets-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoSelfCustodyWallet-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoSelfCustodyWallet-0.png new file mode 100644 index 0000000000..39fdbfe467 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoSelfCustodyWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoStakingGraph-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoStakingGraph-0.png new file mode 100644 index 0000000000..4512ddc672 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoStakingGraph-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoTrading-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoTrading-0.png new file mode 100644 index 0000000000..1f581028ac Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoTrading-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoWalletWarning-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoWalletWarning-0.png new file mode 100644 index 0000000000..7feffc19cd Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoWalletWarning-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/instoprimeMobileApp-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/instoprimeMobileApp-0.png new file mode 100644 index 0000000000..9d6019f5d4 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/instoprimeMobileApp-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrow-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrow-0.png new file mode 100644 index 0000000000..a3e07e175a Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrow-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrowBlue-0.png b/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrowBlue-0.png new file mode 100644 index 0000000000..73d06b7822 Binary files /dev/null and b/packages/illustrations/src/__generated__/pictogram/png/light/pieChartWithArrowBlue-0.png differ diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/arrowsUpDown-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/arrowsUpDown-0.svg new file mode 100644 index 0000000000..236379760b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/arrowsUpDown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-0.svg deleted file mode 100644 index 52c3f44170..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-0.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-1.svg new file mode 100644 index 0000000000..5ee621aae9 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/baseCheckSmall-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-6.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-6.svg deleted file mode 100644 index f15e049b6e..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-6.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-7.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-7.svg new file mode 100644 index 0000000000..3dfa8d0a35 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/browserMultiPlatform-7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/commodities-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/commodities-0.svg new file mode 100644 index 0000000000..26011bd9d4 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/commodities-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/download-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/download-1.svg new file mode 100644 index 0000000000..d8afc1d5ad --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/download-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/inrTrade-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/inrTrade-0.svg new file mode 100644 index 0000000000..6ad08a5ede --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAccount-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAccount-0.svg new file mode 100644 index 0000000000..1ca8ce296d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAddressBook-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAddressBook-0.svg new file mode 100644 index 0000000000..d45c358727 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAddressBook-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAdvancedTradingRebates-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAdvancedTradingRebates-0.svg new file mode 100644 index 0000000000..27862b7f48 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAdvancedTradingRebates-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoApyInterest-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoApyInterest-2.svg new file mode 100644 index 0000000000..fe2fb6d5d4 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoApyInterest-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAuthenticatorProgress-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAuthenticatorProgress-0.svg new file mode 100644 index 0000000000..91841e1adf --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoAuthenticatorProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowCoins-0.svg new file mode 100644 index 0000000000..2ff38e86df --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowingLending-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowingLending-0.svg new file mode 100644 index 0000000000..921e18916b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoBorrowingLending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinFocus-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinFocus-0.svg new file mode 100644 index 0000000000..6ce7c2800e --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinFocus-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinbaseOneShield-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinbaseOneShield-0.svg new file mode 100644 index 0000000000..ef68cab335 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCoinbaseOneShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCrypto101-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCrypto101-0.svg new file mode 100644 index 0000000000..c078a4c7da --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoCrypto101-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizationEverything-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizationEverything-0.svg new file mode 100644 index 0000000000..513802b8a5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizationEverything-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedExchange-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedExchange-1.svg new file mode 100644 index 0000000000..fa2a2403d0 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedExchange-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedWeb3-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedWeb3-1.svg new file mode 100644 index 0000000000..667b3f04f3 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDecentralizedWeb3-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDelegate-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDelegate-0.svg new file mode 100644 index 0000000000..13849d8dd5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoDelegate-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnCoins-0.svg new file mode 100644 index 0000000000..5d121b10be --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnGraph-0.svg new file mode 100644 index 0000000000..8ddb256945 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEarnGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEasyToUse-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEasyToUse-0.svg new file mode 100644 index 0000000000..3b54fd575a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEasyToUse-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEth-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEth-0.svg new file mode 100644 index 0000000000..e8af1266af --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthRewards-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthRewards-0.svg new file mode 100644 index 0000000000..8d7255517d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthStakingChart-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthStakingChart-0.svg new file mode 100644 index 0000000000..f5a6712fe8 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoEthStakingChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoFiat-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoFiat-0.svg new file mode 100644 index 0000000000..e90b1431fa --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoFiat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGem-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGem-0.svg new file mode 100644 index 0000000000..14728e97be --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGem-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGlobalConnections-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGlobalConnections-0.svg new file mode 100644 index 0000000000..100762bfd8 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoGlobalConnections-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoKey-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoKey-1.svg new file mode 100644 index 0000000000..2c15556614 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoMonitoringPerformance-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoMonitoringPerformance-0.svg new file mode 100644 index 0000000000..a311ef01aa --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoMonitoringPerformance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoNftLibrary-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoNftLibrary-0.svg new file mode 100644 index 0000000000..9c3e581f31 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoNftLibrary-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoPasswordWalletLocked-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoPasswordWalletLocked-0.svg new file mode 100644 index 0000000000..6e3dd16500 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoPasswordWalletLocked-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRestaking-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRestaking-2.svg new file mode 100644 index 0000000000..a9b5deffbc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRestaking-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRiskStaking-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRiskStaking-0.svg new file mode 100644 index 0000000000..13a6c17bda --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoRiskStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSecuredAssets-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSecuredAssets-0.svg new file mode 100644 index 0000000000..2d90eaa605 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSecuredAssets-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSelfCustodyWallet-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSelfCustodyWallet-0.svg new file mode 100644 index 0000000000..e02733b585 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoSelfCustodyWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoStakingGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoStakingGraph-0.svg new file mode 100644 index 0000000000..45e775812d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoStakingGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoTrading-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoTrading-0.svg new file mode 100644 index 0000000000..05d634af15 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoTrading-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoWalletWarning-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoWalletWarning-0.svg new file mode 100644 index 0000000000..c692a10890 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoWalletWarning-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/instoprimeMobileApp-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoprimeMobileApp-0.svg new file mode 100644 index 0000000000..063649bdb1 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/instoprimeMobileApp-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..170164ff3f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..bbfd6a6439 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/dark/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/arrowsUpDown-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/arrowsUpDown-0.svg new file mode 100644 index 0000000000..d538b53db2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/arrowsUpDown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-0.svg deleted file mode 100644 index 52c3f44170..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-0.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-1.svg new file mode 100644 index 0000000000..5ee621aae9 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/baseCheckSmall-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-6.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-6.svg deleted file mode 100644 index 9ba9c11f8d..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-6.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-7.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-7.svg new file mode 100644 index 0000000000..76bc13ef7c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/browserMultiPlatform-7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/commodities-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/commodities-0.svg new file mode 100644 index 0000000000..f82e2710ee --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/commodities-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/download-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/download-1.svg new file mode 100644 index 0000000000..c05bb6ce52 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/download-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/inrTrade-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/inrTrade-0.svg new file mode 100644 index 0000000000..46555c0137 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoAccount-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAccount-0.svg new file mode 100644 index 0000000000..b9997fce84 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoAddressBook-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAddressBook-0.svg new file mode 100644 index 0000000000..a9c57726dc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAddressBook-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoAdvancedTradingRebates-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAdvancedTradingRebates-0.svg new file mode 100644 index 0000000000..33fcee87ad --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAdvancedTradingRebates-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoApyInterest-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoApyInterest-2.svg new file mode 100644 index 0000000000..0afa9c6381 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoApyInterest-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoAuthenticatorProgress-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAuthenticatorProgress-0.svg new file mode 100644 index 0000000000..ff05d9e50e --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoAuthenticatorProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowCoins-0.svg new file mode 100644 index 0000000000..d07a6799d1 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowingLending-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowingLending-0.svg new file mode 100644 index 0000000000..ca164a55c5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoBorrowingLending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinFocus-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinFocus-0.svg new file mode 100644 index 0000000000..89b41866be --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinFocus-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinbaseOneShield-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinbaseOneShield-0.svg new file mode 100644 index 0000000000..9d9a126a7b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCoinbaseOneShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoCrypto101-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCrypto101-0.svg new file mode 100644 index 0000000000..4e1df3b78c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoCrypto101-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizationEverything-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizationEverything-0.svg new file mode 100644 index 0000000000..10514b8b14 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizationEverything-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedExchange-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedExchange-1.svg new file mode 100644 index 0000000000..82a190c329 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedExchange-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedWeb3-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedWeb3-1.svg new file mode 100644 index 0000000000..bed11e1ff7 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDecentralizedWeb3-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoDelegate-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDelegate-0.svg new file mode 100644 index 0000000000..e49e2457d4 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoDelegate-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnCoins-0.svg new file mode 100644 index 0000000000..53d83e42a4 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnGraph-0.svg new file mode 100644 index 0000000000..50432534cc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEarnGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEasyToUse-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEasyToUse-0.svg new file mode 100644 index 0000000000..6d722aeff3 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEasyToUse-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEth-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEth-0.svg new file mode 100644 index 0000000000..a2c441344b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthRewards-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthRewards-0.svg new file mode 100644 index 0000000000..7fdb3feef5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthStakingChart-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthStakingChart-0.svg new file mode 100644 index 0000000000..2121170ce5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoEthStakingChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoFiat-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoFiat-0.svg new file mode 100644 index 0000000000..dfdead9d26 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoFiat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoGem-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoGem-0.svg new file mode 100644 index 0000000000..d570587d7f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoGem-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoGlobalConnections-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoGlobalConnections-0.svg new file mode 100644 index 0000000000..5ac76a5d0b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoGlobalConnections-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoKey-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoKey-1.svg new file mode 100644 index 0000000000..50ef8653ad --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoMonitoringPerformance-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoMonitoringPerformance-0.svg new file mode 100644 index 0000000000..e0e1910b33 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoMonitoringPerformance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoNftLibrary-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoNftLibrary-0.svg new file mode 100644 index 0000000000..1d86c99d3f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoNftLibrary-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoPasswordWalletLocked-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoPasswordWalletLocked-0.svg new file mode 100644 index 0000000000..b02d97b02f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoPasswordWalletLocked-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoRestaking-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoRestaking-2.svg new file mode 100644 index 0000000000..258403bb0c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoRestaking-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoRiskStaking-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoRiskStaking-0.svg new file mode 100644 index 0000000000..df32a8a6cf --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoRiskStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoSecuredAssets-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoSecuredAssets-0.svg new file mode 100644 index 0000000000..66a0f7521a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoSecuredAssets-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoSelfCustodyWallet-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoSelfCustodyWallet-0.svg new file mode 100644 index 0000000000..1246189684 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoSelfCustodyWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoStakingGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoStakingGraph-0.svg new file mode 100644 index 0000000000..1976f9fe17 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoStakingGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoTrading-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoTrading-0.svg new file mode 100644 index 0000000000..eae0093937 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoTrading-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoWalletWarning-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoWalletWarning-0.svg new file mode 100644 index 0000000000..05048ab299 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoWalletWarning-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/instoprimeMobileApp-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/instoprimeMobileApp-0.svg new file mode 100644 index 0000000000..dd7ee96e3f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/instoprimeMobileApp-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..3cdcbeb1bd --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..813ebbc115 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/light/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/arrowsUpDown-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/arrowsUpDown-0.svg new file mode 100644 index 0000000000..fb009c5a01 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/arrowsUpDown-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-0.svg deleted file mode 100644 index 52c3f44170..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-0.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-1.svg new file mode 100644 index 0000000000..5ee621aae9 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/baseCheckSmall-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-6.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-6.svg deleted file mode 100644 index b926f856af..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-6.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-7.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-7.svg new file mode 100644 index 0000000000..c4aec2221d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/browserMultiPlatform-7.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/commodities-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/commodities-0.svg new file mode 100644 index 0000000000..d4450ab8ff --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/commodities-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/download-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/download-1.svg new file mode 100644 index 0000000000..7267e902b8 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/download-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/inrTrade-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/inrTrade-0.svg new file mode 100644 index 0000000000..bc4b1014d6 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAccount-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAccount-0.svg new file mode 100644 index 0000000000..b326165f67 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAccount-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAddressBook-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAddressBook-0.svg new file mode 100644 index 0000000000..6499402bbe --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAddressBook-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAdvancedTradingRebates-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAdvancedTradingRebates-0.svg new file mode 100644 index 0000000000..c3ce02871a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAdvancedTradingRebates-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoApyInterest-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoApyInterest-2.svg new file mode 100644 index 0000000000..e5b4b19136 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoApyInterest-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAuthenticatorProgress-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAuthenticatorProgress-0.svg new file mode 100644 index 0000000000..e9059124ac --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoAuthenticatorProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowCoins-0.svg new file mode 100644 index 0000000000..1e2e544249 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowingLending-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowingLending-0.svg new file mode 100644 index 0000000000..5c4ea530cd --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoBorrowingLending-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinFocus-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinFocus-0.svg new file mode 100644 index 0000000000..2aa785b97d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinFocus-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinbaseOneShield-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinbaseOneShield-0.svg new file mode 100644 index 0000000000..1f51b0fdc2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCoinbaseOneShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCrypto101-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCrypto101-0.svg new file mode 100644 index 0000000000..471d352c63 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoCrypto101-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizationEverything-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizationEverything-0.svg new file mode 100644 index 0000000000..5055403da5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizationEverything-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedExchange-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedExchange-1.svg new file mode 100644 index 0000000000..8c3a552d79 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedExchange-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedWeb3-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedWeb3-1.svg new file mode 100644 index 0000000000..b95da5fda6 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDecentralizedWeb3-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDelegate-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDelegate-0.svg new file mode 100644 index 0000000000..0d94a23989 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoDelegate-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnCoins-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnCoins-0.svg new file mode 100644 index 0000000000..17201dc65b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnCoins-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnGraph-0.svg new file mode 100644 index 0000000000..ec91b9c5ad --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEarnGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEasyToUse-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEasyToUse-0.svg new file mode 100644 index 0000000000..a57caf45b2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEasyToUse-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEth-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEth-0.svg new file mode 100644 index 0000000000..f8afe547f2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEth-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthRewards-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthRewards-0.svg new file mode 100644 index 0000000000..f732b8cc38 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthStakingChart-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthStakingChart-0.svg new file mode 100644 index 0000000000..98d0d77011 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoEthStakingChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoFiat-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoFiat-0.svg new file mode 100644 index 0000000000..76fbc08569 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoFiat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGem-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGem-0.svg new file mode 100644 index 0000000000..9501e12915 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGem-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGlobalConnections-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGlobalConnections-0.svg new file mode 100644 index 0000000000..370e84ad23 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoGlobalConnections-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoKey-1.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoKey-1.svg new file mode 100644 index 0000000000..82a51aedde --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoMonitoringPerformance-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoMonitoringPerformance-0.svg new file mode 100644 index 0000000000..7a16472967 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoMonitoringPerformance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoNftLibrary-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoNftLibrary-0.svg new file mode 100644 index 0000000000..f9156badb6 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoNftLibrary-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoPasswordWalletLocked-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoPasswordWalletLocked-0.svg new file mode 100644 index 0000000000..6fa8df8983 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoPasswordWalletLocked-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRestaking-2.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRestaking-2.svg new file mode 100644 index 0000000000..a2458162bf --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRestaking-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRiskStaking-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRiskStaking-0.svg new file mode 100644 index 0000000000..cc9cb8b67b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoRiskStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSecuredAssets-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSecuredAssets-0.svg new file mode 100644 index 0000000000..0af16eb28e --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSecuredAssets-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSelfCustodyWallet-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSelfCustodyWallet-0.svg new file mode 100644 index 0000000000..74768b2b1b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoSelfCustodyWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoStakingGraph-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoStakingGraph-0.svg new file mode 100644 index 0000000000..8945f81f33 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoStakingGraph-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoTrading-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoTrading-0.svg new file mode 100644 index 0000000000..cb6e68d1da --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoTrading-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoWalletWarning-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoWalletWarning-0.svg new file mode 100644 index 0000000000..26c910a178 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoWalletWarning-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoprimeMobileApp-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoprimeMobileApp-0.svg new file mode 100644 index 0000000000..c4a3075555 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/instoprimeMobileApp-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..d28ddf0b9c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..4ea928c029 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svg/themeable/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/arrowsUpDown-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/arrowsUpDown-0.js new file mode 100644 index 0000000000..1d5a372c51 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/arrowsUpDown-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-0.js deleted file mode 100644 index 309f8ccad1..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-0.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - content: ``, -}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-1.js new file mode 100644 index 0000000000..e74a5ad95d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/baseCheckSmall-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-6.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-6.js deleted file mode 100644 index 09e37c4526..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-6.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - content: ``, -}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-7.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-7.js new file mode 100644 index 0000000000..7970974568 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/browserMultiPlatform-7.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/commodities-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/commodities-0.js new file mode 100644 index 0000000000..1926560328 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/commodities-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/download-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/download-1.js new file mode 100644 index 0000000000..3e8bc3f32b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/download-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/inrTrade-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/inrTrade-0.js new file mode 100644 index 0000000000..13cf1e705a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/inrTrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAccount-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAccount-0.js new file mode 100644 index 0000000000..1a8271a245 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAccount-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAddressBook-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAddressBook-0.js new file mode 100644 index 0000000000..d9b7a1a995 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAddressBook-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAdvancedTradingRebates-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAdvancedTradingRebates-0.js new file mode 100644 index 0000000000..173c1fd136 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAdvancedTradingRebates-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoApyInterest-2.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoApyInterest-2.js new file mode 100644 index 0000000000..0d080b610d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoApyInterest-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAuthenticatorProgress-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAuthenticatorProgress-0.js new file mode 100644 index 0000000000..462ffd5e0a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoAuthenticatorProgress-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowCoins-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowCoins-0.js new file mode 100644 index 0000000000..99279e7fa1 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowCoins-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowingLending-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowingLending-0.js new file mode 100644 index 0000000000..2f1a7062b8 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoBorrowingLending-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinFocus-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinFocus-0.js new file mode 100644 index 0000000000..2529ed84b2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinFocus-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinbaseOneShield-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinbaseOneShield-0.js new file mode 100644 index 0000000000..b705d2a63a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCoinbaseOneShield-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCrypto101-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCrypto101-0.js new file mode 100644 index 0000000000..45f6af48fb --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoCrypto101-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizationEverything-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizationEverything-0.js new file mode 100644 index 0000000000..783b7a77f0 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizationEverything-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedExchange-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedExchange-1.js new file mode 100644 index 0000000000..015437b4bf --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedExchange-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedWeb3-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedWeb3-1.js new file mode 100644 index 0000000000..b2cf859470 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDecentralizedWeb3-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDelegate-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDelegate-0.js new file mode 100644 index 0000000000..44d8dca849 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoDelegate-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnCoins-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnCoins-0.js new file mode 100644 index 0000000000..2791ca15ce --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnCoins-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnGraph-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnGraph-0.js new file mode 100644 index 0000000000..8211200d8a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEarnGraph-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEasyToUse-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEasyToUse-0.js new file mode 100644 index 0000000000..f64591f38b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEasyToUse-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEth-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEth-0.js new file mode 100644 index 0000000000..ecfd25c406 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEth-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthRewards-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthRewards-0.js new file mode 100644 index 0000000000..b25bcfd796 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthStakingChart-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthStakingChart-0.js new file mode 100644 index 0000000000..f38988b926 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoEthStakingChart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoFiat-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoFiat-0.js new file mode 100644 index 0000000000..f96c61790e --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoFiat-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGem-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGem-0.js new file mode 100644 index 0000000000..e5f404030b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGem-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGlobalConnections-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGlobalConnections-0.js new file mode 100644 index 0000000000..0d422a3085 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoGlobalConnections-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoKey-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoKey-1.js new file mode 100644 index 0000000000..5789cbadfc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoMonitoringPerformance-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoMonitoringPerformance-0.js new file mode 100644 index 0000000000..2b924aceb3 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoMonitoringPerformance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoNftLibrary-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoNftLibrary-0.js new file mode 100644 index 0000000000..e2de237613 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoNftLibrary-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoPasswordWalletLocked-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoPasswordWalletLocked-0.js new file mode 100644 index 0000000000..6e0555de86 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoPasswordWalletLocked-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRestaking-2.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRestaking-2.js new file mode 100644 index 0000000000..fa812f4810 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRestaking-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRiskStaking-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRiskStaking-0.js new file mode 100644 index 0000000000..3e91b03604 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoRiskStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSecuredAssets-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSecuredAssets-0.js new file mode 100644 index 0000000000..d31efa6c68 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSecuredAssets-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSelfCustodyWallet-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSelfCustodyWallet-0.js new file mode 100644 index 0000000000..36ddb23900 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoSelfCustodyWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoStakingGraph-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoStakingGraph-0.js new file mode 100644 index 0000000000..6875d0addc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoStakingGraph-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoTrading-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoTrading-0.js new file mode 100644 index 0000000000..54c50fd92a --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoTrading-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoWalletWarning-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoWalletWarning-0.js new file mode 100644 index 0000000000..8e9f9aa22d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoWalletWarning-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoprimeMobileApp-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoprimeMobileApp-0.js new file mode 100644 index 0000000000..f0f2d50571 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/instoprimeMobileApp-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrow-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrow-0.js new file mode 100644 index 0000000000..3893e8cd12 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrow-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrowBlue-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrowBlue-0.js new file mode 100644 index 0000000000..f85c6f2d28 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/dark/pieChartWithArrowBlue-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/arrowsUpDown-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/arrowsUpDown-0.js new file mode 100644 index 0000000000..f94105468f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/arrowsUpDown-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-0.js deleted file mode 100644 index 309f8ccad1..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-0.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - content: ``, -}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-1.js new file mode 100644 index 0000000000..e74a5ad95d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/baseCheckSmall-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-6.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-6.js deleted file mode 100644 index 7118e5c557..0000000000 --- a/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-6.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - content: ``, -}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-7.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-7.js new file mode 100644 index 0000000000..e9085ed83f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/browserMultiPlatform-7.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/commodities-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/commodities-0.js new file mode 100644 index 0000000000..e3bf6f132c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/commodities-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/download-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/download-1.js new file mode 100644 index 0000000000..53b4249340 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/download-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/inrTrade-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/inrTrade-0.js new file mode 100644 index 0000000000..84a7aee9eb --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/inrTrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAccount-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAccount-0.js new file mode 100644 index 0000000000..0c6f7d6e8b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAccount-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAddressBook-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAddressBook-0.js new file mode 100644 index 0000000000..40e44dfa3b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAddressBook-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAdvancedTradingRebates-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAdvancedTradingRebates-0.js new file mode 100644 index 0000000000..1941c0e3d5 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAdvancedTradingRebates-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoApyInterest-2.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoApyInterest-2.js new file mode 100644 index 0000000000..288b14707f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoApyInterest-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAuthenticatorProgress-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAuthenticatorProgress-0.js new file mode 100644 index 0000000000..c284478175 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoAuthenticatorProgress-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowCoins-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowCoins-0.js new file mode 100644 index 0000000000..f84bb84d12 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowCoins-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowingLending-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowingLending-0.js new file mode 100644 index 0000000000..89e8fc7372 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoBorrowingLending-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinFocus-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinFocus-0.js new file mode 100644 index 0000000000..d16e4d673d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinFocus-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinbaseOneShield-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinbaseOneShield-0.js new file mode 100644 index 0000000000..924a2403b9 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCoinbaseOneShield-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCrypto101-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCrypto101-0.js new file mode 100644 index 0000000000..1db5fa2073 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoCrypto101-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizationEverything-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizationEverything-0.js new file mode 100644 index 0000000000..59aae36f4f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizationEverything-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedExchange-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedExchange-1.js new file mode 100644 index 0000000000..161fbfd1ad --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedExchange-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedWeb3-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedWeb3-1.js new file mode 100644 index 0000000000..3a57723e45 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDecentralizedWeb3-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDelegate-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDelegate-0.js new file mode 100644 index 0000000000..2500769242 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoDelegate-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnCoins-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnCoins-0.js new file mode 100644 index 0000000000..887afafe86 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnCoins-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnGraph-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnGraph-0.js new file mode 100644 index 0000000000..8bbb73fc3d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEarnGraph-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEasyToUse-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEasyToUse-0.js new file mode 100644 index 0000000000..d0a9c8fd12 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEasyToUse-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEth-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEth-0.js new file mode 100644 index 0000000000..575ee6b1c2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEth-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthRewards-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthRewards-0.js new file mode 100644 index 0000000000..693091905c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthStakingChart-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthStakingChart-0.js new file mode 100644 index 0000000000..ef2a9c876d --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoEthStakingChart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoFiat-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoFiat-0.js new file mode 100644 index 0000000000..899fba0d8b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoFiat-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGem-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGem-0.js new file mode 100644 index 0000000000..fa446bba6c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGem-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGlobalConnections-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGlobalConnections-0.js new file mode 100644 index 0000000000..f8fb43778c --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoGlobalConnections-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoKey-1.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoKey-1.js new file mode 100644 index 0000000000..324cb16b3b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoMonitoringPerformance-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoMonitoringPerformance-0.js new file mode 100644 index 0000000000..f4f8ee1002 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoMonitoringPerformance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoNftLibrary-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoNftLibrary-0.js new file mode 100644 index 0000000000..826e68c476 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoNftLibrary-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoPasswordWalletLocked-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoPasswordWalletLocked-0.js new file mode 100644 index 0000000000..99d92c27cf --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoPasswordWalletLocked-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRestaking-2.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRestaking-2.js new file mode 100644 index 0000000000..548baa324b --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRestaking-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRiskStaking-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRiskStaking-0.js new file mode 100644 index 0000000000..f38e6950a8 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoRiskStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSecuredAssets-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSecuredAssets-0.js new file mode 100644 index 0000000000..47d152a7f2 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSecuredAssets-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSelfCustodyWallet-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSelfCustodyWallet-0.js new file mode 100644 index 0000000000..ad184a58dc --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoSelfCustodyWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoStakingGraph-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoStakingGraph-0.js new file mode 100644 index 0000000000..59e0185f2f --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoStakingGraph-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoTrading-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoTrading-0.js new file mode 100644 index 0000000000..cb5a265a55 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoTrading-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoWalletWarning-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoWalletWarning-0.js new file mode 100644 index 0000000000..f890283067 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoWalletWarning-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoprimeMobileApp-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoprimeMobileApp-0.js new file mode 100644 index 0000000000..ea242137db --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/instoprimeMobileApp-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrow-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrow-0.js new file mode 100644 index 0000000000..5f9d6719e6 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrow-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrowBlue-0.js b/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrowBlue-0.js new file mode 100644 index 0000000000..ca5b1d4cf6 --- /dev/null +++ b/packages/illustrations/src/__generated__/pictogram/svgJs/light/pieChartWithArrowBlue-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/pictogram/types/PictogramName.ts b/packages/illustrations/src/__generated__/pictogram/types/PictogramName.ts index 8f91230f59..f4334c921e 100644 --- a/packages/illustrations/src/__generated__/pictogram/types/PictogramName.ts +++ b/packages/illustrations/src/__generated__/pictogram/types/PictogramName.ts @@ -24,6 +24,7 @@ export type PictogramName = | 'apartOfDropsNft' | 'applyForHigherLimits' | 'apyInterest' + | 'arrowsUpDown' | 'assetEncryption' | 'assetHubNavigation' | 'assetManagement' @@ -248,6 +249,7 @@ export type PictogramName = | 'commerceCheckout' | 'commerceInvoice' | 'commerceNavigation' + | 'commodities' | 'completeQuiz' | 'complianceNavigation' | 'congratulations' @@ -278,6 +280,7 @@ export type PictogramName = | 'directDepositNavigation' | 'dollarShowcase' | 'done' + | 'download' | 'driversLicense' | 'driversLicenseWheel' | 'earnCoins' @@ -326,9 +329,45 @@ export type PictogramName = | 'idError' | 'idVerification' | 'increaseLimits' + | 'inrTrade' | 'instantUnstakingClock' | 'institutionalNavigation' | 'institutions' + | 'instoAccount' + | 'instoAddressBook' + | 'instoAdvancedTradingRebates' + | 'instoApyInterest' + | 'instoAuthenticatorProgress' + | 'instoBorrowCoins' + | 'instoBorrowingLending' + | 'instoCoinbaseOneShield' + | 'instoCoinFocus' + | 'instoCrypto101' + | 'instoDecentralizationEverything' + | 'instoDecentralizedExchange' + | 'instoDecentralizedWeb3' + | 'instoDelegate' + | 'instoEarnCoins' + | 'instoEarnGraph' + | 'instoEasyToUse' + | 'instoEth' + | 'instoEthRewards' + | 'instoEthStakingChart' + | 'instoFiat' + | 'instoGem' + | 'instoGlobalConnections' + | 'instoKey' + | 'instoMonitoringPerformance' + | 'instoNftLibrary' + | 'instoPasswordWalletLocked' + | 'instoprimeMobileApp' + | 'instoRestaking' + | 'instoRiskStaking' + | 'instoSecuredAssets' + | 'instoSelfCustodyWallet' + | 'instoStakingGraph' + | 'instoTrading' + | 'instoWalletWarning' | 'internationalExchangeNavigation' | 'internet' | 'investGraph' @@ -394,6 +433,8 @@ export type PictogramName = | 'phone' | 'pieChart' | 'pieChartData' + | 'pieChartWithArrow' + | 'pieChartWithArrowBlue' | 'pizza' | 'planet' | 'pluginBrowser' diff --git a/packages/illustrations/src/__generated__/spotIcon/data/descriptionMap.ts b/packages/illustrations/src/__generated__/spotIcon/data/descriptionMap.ts index 0a2c7b6f92..f314f9e542 100644 --- a/packages/illustrations/src/__generated__/spotIcon/data/descriptionMap.ts +++ b/packages/illustrations/src/__generated__/spotIcon/data/descriptionMap.ts @@ -9,403 +9,538 @@ * The search query filters the shown illustrations based on matches with name or description. */ const descriptionMap: Record = { - 'chat bubble': ['chat'], - speech: ['chat'], - communication: ['chat'], - social: ['chat'], - interaction: ['chat'], - message: ['chat', 'email'], - '💬': ['chat'], - notification: ['contract'], - hub: ['contract'], - notify: ['contract'], - alert: ['contract'], - ping: ['contract'], - red: ['contract'], - dot: ['contract'], - news: ['contract'], - paper: ['contract'], - doc: ['contract'], - '': [ - 'coinbaseOneProductInvestWeekly', - 'assetEmptyStateDa', - 'assetEmptyStateEc', - 'assetEmptyStateDb', - 'assetEmptyStateDc', - 'assetEmptyStateEe', - 'multiCoin', - 'assetHubProduct', - 'coinbaseOneChart', - 'assetEmptyStateBe', - 'nodeProduct', - 'rewardsProduct', - 'assetEmptyStateDd', - 'assetEmptyStateAd', - 'assetEmptyStateAa', - 'assetEmptyStateEb', - 'assetEmptyStateAe', - 'assetEmptyStateCd', - 'assetEmptyStateBa', - 'assetEmptyStateCe', - 'assetEmptyStateAc', - 'assetEmptyStateCa', - 'assetEmptyStateBb', - 'assetEmptyStateCc', - 'assetEmptyStateDe', - 'noFees', - 'bonusTwoPercent', - 'assetEmptyStateBd', - 'assetEmptyStateAb', - 'assetEmptyStateEa', - 'bonusFivePercent', - 'send', - 'assetEmptyStateEd', - 'assetEmptyStateCb', - 'assetEmptyStateBc', - 'businessProduct', - ], product: [ - 'cb1Cash', - 'productCompliance', + 'productCoinbaseCard', 'productPro', + 'productCompliance', + 'productEarn', + 'productWallet', + 'advancedTradeProduct', 'paySDKProduct', + 'signInProduct', + 'exchangeProduct', + 'commerceProduct', + 'primeProduct', + 'stakingProduct', 'assetManagementProduct', - 'productCoinbaseCard', + 'coinbase', 'helpCenterProduct', - 'coinbaseOneChart', - 'productEarn', - 'stakingProduct', 'walletLogo', - 'internationalExchangeProduct', - 'borrowProduct', - 'learningRewardsProduct', + 'walletAsAServiceProduct', + 'cloudProduct', + 'rosettaProduct', + 'privateClientProduct', 'institutionalProduct', + 'custodyProduct', 'dataMarketplace', - 'coinbase', + 'nftProduct', 'venturesProduct', - 'participateProduct', - 'walletAsAServiceProduct', 'coinbaseOneProduct', - 'cloudProduct', - 'exchangeProduct', - 'primeProduct', - 'signInProduct', - 'nftProduct', - 'privateClientProduct', + 'base', + 'participateProduct', 'delegateProduct', - 'advancedTradeProduct', - 'commerceProduct', - 'custodyProduct', - 'productWallet', - 'rosettaProduct', + 'learningRewardsProduct', + 'internationalExchangeProduct', + 'borrowProduct', + 'coinbaseOneChart', + 'cb1Cash', 'businessProduct', - 'base', + 'instoStakingProduct', + 'instoAdvancedTradeProduct', + 'instoPaySDKProduct', + 'instoDataMarketplace', + 'instoWalletAsAServiceProduct', + 'instoBorrowProduct', + 'instoLearningRewardsProduct', + 'instoCommerceProduct', + 'instoPrivateClientProduct', + 'instoCustodyProduct', + 'instoPrimeProduct', + 'instoHelpCenterProduct', + 'instoBusinessProduct', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoSignInProduct', + 'instoCloudProduct', + 'instoProductWallet', ], icons: [ - 'cb1Cash', - 'productCompliance', + 'productCoinbaseCard', 'productPro', + 'productCompliance', + 'productEarn', + 'productWallet', + 'advancedTradeProduct', 'paySDKProduct', + 'signInProduct', + 'exchangeProduct', + 'commerceProduct', + 'primeProduct', + 'stakingProduct', 'assetManagementProduct', - 'productCoinbaseCard', + 'coinbase', 'helpCenterProduct', - 'coinbaseOneChart', - 'productEarn', - 'stakingProduct', 'walletLogo', - 'internationalExchangeProduct', - 'borrowProduct', - 'learningRewardsProduct', + 'walletAsAServiceProduct', + 'cloudProduct', + 'rosettaProduct', + 'privateClientProduct', 'institutionalProduct', + 'custodyProduct', 'dataMarketplace', - 'coinbase', + 'nftProduct', 'venturesProduct', - 'participateProduct', - 'walletAsAServiceProduct', 'coinbaseOneProduct', - 'cloudProduct', - 'exchangeProduct', - 'primeProduct', - 'signInProduct', - 'nftProduct', - 'privateClientProduct', + 'base', + 'participateProduct', 'delegateProduct', - 'advancedTradeProduct', - 'commerceProduct', - 'custodyProduct', - 'productWallet', - 'rosettaProduct', + 'learningRewardsProduct', + 'internationalExchangeProduct', + 'borrowProduct', + 'coinbaseOneChart', + 'cb1Cash', 'businessProduct', - 'base', + 'instoStakingProduct', + 'instoAdvancedTradeProduct', + 'instoPaySDKProduct', + 'instoDataMarketplace', + 'instoWalletAsAServiceProduct', + 'instoBorrowProduct', + 'instoLearningRewardsProduct', + 'instoCommerceProduct', + 'instoPrivateClientProduct', + 'instoCustodyProduct', + 'instoPrimeProduct', + 'instoHelpCenterProduct', + 'instoBusinessProduct', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoSignInProduct', + 'instoCloudProduct', + 'instoProductWallet', ], - borrow: ['cb1Cash', 'borrowProduct'], - check: ['idVerification', 'done', 'delegate'], - checkmark: ['idVerification', 'done', 'delegate'], - secure: ['idVerification'], - '2fa': ['idVerification', '2fa'], - protection: ['idVerification', 'shield'], - 'identity card': ['idVerification'], - profile: ['idVerification'], - personal: ['idVerification'], - ID: ['idVerification'], - human: ['idVerification'], - card: ['idVerification', 'productCoinbaseCard', 'creditCard'], - '🆔': ['idVerification'], - '✅': ['idVerification', 'done', 'delegate'], - '✔️': ['idVerification', 'done', 'delegate'], - '👶': ['idVerification', 'delegate'], - '👧': ['idVerification', 'delegate'], - '🧒': ['idVerification', 'delegate'], - '👦': ['idVerification', 'delegate'], - '👩': ['idVerification', 'delegate'], - '🧑': ['idVerification', 'delegate'], - '👨': ['idVerification', 'delegate'], - '👩‍🦱': ['idVerification', 'delegate'], - '🧑‍🦱': ['idVerification', 'delegate'], - '👨‍🦱': ['idVerification', 'delegate'], - '👩‍🦰': ['idVerification', 'delegate'], - '🧑‍🦰': ['idVerification', 'delegate'], - '👨‍🦰': ['idVerification', 'delegate'], - '👱‍♀️': ['idVerification', 'delegate'], - '👱': ['idVerification', 'delegate'], - '👱‍♂️': ['idVerification', 'delegate'], - '👩‍🦳': ['idVerification', 'delegate'], - '🧑‍🦳': ['idVerification', 'delegate'], - '👨‍🦳': ['idVerification', 'delegate'], - '👩‍🦲': ['idVerification', 'delegate'], - '🧑‍🦲': ['idVerification', 'delegate'], - '👨‍🦲': ['idVerification', 'delegate'], - '🧔': ['idVerification', 'delegate'], - '👵': ['idVerification', 'delegate'], - '🧓': ['idVerification', 'delegate'], - '👴': ['idVerification', 'delegate'], - '👲': ['idVerification', 'delegate'], - '👳‍♀️': ['idVerification', 'delegate'], - '👳': ['idVerification', 'delegate'], - '👳‍♂️': ['idVerification', 'delegate'], - '🧕': ['idVerification', 'delegate'], - '👮‍♀️': ['idVerification', 'delegate'], - '👮': ['idVerification', 'delegate'], - '👮‍♂️': ['idVerification', 'delegate'], - '👷‍♀️': ['idVerification', 'delegate'], - '👷': ['idVerification', 'delegate'], - '👷‍♂️': ['idVerification', 'delegate'], - '💂‍♀️': ['idVerification', 'delegate'], - '💂': ['idVerification', 'delegate'], - '💂‍♂️': ['idVerification', 'delegate'], - '🕵️‍♀️': ['idVerification', 'delegate'], - '🕵️': ['idVerification', 'delegate'], - '🕵️‍♂️': ['idVerification', 'delegate'], - '👩‍⚕️': ['idVerification', 'delegate'], - '🧑‍⚕️': ['idVerification', 'delegate'], - '👨‍⚕️': ['idVerification', 'delegate'], - '👩‍🌾': ['idVerification', 'delegate'], - '🧑‍🌾': ['idVerification', 'delegate'], - '👨‍🌾': ['idVerification', 'delegate'], - '👩‍🍳': ['idVerification', 'delegate'], - '🧑‍🍳': ['idVerification', 'delegate'], - '👨‍🍳': ['idVerification', 'delegate'], - '👩‍🎓': ['idVerification', 'delegate'], - '🧑‍🎓': ['idVerification', 'delegate'], - '👨‍🎓': ['idVerification', 'delegate'], - '👩‍🎤': ['idVerification', 'delegate'], - '🧑‍🎤': ['idVerification', 'delegate'], - '👨‍🎤': ['idVerification', 'delegate'], - '👩‍🏫': ['idVerification', 'delegate'], - '🧑‍🏫': ['idVerification', 'delegate'], - '👨‍🏫': ['idVerification', 'delegate'], - '👩‍🏭': ['idVerification', 'delegate'], - '🧑‍🏭': ['idVerification', 'delegate'], - '👨‍🏭': ['idVerification', 'delegate'], - '👩‍💻': ['idVerification', 'delegate'], - '🧑‍💻': ['idVerification', 'delegate'], - '👨‍💻': ['idVerification', 'delegate'], - '👩‍💼': ['idVerification', 'delegate'], - '🧑‍💼': ['idVerification', 'delegate'], - '👨‍💼': ['idVerification', 'delegate'], - '👩‍🔧': ['idVerification', 'delegate'], - '🧑‍🔧': ['idVerification', 'delegate'], - '👨‍🔧': ['idVerification', 'delegate'], - '👩‍🔬': ['idVerification', 'delegate'], - '🧑‍🔬': ['idVerification', 'delegate'], - '👨‍🔬': ['idVerification', 'delegate'], - '👩‍🎨': ['idVerification', 'delegate'], - '🧑‍🎨': ['idVerification', 'delegate'], - '👨‍🎨': ['idVerification', 'delegate'], - '👩‍🚒': ['idVerification', 'delegate'], - '🧑‍🚒': ['idVerification', 'delegate'], - '👨‍🚒': ['idVerification', 'delegate'], - '👩‍✈️': ['idVerification', 'delegate'], - '🧑‍✈️': ['idVerification', 'delegate'], - '👨‍✈️': ['idVerification', 'delegate'], - '👩‍🚀': ['idVerification', 'delegate'], - '🧑‍🚀': ['idVerification', 'delegate'], - '👨‍🚀': ['idVerification', 'delegate'], - '👩‍⚖️': ['idVerification', 'delegate'], - '🤵‍♀️': ['idVerification', 'delegate'], - '🤵': ['idVerification', 'delegate'], - '🤵‍♂️': ['idVerification', 'delegate'], - '👸': ['idVerification', 'delegate'], - '🤴': ['idVerification', 'delegate'], - '🥷': ['idVerification', 'delegate'], - '🦸‍♀️': ['idVerification', 'delegate'], - '🦸': ['idVerification', 'delegate'], - '🦸‍♂️': ['idVerification', 'delegate'], - '🦹‍♀️': ['idVerification', 'delegate'], - '🦹': ['idVerification', 'delegate'], - '🦹‍♂️': ['idVerification', 'delegate'], - '🤶': ['idVerification', 'delegate'], - '🧑‍🎄': ['idVerification', 'delegate'], - '🎅': ['idVerification', 'delegate'], - '🧙‍♀️': ['idVerification', 'delegate'], - '🧙': ['idVerification', 'delegate'], - '🧙‍♂️': ['idVerification', 'delegate'], - '🧝‍♀️': ['idVerification', 'delegate'], - '🧝': ['idVerification', 'delegate'], - '🧝‍♂️': ['idVerification', 'delegate'], - '🧛‍♀️': ['idVerification', 'delegate'], - '🧛': ['idVerification', 'delegate'], - '🧛‍♂️': ['idVerification', 'delegate'], - 'success state': ['idVerification', 'done'], - circle: ['done', 'error'], - tick: ['done'], - confirmation: ['done'], - success: ['done'], - positive: ['done'], - done: ['done'], - green: ['done'], - icon: ['productCompliance', 'productPro', 'productCoinbaseCard', 'productEarn', 'productWallet'], - small: ['productCompliance', 'productPro', 'productCoinbaseCard', 'productEarn', 'productWallet'], - coinbase: [ + icon: [ + 'productCoinbaseCard', + 'productPro', 'productCompliance', + 'productEarn', + 'productWallet', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoProductWallet', + ], + small: [ + 'productCoinbaseCard', 'productPro', + 'productCompliance', + 'productEarn', + 'productWallet', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoProductWallet', + ], + coinbase: [ 'productCoinbaseCard', + 'productPro', + 'productCompliance', 'productEarn', + 'productWallet', + 'signInProduct', 'coinbase', 'coinbaseOneProduct', - 'signInProduct', - 'productWallet', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoSignInProduct', + 'instoProductWallet', ], '32x32': [ - 'productCompliance', - 'productPro', 'productCoinbaseCard', + 'productPro', + 'productCompliance', 'productEarn', 'productWallet', + 'instoProductPro', + 'instoProductCompliance', + 'instoProductCoinbaseCard', + 'instoProductWallet', ], - compliance: ['productCompliance'], - pro: ['productPro'], - wallet: ['wallet', 'walletLogo', 'walletAsAServiceProduct', 'productWallet'], - storage: ['wallet'], - 'crypto transactions': ['wallet'], - pay: ['wallet', 'paySDKProduct', 'creditCard'], - retrieve: ['wallet'], - 'digital assets': ['wallet'], - '💰': ['wallet'], - '💵': ['wallet', 'bank'], - '💸': ['wallet', 'bank'], - SDK: ['paySDKProduct'], - layers: ['layeredNetworks'], - 'layer three': ['layeredNetworks'], - three: ['layeredNetworks'], - isometric: ['layeredNetworks'], - networks: ['layeredNetworks'], - base: ['layeredNetworks', 'base'], - blue: ['layeredNetworks'], - yellow: ['layeredNetworks', 'outage', 'warning'], - represent: ['delegate'], - envoy: ['delegate'], - agent: ['delegate'], - assign: ['delegate'], - entrust: ['delegate'], - give: ['delegate'], - person: ['delegate', 'coinbaseOneEarn'], - asset: ['assetManagementProduct'], - management: ['assetManagementProduct'], - Coinbase: ['coinbaseOneEarn'], - One: ['coinbaseOneEarn', 'coinbaseOneChart', 'businessProduct'], - Concierge: ['coinbaseOneEarn'], - attendant: ['coinbaseOneEarn'], - credit: ['creditCard'], - debit: ['creditCard'], - money: ['creditCard', 'bank'], - '💳': ['creditCard'], - '🏦': ['creditCard', 'bank'], - '🏧': ['creditCard', 'bank'], - trust: ['authenticator', '2fa'], - true: ['authenticator', '2fa'], - genuine: ['authenticator', '2fa'], - actual: ['authenticator', '2fa'], - verification: ['authenticator', '2fa'], - help: ['helpCenterProduct'], - center: ['helpCenterProduct'], - authenticate: ['2fa'], - device: ['2fa'], - warning: ['outage', 'warning'], - triangle: ['outage', 'warning'], - error: ['outage', 'warning'], - warn: ['outage', 'warning'], - yield: ['outage', 'warning'], - advanced: [ + card: [ + 'productCoinbaseCard', + 'creditCard', + 'idVerification', + 'instoProductCoinbaseCard', + 'instoIdVerification', + ], + pro: ['productPro', 'instoProductPro'], + compliance: ['productCompliance', 'instoProductCompliance'], + earn: ['productEarn'], + wallet: [ + 'productWallet', + 'walletLogo', + 'walletAsAServiceProduct', + 'wallet', + 'instoWalletAsAServiceProduct', + 'instoProductWallet', + ], + '': [ + 'nodeProduct', + 'rewardsProduct', + 'assetHubProduct', + 'multiCoin', + 'noFees', + 'send', 'coinbaseOneChart', + 'businessProduct', + 'bonusTwoPercent', + 'bonusFivePercent', + 'assetEmptyStateAa', + 'assetEmptyStateAb', + 'assetEmptyStateAc', + 'assetEmptyStateAd', + 'assetEmptyStateAe', + 'assetEmptyStateBe', + 'assetEmptyStateBd', + 'assetEmptyStateBc', + 'assetEmptyStateBb', + 'assetEmptyStateBa', + 'assetEmptyStateCa', + 'assetEmptyStateCb', + 'assetEmptyStateCc', + 'assetEmptyStateCd', + 'assetEmptyStateCe', + 'assetEmptyStateDe', + 'assetEmptyStateDd', + 'assetEmptyStateDc', + 'assetEmptyStateDb', + 'assetEmptyStateDa', + 'assetEmptyStateEa', + 'assetEmptyStateEb', + 'assetEmptyStateEc', + 'assetEmptyStateEd', + 'assetEmptyStateEe', + 'coinbaseOneProductInvestWeekly', + 'instantAccess', + 'instoMultiCoin', + 'instoRewardsProduct', + 'instoBusinessProduct', + 'instoAssetHubProduct', + ], + advanced: [ 'advancedTradeProduct', - 'arrowsUpDown', 'derivativesProduct', + 'coinbaseOneChart', 'businessProduct', + 'arrowsUpDown', + 'instoAdvancedTradeProduct', + 'instoDerivativesProduct', + 'instoBusinessProduct', ], - trade: ['coinbaseOneChart', 'advancedTradeProduct', 'businessProduct'], - coinbaseone: ['coinbaseOneChart', 'businessProduct'], - earn: ['productEarn'], - quick: ['fast'], - time: ['fast'], - clock: ['fast'], - speed: ['fast'], - lightning: ['fast'], - '🕦': ['fast'], - '🕐': ['fast'], - '🕚': ['fast'], - '🕥': ['fast'], - '🕧': ['fast'], - '🕙': ['fast'], - '🕣': ['fast'], - '🕠': ['fast'], - '🕝': ['fast'], - '🕢': ['fast'], - '🕟': ['fast'], - '🕜': ['fast'], - '🕤': ['fast'], - '🕡': ['fast'], - '🕞': ['fast'], - '🕘': ['fast'], - '🕒': ['fast'], - '🕗': ['fast'], - '🕔': ['fast'], - '🕑': ['fast'], - '🕖': ['fast'], - '🕓': ['fast'], - '🕛': ['fast'], - '⏰': ['fast'], - '⏱': ['fast'], - '🕰': ['fast'], - '🔄': ['fast'], - '⏳': ['fast'], - '⌛️': ['fast'], + trade: [ + 'advancedTradeProduct', + 'coinbaseOneChart', + 'businessProduct', + 'instoAdvancedTradeProduct', + 'instoBusinessProduct', + ], + pay: ['paySDKProduct', 'wallet', 'creditCard', 'instoPaySDKProduct'], + SDK: ['paySDKProduct', 'instoPaySDKProduct'], + sign: ['signInProduct', 'instoSignInProduct'], + in: ['signInProduct', 'instoSignInProduct'], + with: ['signInProduct', 'instoSignInProduct'], + exchange: ['exchangeProduct', 'internationalExchangeProduct'], + commerce: ['commerceProduct', 'instoCommerceProduct'], + prime: [ + 'primeProduct', + 'derivativesProduct', + 'arrowsUpDown', + 'instoStakingProduct', + 'instoPrimeProduct', + 'instoDerivativesProduct', + ], + staking: ['stakingProduct', 'instoStakingProduct'], + asset: ['assetManagementProduct'], + management: ['assetManagementProduct'], + help: ['helpCenterProduct', 'instoHelpCenterProduct'], + center: ['helpCenterProduct', 'instoHelpCenterProduct'], + as: ['walletAsAServiceProduct', 'instoWalletAsAServiceProduct'], + a: ['walletAsAServiceProduct', 'instoWalletAsAServiceProduct'], + service: ['walletAsAServiceProduct', 'instoWalletAsAServiceProduct'], + cloud: ['cloudProduct', 'instoCloudProduct'], + developer: ['cloudProduct', 'instoCloudProduct'], + portal: ['cloudProduct', 'instoCloudProduct'], + rosetta: ['rosettaProduct'], + private: ['privateClientProduct', 'instoPrivateClientProduct'], + client: ['privateClientProduct', 'instoPrivateClientProduct'], + insto: ['institutionalProduct', 'instoStakingProduct'], + institutional: ['institutionalProduct', 'instoStakingProduct'], + custody: ['custodyProduct', 'instoCustodyProduct'], + data: ['dataMarketplace', 'pieChart', 'instoPieChart', 'instoDataMarketplace'], + marketplace: ['dataMarketplace', 'instoDataMarketplace'], + nft: ['nftProduct'], + ventures: ['venturesProduct'], + one: ['coinbaseOneProduct'], + cb1: ['coinbaseOneProduct'], + base: ['base', 'layeredNetworks', 'instoLayeredNetworks'], + participate: ['participateProduct'], + delegate: ['delegateProduct'], + learning: ['learningRewardsProduct', 'instoLearningRewardsProduct'], + rewards: ['learningRewardsProduct', 'instoLearningRewardsProduct'], + international: ['internationalExchangeProduct'], + i18n: ['internationalExchangeProduct'], + borrow: ['borrowProduct', 'cb1Cash', 'instoBorrowProduct'], envelope: ['email'], letter: ['email'], online: ['email'], send: ['email'], + message: ['email', 'chat', 'instoChat'], '💌': ['email'], '✉️': ['email'], '📨': ['email'], '📩': ['email'], '📧': ['email'], - shield: ['shield'], - guard: ['shield'], - defense: ['shield'], - cover: ['shield'], - safety: ['shield'], - security: ['shield'], - staking: ['stakingProduct'], + storage: ['wallet'], + 'crypto transactions': ['wallet'], + retrieve: ['wallet'], + 'digital assets': ['wallet'], + '💰': ['wallet'], + '💵': ['wallet', 'bank'], + '💸': ['wallet', 'bank'], + represent: ['delegate', 'instoDelegate'], + envoy: ['delegate', 'instoDelegate'], + agent: ['delegate', 'instoDelegate'], + assign: ['delegate', 'instoDelegate'], + entrust: ['delegate', 'instoDelegate'], + give: ['delegate', 'instoDelegate'], + person: ['delegate', 'coinbaseOneEarn', 'instoDelegate', 'instoCoinbaseOneEarn'], + check: ['delegate', 'done', 'idVerification', 'instoDelegate', 'instoIdVerification'], + checkmark: ['delegate', 'done', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '✅': ['delegate', 'done', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '✔️': ['delegate', 'done', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👶': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👧': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧒': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👦': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🦱': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🦱': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🦱': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🦰': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🦰': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🦰': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👱‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👱': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👱‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🦳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🦳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🦳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🦲': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🦲': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🦲': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧔': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👵': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧓': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👴': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👲': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👳‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👳‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧕': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👮‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👮': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👮‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👷‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👷': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👷‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '💂‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '💂': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '💂‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🕵️‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🕵️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🕵️‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍⚕️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍⚕️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍⚕️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🌾': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🌾': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🌾': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🍳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🍳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🍳': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🎓': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🎓': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🎓': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🎤': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🎤': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🎤': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🏫': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🏫': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🏫': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🏭': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🏭': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🏭': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍💻': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍💻': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍💻': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍💼': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍💼': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍💼': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🔧': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🔧': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🔧': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🔬': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🔬': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🔬': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🎨': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🎨': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🎨': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🚒': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🚒': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🚒': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍✈️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍✈️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍✈️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍🚀': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🚀': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👨‍🚀': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👩‍⚖️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🤵‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🤵': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🤵‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '👸': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🤴': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🥷': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦸‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦸': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦸‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦹‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦹': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🦹‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🤶': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧑‍🎄': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🎅': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧙‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧙': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧙‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧝‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧝': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧝‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧛‍♀️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧛': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + '🧛‍♂️': ['delegate', 'idVerification', 'instoDelegate', 'instoIdVerification'], + bank: ['bank'], + fund: ['bank'], + stock: ['bank'], + currency: ['bank'], + money: ['bank', 'creditCard'], + building: ['bank'], + institution: ['bank'], + '🏦': ['bank', 'creditCard'], + '🏧': ['bank', 'creditCard'], + '💴': ['bank'], + '💶': ['bank'], + '💷': ['bank'], + warning: ['warning', 'outage'], + yellow: ['warning', 'outage', 'layeredNetworks', 'instoLayeredNetworks'], + triangle: ['warning', 'outage'], + error: ['warning', 'outage'], + warn: ['warning', 'outage'], + yield: ['warning', 'outage'], + 'chat bubble': ['chat', 'instoChat'], + speech: ['chat', 'instoChat'], + communication: ['chat', 'instoChat'], + social: ['chat', 'instoChat'], + interaction: ['chat', 'instoChat'], + '💬': ['chat', 'instoChat'], + trust: ['2fa', 'authenticator', 'instoAuthenticator'], + true: ['2fa', 'authenticator', 'instoAuthenticator'], + genuine: ['2fa', 'authenticator', 'instoAuthenticator'], + actual: ['2fa', 'authenticator', 'instoAuthenticator'], + verification: ['2fa', 'authenticator', 'instoAuthenticator'], + '2fa': ['2fa', 'idVerification', 'instoIdVerification'], + authenticate: ['2fa'], + device: ['2fa'], + quick: ['fast', 'instoFast'], + time: ['fast', 'instoFast'], + clock: ['fast', 'instoFast'], + speed: ['fast', 'instoFast'], + lightning: ['fast', 'instoFast'], + '🕦': ['fast', 'instoFast'], + '🕐': ['fast', 'instoFast'], + '🕚': ['fast', 'instoFast'], + '🕥': ['fast', 'instoFast'], + '🕧': ['fast', 'instoFast'], + '🕙': ['fast', 'instoFast'], + '🕣': ['fast', 'instoFast'], + '🕠': ['fast', 'instoFast'], + '🕝': ['fast', 'instoFast'], + '🕢': ['fast', 'instoFast'], + '🕟': ['fast', 'instoFast'], + '🕜': ['fast', 'instoFast'], + '🕤': ['fast', 'instoFast'], + '🕡': ['fast', 'instoFast'], + '🕞': ['fast', 'instoFast'], + '🕘': ['fast', 'instoFast'], + '🕒': ['fast', 'instoFast'], + '🕗': ['fast', 'instoFast'], + '🕔': ['fast', 'instoFast'], + '🕑': ['fast', 'instoFast'], + '🕖': ['fast', 'instoFast'], + '🕓': ['fast', 'instoFast'], + '🕛': ['fast', 'instoFast'], + '⏰': ['fast', 'instoFast'], + '⏱': ['fast', 'instoFast'], + '🕰': ['fast', 'instoFast'], + '🔄': ['fast', 'instoFast'], + '⏳': ['fast', 'instoFast'], + '⌛️': ['fast', 'instoFast'], + circle: ['done', 'error'], + tick: ['done'], + confirmation: ['done'], + success: ['done'], + positive: ['done'], + done: ['done'], + green: ['done'], + 'success state': ['done', 'idVerification', 'instoIdVerification'], + credit: ['creditCard'], + debit: ['creditCard'], + '💳': ['creditCard'], + reoccur: ['recurringPurchases', 'instoRecurringPurchases'], + regular: ['recurringPurchases', 'instoRecurringPurchases'], + schedule: ['recurringPurchases', 'instoRecurringPurchases'], + calendar: ['recurringPurchases', 'instoRecurringPurchases'], + organize: ['recurringPurchases', 'instoRecurringPurchases'], + date: ['recurringPurchases', 'instoRecurringPurchases'], + year: ['recurringPurchases', 'instoRecurringPurchases'], + month: ['recurringPurchases', 'instoRecurringPurchases'], + week: ['recurringPurchases', 'instoRecurringPurchases'], + book: ['recurringPurchases', 'instoRecurringPurchases'], + refresh: ['recurringPurchases', 'instoRecurringPurchases'], + '📆': ['recurringPurchases', 'instoRecurringPurchases'], + '📅': ['recurringPurchases', 'instoRecurringPurchases'], + '🗓': ['recurringPurchases', 'instoRecurringPurchases'], + 'chart pie': ['pieChart', 'instoPieChart'], + visualization: ['pieChart', 'instoPieChart'], + numbers: ['pieChart', 'instoPieChart'], + graph: ['pieChart', 'instoPieChart'], + '📊': ['pieChart', 'instoPieChart'], + '📉': ['pieChart', 'instoPieChart'], + '📈': ['pieChart', 'instoPieChart'], + '🥧': ['pieChart', 'instoPieChart'], + secure: ['idVerification', 'instoIdVerification'], + protection: ['idVerification', 'shield', 'instoShield', 'instoIdVerification'], + 'identity card': ['idVerification', 'instoIdVerification'], + profile: ['idVerification', 'instoIdVerification'], + personal: ['idVerification', 'instoIdVerification'], + ID: ['idVerification', 'instoIdVerification'], + human: ['idVerification', 'instoIdVerification'], + '🆔': ['idVerification', 'instoIdVerification'], close: ['error'], cross: ['error'], decline: ['error'], @@ -419,74 +554,49 @@ const descriptionMap: Record = { '🙅‍♀️': ['error'], '🚫': ['error'], '❎': ['error'], - international: ['internationalExchangeProduct'], - exchange: ['internationalExchangeProduct', 'exchangeProduct'], - i18n: ['internationalExchangeProduct'], - learning: ['learningRewardsProduct'], - rewards: ['learningRewardsProduct'], - insto: ['institutionalProduct'], - institutional: ['institutionalProduct'], - data: ['dataMarketplace', 'pieChart'], - marketplace: ['dataMarketplace'], - ventures: ['venturesProduct'], - 'chart pie': ['pieChart'], - visualization: ['pieChart'], - numbers: ['pieChart'], - graph: ['pieChart'], - '📊': ['pieChart'], - '📉': ['pieChart'], - '📈': ['pieChart'], - '🥧': ['pieChart'], - participate: ['participateProduct'], - as: ['walletAsAServiceProduct'], - a: ['walletAsAServiceProduct'], - service: ['walletAsAServiceProduct'], - one: ['coinbaseOneProduct'], - cb1: ['coinbaseOneProduct'], - cloud: ['cloudProduct'], - developer: ['cloudProduct'], - portal: ['cloudProduct'], - prime: ['primeProduct', 'arrowsUpDown', 'derivativesProduct'], - sign: ['signInProduct'], - in: ['signInProduct'], - with: ['signInProduct'], - nft: ['nftProduct'], - private: ['privateClientProduct'], - client: ['privateClientProduct'], - delegate: ['delegateProduct'], - reoccur: ['recurringPurchases'], - regular: ['recurringPurchases'], - schedule: ['recurringPurchases'], - calendar: ['recurringPurchases'], - organize: ['recurringPurchases'], - date: ['recurringPurchases'], - year: ['recurringPurchases'], - month: ['recurringPurchases'], - week: ['recurringPurchases'], - book: ['recurringPurchases'], - refresh: ['recurringPurchases'], - '📆': ['recurringPurchases'], - '📅': ['recurringPurchases'], - '🗓': ['recurringPurchases'], - commerce: ['commerceProduct'], - pictogram: ['arrowsUpDown', 'derivativesProduct'], - leverage: ['arrowsUpDown', 'derivativesProduct'], - invest: ['arrowsUpDown', 'derivativesProduct'], - derive: ['arrowsUpDown', 'derivativesProduct'], - arrow: ['arrowsUpDown', 'derivativesProduct'], - triangles: ['arrowsUpDown', 'derivativesProduct'], - derivatives: ['derivativesProduct'], - custody: ['custodyProduct'], - rosetta: ['rosettaProduct'], - bank: ['bank'], - fund: ['bank'], - stock: ['bank'], - currency: ['bank'], - building: ['bank'], - institution: ['bank'], - '💴': ['bank'], - '💶': ['bank'], - '💷': ['bank'], + shield: ['shield', 'instoShield'], + guard: ['shield', 'instoShield'], + defense: ['shield', 'instoShield'], + cover: ['shield', 'instoShield'], + safety: ['shield', 'instoShield'], + security: ['shield', 'instoShield'], + derivatives: ['derivativesProduct', 'instoDerivativesProduct'], + pictogram: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + leverage: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + invest: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + derive: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + arrow: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + triangles: ['derivativesProduct', 'arrowsUpDown', 'instoDerivativesProduct'], + coinbaseone: ['coinbaseOneChart', 'businessProduct', 'instoBusinessProduct'], + One: [ + 'coinbaseOneChart', + 'coinbaseOneEarn', + 'businessProduct', + 'instoBusinessProduct', + 'instoCoinbaseOneEarn', + ], + Coinbase: ['coinbaseOneEarn', 'instoCoinbaseOneEarn'], + Concierge: ['coinbaseOneEarn', 'instoCoinbaseOneEarn'], + attendant: ['coinbaseOneEarn', 'instoCoinbaseOneEarn'], + layers: ['layeredNetworks', 'instoLayeredNetworks'], + 'layer three': ['layeredNetworks', 'instoLayeredNetworks'], + three: ['layeredNetworks', 'instoLayeredNetworks'], + isometric: ['layeredNetworks', 'instoLayeredNetworks'], + networks: ['layeredNetworks', 'instoLayeredNetworks'], + blue: ['layeredNetworks', 'instoLayeredNetworks'], + notification: ['contract'], + hub: ['contract'], + notify: ['contract'], + alert: ['contract'], + ping: ['contract'], + red: ['contract'], + dot: ['contract'], + news: ['contract'], + paper: ['contract'], + doc: ['contract'], + negroni: ['instoStakingProduct'], + orange: ['instoStakingProduct'], + 'institutional investor': ['instoStakingProduct'], }; export default descriptionMap; diff --git a/packages/illustrations/src/__generated__/spotIcon/data/names.ts b/packages/illustrations/src/__generated__/spotIcon/data/names.ts index e0c3cf40fc..4aa3c7d0e9 100644 --- a/packages/illustrations/src/__generated__/spotIcon/data/names.ts +++ b/packages/illustrations/src/__generated__/spotIcon/data/names.ts @@ -70,7 +70,41 @@ const names: SpotIconName[] = [ 'fast', 'helpCenterProduct', 'idVerification', + 'instantAccess', 'institutionalProduct', + 'instoAdvancedTradeProduct', + 'instoAssetHubProduct', + 'instoAuthenticator', + 'instoBorrowProduct', + 'instoBusinessProduct', + 'instoChat', + 'instoCloudProduct', + 'instoCoinbaseOneEarn', + 'instoCommerceProduct', + 'instoCustodyProduct', + 'instoDataMarketplace', + 'instoDelegate', + 'instoDerivativesProduct', + 'instoFast', + 'instoHelpCenterProduct', + 'instoIdVerification', + 'instoLayeredNetworks', + 'instoLearningRewardsProduct', + 'instoMultiCoin', + 'instoPaySDKProduct', + 'instoPieChart', + 'instoPrimeProduct', + 'instoPrivateClientProduct', + 'instoProductCoinbaseCard', + 'instoProductCompliance', + 'instoProductPro', + 'instoProductWallet', + 'instoRecurringPurchases', + 'instoRewardsProduct', + 'instoShield', + 'instoSignInProduct', + 'instoStakingProduct', + 'instoWalletAsAServiceProduct', 'internationalExchangeProduct', 'layeredNetworks', 'learningRewardsProduct', diff --git a/packages/illustrations/src/__generated__/spotIcon/data/svgJsMap.ts b/packages/illustrations/src/__generated__/spotIcon/data/svgJsMap.ts index 5a821b9819..aa3f9a32df 100644 --- a/packages/illustrations/src/__generated__/spotIcon/data/svgJsMap.ts +++ b/packages/illustrations/src/__generated__/spotIcon/data/svgJsMap.ts @@ -246,10 +246,146 @@ const svgJsMap = { light: () => require('../svgJs/light/idVerification-3.js').content, dark: () => require('../svgJs/dark/idVerification-3.js').content, }, + instantAccess: { + light: () => require('../svgJs/light/instantAccess-1.js').content, + dark: () => require('../svgJs/dark/instantAccess-1.js').content, + }, institutionalProduct: { light: () => require('../svgJs/light/institutionalProduct-2.js').content, dark: () => require('../svgJs/dark/institutionalProduct-2.js').content, }, + instoAdvancedTradeProduct: { + light: () => require('../svgJs/light/instoAdvancedTradeProduct-0.js').content, + dark: () => require('../svgJs/dark/instoAdvancedTradeProduct-0.js').content, + }, + instoAssetHubProduct: { + light: () => require('../svgJs/light/instoAssetHubProduct-0.js').content, + dark: () => require('../svgJs/dark/instoAssetHubProduct-0.js').content, + }, + instoAuthenticator: { + light: () => require('../svgJs/light/instoAuthenticator-0.js').content, + dark: () => require('../svgJs/dark/instoAuthenticator-0.js').content, + }, + instoBorrowProduct: { + light: () => require('../svgJs/light/instoBorrowProduct-0.js').content, + dark: () => require('../svgJs/dark/instoBorrowProduct-0.js').content, + }, + instoBusinessProduct: { + light: () => require('../svgJs/light/instoBusinessProduct-0.js').content, + dark: () => require('../svgJs/dark/instoBusinessProduct-0.js').content, + }, + instoChat: { + light: () => require('../svgJs/light/instoChat-0.js').content, + dark: () => require('../svgJs/dark/instoChat-0.js').content, + }, + instoCloudProduct: { + light: () => require('../svgJs/light/instoCloudProduct-0.js').content, + dark: () => require('../svgJs/dark/instoCloudProduct-0.js').content, + }, + instoCoinbaseOneEarn: { + light: () => require('../svgJs/light/instoCoinbaseOneEarn-0.js').content, + dark: () => require('../svgJs/dark/instoCoinbaseOneEarn-0.js').content, + }, + instoCommerceProduct: { + light: () => require('../svgJs/light/instoCommerceProduct-0.js').content, + dark: () => require('../svgJs/dark/instoCommerceProduct-0.js').content, + }, + instoCustodyProduct: { + light: () => require('../svgJs/light/instoCustodyProduct-0.js').content, + dark: () => require('../svgJs/dark/instoCustodyProduct-0.js').content, + }, + instoDataMarketplace: { + light: () => require('../svgJs/light/instoDataMarketplace-0.js').content, + dark: () => require('../svgJs/dark/instoDataMarketplace-0.js').content, + }, + instoDelegate: { + light: () => require('../svgJs/light/instoDelegate-1.js').content, + dark: () => require('../svgJs/dark/instoDelegate-1.js').content, + }, + instoDerivativesProduct: { + light: () => require('../svgJs/light/instoDerivativesProduct-0.js').content, + dark: () => require('../svgJs/dark/instoDerivativesProduct-0.js').content, + }, + instoFast: { + light: () => require('../svgJs/light/instoFast-1.js').content, + dark: () => require('../svgJs/dark/instoFast-1.js').content, + }, + instoHelpCenterProduct: { + light: () => require('../svgJs/light/instoHelpCenterProduct-0.js').content, + dark: () => require('../svgJs/dark/instoHelpCenterProduct-0.js').content, + }, + instoIdVerification: { + light: () => require('../svgJs/light/instoIdVerification-0.js').content, + dark: () => require('../svgJs/dark/instoIdVerification-0.js').content, + }, + instoLayeredNetworks: { + light: () => require('../svgJs/light/instoLayeredNetworks-0.js').content, + dark: () => require('../svgJs/dark/instoLayeredNetworks-0.js').content, + }, + instoLearningRewardsProduct: { + light: () => require('../svgJs/light/instoLearningRewardsProduct-0.js').content, + dark: () => require('../svgJs/dark/instoLearningRewardsProduct-0.js').content, + }, + instoMultiCoin: { + light: () => require('../svgJs/light/instoMultiCoin-0.js').content, + dark: () => require('../svgJs/dark/instoMultiCoin-0.js').content, + }, + instoPaySDKProduct: { + light: () => require('../svgJs/light/instoPaySDKProduct-0.js').content, + dark: () => require('../svgJs/dark/instoPaySDKProduct-0.js').content, + }, + instoPieChart: { + light: () => require('../svgJs/light/instoPieChart-0.js').content, + dark: () => require('../svgJs/dark/instoPieChart-0.js').content, + }, + instoPrimeProduct: { + light: () => require('../svgJs/light/instoPrimeProduct-0.js').content, + dark: () => require('../svgJs/dark/instoPrimeProduct-0.js').content, + }, + instoPrivateClientProduct: { + light: () => require('../svgJs/light/instoPrivateClientProduct-1.js').content, + dark: () => require('../svgJs/dark/instoPrivateClientProduct-1.js').content, + }, + instoProductCoinbaseCard: { + light: () => require('../svgJs/light/instoProductCoinbaseCard-0.js').content, + dark: () => require('../svgJs/dark/instoProductCoinbaseCard-0.js').content, + }, + instoProductCompliance: { + light: () => require('../svgJs/light/instoProductCompliance-0.js').content, + dark: () => require('../svgJs/dark/instoProductCompliance-0.js').content, + }, + instoProductPro: { + light: () => require('../svgJs/light/instoProductPro-0.js').content, + dark: () => require('../svgJs/dark/instoProductPro-0.js').content, + }, + instoProductWallet: { + light: () => require('../svgJs/light/instoProductWallet-0.js').content, + dark: () => require('../svgJs/dark/instoProductWallet-0.js').content, + }, + instoRecurringPurchases: { + light: () => require('../svgJs/light/instoRecurringPurchases-1.js').content, + dark: () => require('../svgJs/dark/instoRecurringPurchases-1.js').content, + }, + instoRewardsProduct: { + light: () => require('../svgJs/light/instoRewardsProduct-0.js').content, + dark: () => require('../svgJs/dark/instoRewardsProduct-0.js').content, + }, + instoShield: { + light: () => require('../svgJs/light/instoShield-0.js').content, + dark: () => require('../svgJs/dark/instoShield-0.js').content, + }, + instoSignInProduct: { + light: () => require('../svgJs/light/instoSignInProduct-0.js').content, + dark: () => require('../svgJs/dark/instoSignInProduct-0.js').content, + }, + instoStakingProduct: { + light: () => require('../svgJs/light/instoStakingProduct-0.js').content, + dark: () => require('../svgJs/dark/instoStakingProduct-0.js').content, + }, + instoWalletAsAServiceProduct: { + light: () => require('../svgJs/light/instoWalletAsAServiceProduct-0.js').content, + dark: () => require('../svgJs/dark/instoWalletAsAServiceProduct-0.js').content, + }, internationalExchangeProduct: { light: () => require('../svgJs/light/internationalExchangeProduct-1.js').content, dark: () => require('../svgJs/dark/internationalExchangeProduct-1.js').content, diff --git a/packages/illustrations/src/__generated__/spotIcon/data/versionMap.ts b/packages/illustrations/src/__generated__/spotIcon/data/versionMap.ts index 4450e66b11..62c2f2aeed 100644 --- a/packages/illustrations/src/__generated__/spotIcon/data/versionMap.ts +++ b/packages/illustrations/src/__generated__/spotIcon/data/versionMap.ts @@ -107,6 +107,40 @@ const versionMap: Record = { assetEmptyStateEa: 0, coinbaseOneProductInvestWeekly: 0, arrowsUpDown: 0, + instoStakingProduct: 0, + instantAccess: 1, + instoProductWallet: 0, + instoAssetHubProduct: 0, + instoCloudProduct: 0, + instoSignInProduct: 0, + instoLayeredNetworks: 0, + instoIdVerification: 0, + instoProductCoinbaseCard: 0, + instoProductCompliance: 0, + instoProductPro: 0, + instoBusinessProduct: 0, + instoHelpCenterProduct: 0, + instoPrimeProduct: 0, + instoRewardsProduct: 0, + instoDataMarketplace: 0, + instoAdvancedTradeProduct: 0, + instoPaySDKProduct: 0, + instoDelegate: 1, + instoCustodyProduct: 0, + instoWalletAsAServiceProduct: 0, + instoShield: 0, + instoMultiCoin: 0, + instoPrivateClientProduct: 1, + instoAuthenticator: 0, + instoCoinbaseOneEarn: 0, + instoChat: 0, + instoFast: 1, + instoRecurringPurchases: 1, + instoCommerceProduct: 0, + instoBorrowProduct: 0, + instoLearningRewardsProduct: 0, + instoPieChart: 0, + instoDerivativesProduct: 0, }; export default versionMap; diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instantAccess-1.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instantAccess-1.png new file mode 100644 index 0000000000..38fe210f4c Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instantAccess-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAdvancedTradeProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAdvancedTradeProduct-0.png new file mode 100644 index 0000000000..4cce81ddb5 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAdvancedTradeProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAssetHubProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAssetHubProduct-0.png new file mode 100644 index 0000000000..2afbc88b07 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAssetHubProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAuthenticator-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAuthenticator-0.png new file mode 100644 index 0000000000..4e4ab0d9a0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoAuthenticator-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBorrowProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBorrowProduct-0.png new file mode 100644 index 0000000000..f48a03f3d8 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBorrowProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBusinessProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBusinessProduct-0.png new file mode 100644 index 0000000000..326489eddc Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoBusinessProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoChat-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoChat-0.png new file mode 100644 index 0000000000..f943e4d72c Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoChat-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCloudProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCloudProduct-0.png new file mode 100644 index 0000000000..22dadcc46e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCloudProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCoinbaseOneEarn-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCoinbaseOneEarn-0.png new file mode 100644 index 0000000000..c00b866d2f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCoinbaseOneEarn-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCommerceProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCommerceProduct-0.png new file mode 100644 index 0000000000..c40837e917 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCommerceProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCustodyProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCustodyProduct-0.png new file mode 100644 index 0000000000..1314e06dbd Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoCustodyProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDataMarketplace-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDataMarketplace-0.png new file mode 100644 index 0000000000..057ecf72d3 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDataMarketplace-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDelegate-1.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDelegate-1.png new file mode 100644 index 0000000000..6222e16a24 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDelegate-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDerivativesProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDerivativesProduct-0.png new file mode 100644 index 0000000000..fc9748de56 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoDerivativesProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoFast-1.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoFast-1.png new file mode 100644 index 0000000000..477b5971b9 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoFast-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoHelpCenterProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoHelpCenterProduct-0.png new file mode 100644 index 0000000000..a1fca11a13 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoHelpCenterProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoIdVerification-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoIdVerification-0.png new file mode 100644 index 0000000000..f9e52cbbef Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoIdVerification-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLayeredNetworks-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLayeredNetworks-0.png new file mode 100644 index 0000000000..b67f131b6c Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLayeredNetworks-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLearningRewardsProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLearningRewardsProduct-0.png new file mode 100644 index 0000000000..dbee4afbec Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoLearningRewardsProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoMultiCoin-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoMultiCoin-0.png new file mode 100644 index 0000000000..a87a95f7ef Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoMultiCoin-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPaySDKProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPaySDKProduct-0.png new file mode 100644 index 0000000000..5755a61050 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPaySDKProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPieChart-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPieChart-0.png new file mode 100644 index 0000000000..67f7f0cb01 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPieChart-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrimeProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrimeProduct-0.png new file mode 100644 index 0000000000..502317da3b Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrimeProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrivateClientProduct-1.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrivateClientProduct-1.png new file mode 100644 index 0000000000..5416b2a347 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoPrivateClientProduct-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCoinbaseCard-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCoinbaseCard-0.png new file mode 100644 index 0000000000..52f3ddfd32 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCoinbaseCard-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCompliance-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCompliance-0.png new file mode 100644 index 0000000000..5f3a7c0741 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductCompliance-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductPro-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductPro-0.png new file mode 100644 index 0000000000..1ac4aa0427 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductPro-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductWallet-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductWallet-0.png new file mode 100644 index 0000000000..4f21ab50be Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoProductWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRecurringPurchases-1.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRecurringPurchases-1.png new file mode 100644 index 0000000000..7fbd0abd48 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRecurringPurchases-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRewardsProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRewardsProduct-0.png new file mode 100644 index 0000000000..64326d8e35 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoRewardsProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoShield-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoShield-0.png new file mode 100644 index 0000000000..6b56d6406e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoShield-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoSignInProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoSignInProduct-0.png new file mode 100644 index 0000000000..4403d6d592 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoSignInProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoStakingProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoStakingProduct-0.png new file mode 100644 index 0000000000..d9dd253651 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoStakingProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/dark/instoWalletAsAServiceProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoWalletAsAServiceProduct-0.png new file mode 100644 index 0000000000..ba49d34c33 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/dark/instoWalletAsAServiceProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instantAccess-1.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instantAccess-1.png new file mode 100644 index 0000000000..2397fc5025 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instantAccess-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoAdvancedTradeProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAdvancedTradeProduct-0.png new file mode 100644 index 0000000000..816de10eb9 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAdvancedTradeProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoAssetHubProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAssetHubProduct-0.png new file mode 100644 index 0000000000..14498f6c0a Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAssetHubProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoAuthenticator-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAuthenticator-0.png new file mode 100644 index 0000000000..faa9797622 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoAuthenticator-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoBorrowProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoBorrowProduct-0.png new file mode 100644 index 0000000000..d3ab235626 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoBorrowProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoBusinessProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoBusinessProduct-0.png new file mode 100644 index 0000000000..e9ff8c7ea3 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoBusinessProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoChat-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoChat-0.png new file mode 100644 index 0000000000..9e1aa2914f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoChat-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoCloudProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCloudProduct-0.png new file mode 100644 index 0000000000..4785a668af Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCloudProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoCoinbaseOneEarn-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCoinbaseOneEarn-0.png new file mode 100644 index 0000000000..dc79a8fbc2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCoinbaseOneEarn-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoCommerceProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCommerceProduct-0.png new file mode 100644 index 0000000000..e99ecb86c1 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCommerceProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoCustodyProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCustodyProduct-0.png new file mode 100644 index 0000000000..29716723f2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoCustodyProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoDataMarketplace-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDataMarketplace-0.png new file mode 100644 index 0000000000..a616cf460e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDataMarketplace-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoDelegate-1.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDelegate-1.png new file mode 100644 index 0000000000..65e53716ce Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDelegate-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoDerivativesProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDerivativesProduct-0.png new file mode 100644 index 0000000000..1866bb5db3 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoDerivativesProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoFast-1.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoFast-1.png new file mode 100644 index 0000000000..86f3e0a27e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoFast-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoHelpCenterProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoHelpCenterProduct-0.png new file mode 100644 index 0000000000..c3cfceab24 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoHelpCenterProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoIdVerification-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoIdVerification-0.png new file mode 100644 index 0000000000..2dcb6aa9c9 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoIdVerification-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoLayeredNetworks-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoLayeredNetworks-0.png new file mode 100644 index 0000000000..b38d06581e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoLayeredNetworks-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoLearningRewardsProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoLearningRewardsProduct-0.png new file mode 100644 index 0000000000..32a73299fb Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoLearningRewardsProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoMultiCoin-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoMultiCoin-0.png new file mode 100644 index 0000000000..927aeca73c Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoMultiCoin-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoPaySDKProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPaySDKProduct-0.png new file mode 100644 index 0000000000..4ccc440ae0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPaySDKProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoPieChart-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPieChart-0.png new file mode 100644 index 0000000000..f7522ffd06 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPieChart-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrimeProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrimeProduct-0.png new file mode 100644 index 0000000000..7995c2e8d5 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrimeProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrivateClientProduct-1.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrivateClientProduct-1.png new file mode 100644 index 0000000000..179ffb1229 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoPrivateClientProduct-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCoinbaseCard-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCoinbaseCard-0.png new file mode 100644 index 0000000000..52b96e760f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCoinbaseCard-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCompliance-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCompliance-0.png new file mode 100644 index 0000000000..94a068c4c0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductCompliance-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductPro-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductPro-0.png new file mode 100644 index 0000000000..98027278c2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductPro-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductWallet-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductWallet-0.png new file mode 100644 index 0000000000..59639d546b Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoProductWallet-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoRecurringPurchases-1.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoRecurringPurchases-1.png new file mode 100644 index 0000000000..b659515921 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoRecurringPurchases-1.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoRewardsProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoRewardsProduct-0.png new file mode 100644 index 0000000000..8aad4c6774 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoRewardsProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoShield-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoShield-0.png new file mode 100644 index 0000000000..55389a06dd Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoShield-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoSignInProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoSignInProduct-0.png new file mode 100644 index 0000000000..3bca372f13 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoSignInProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoStakingProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoStakingProduct-0.png new file mode 100644 index 0000000000..df0c6cd912 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoStakingProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/png/light/instoWalletAsAServiceProduct-0.png b/packages/illustrations/src/__generated__/spotIcon/png/light/instoWalletAsAServiceProduct-0.png new file mode 100644 index 0000000000..13b7d2a278 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotIcon/png/light/instoWalletAsAServiceProduct-0.png differ diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instantAccess-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instantAccess-1.svg new file mode 100644 index 0000000000..7cc89abdd0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instantAccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAdvancedTradeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAdvancedTradeProduct-0.svg new file mode 100644 index 0000000000..9d5b045197 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAdvancedTradeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAssetHubProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAssetHubProduct-0.svg new file mode 100644 index 0000000000..a958b3ccdf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAssetHubProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAuthenticator-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAuthenticator-0.svg new file mode 100644 index 0000000000..c5e613eb03 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoAuthenticator-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBorrowProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBorrowProduct-0.svg new file mode 100644 index 0000000000..dac65e2a9f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBorrowProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBusinessProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBusinessProduct-0.svg new file mode 100644 index 0000000000..d243ae008f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoBusinessProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoChat-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoChat-0.svg new file mode 100644 index 0000000000..744129e2d2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoChat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCloudProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCloudProduct-0.svg new file mode 100644 index 0000000000..973df71929 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCloudProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCoinbaseOneEarn-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCoinbaseOneEarn-0.svg new file mode 100644 index 0000000000..59d39b1ea0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCoinbaseOneEarn-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCommerceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCommerceProduct-0.svg new file mode 100644 index 0000000000..f3173400b9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCommerceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCustodyProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCustodyProduct-0.svg new file mode 100644 index 0000000000..e9d776b887 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoCustodyProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDataMarketplace-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDataMarketplace-0.svg new file mode 100644 index 0000000000..84c88fccd2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDataMarketplace-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDelegate-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDelegate-1.svg new file mode 100644 index 0000000000..5f81ef6909 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDelegate-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDerivativesProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDerivativesProduct-0.svg new file mode 100644 index 0000000000..fbe6f0eae3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoDerivativesProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoFast-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoFast-1.svg new file mode 100644 index 0000000000..3fa82c7052 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoFast-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoHelpCenterProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoHelpCenterProduct-0.svg new file mode 100644 index 0000000000..8278fd6998 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoHelpCenterProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoIdVerification-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoIdVerification-0.svg new file mode 100644 index 0000000000..d14a2b7159 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoIdVerification-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLayeredNetworks-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLayeredNetworks-0.svg new file mode 100644 index 0000000000..69a97719d7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLayeredNetworks-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLearningRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLearningRewardsProduct-0.svg new file mode 100644 index 0000000000..c8209a9dcf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoLearningRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoMultiCoin-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoMultiCoin-0.svg new file mode 100644 index 0000000000..2707e1ca11 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoMultiCoin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPaySDKProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPaySDKProduct-0.svg new file mode 100644 index 0000000000..00446e5454 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPaySDKProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPieChart-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPieChart-0.svg new file mode 100644 index 0000000000..9ba88f4196 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPieChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrimeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrimeProduct-0.svg new file mode 100644 index 0000000000..84c8747a65 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrimeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrivateClientProduct-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrivateClientProduct-1.svg new file mode 100644 index 0000000000..340dff1baa --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoPrivateClientProduct-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCoinbaseCard-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCoinbaseCard-0.svg new file mode 100644 index 0000000000..192f81a4bf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCoinbaseCard-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCompliance-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCompliance-0.svg new file mode 100644 index 0000000000..adfea87ec0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductCompliance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductPro-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductPro-0.svg new file mode 100644 index 0000000000..d9f7a4e944 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductPro-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductWallet-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductWallet-0.svg new file mode 100644 index 0000000000..1591d120bd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoProductWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRecurringPurchases-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRecurringPurchases-1.svg new file mode 100644 index 0000000000..d92b31e42e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRecurringPurchases-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRewardsProduct-0.svg new file mode 100644 index 0000000000..040f559032 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoShield-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoShield-0.svg new file mode 100644 index 0000000000..fee4a8bc18 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoSignInProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoSignInProduct-0.svg new file mode 100644 index 0000000000..a9c488e642 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoSignInProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoStakingProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoStakingProduct-0.svg new file mode 100644 index 0000000000..eef59a210b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoStakingProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoWalletAsAServiceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoWalletAsAServiceProduct-0.svg new file mode 100644 index 0000000000..471b594d1e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/dark/instoWalletAsAServiceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instantAccess-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instantAccess-1.svg new file mode 100644 index 0000000000..c1529cc600 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instantAccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAdvancedTradeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAdvancedTradeProduct-0.svg new file mode 100644 index 0000000000..0b050711bc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAdvancedTradeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAssetHubProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAssetHubProduct-0.svg new file mode 100644 index 0000000000..c086c2756f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAssetHubProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAuthenticator-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAuthenticator-0.svg new file mode 100644 index 0000000000..acf6910b69 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoAuthenticator-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBorrowProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBorrowProduct-0.svg new file mode 100644 index 0000000000..990dd8d93d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBorrowProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBusinessProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBusinessProduct-0.svg new file mode 100644 index 0000000000..85558f5e7a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoBusinessProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoChat-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoChat-0.svg new file mode 100644 index 0000000000..d0038d33bc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoChat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCloudProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCloudProduct-0.svg new file mode 100644 index 0000000000..2cbc8cbd13 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCloudProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCoinbaseOneEarn-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCoinbaseOneEarn-0.svg new file mode 100644 index 0000000000..ce5c6bb781 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCoinbaseOneEarn-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCommerceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCommerceProduct-0.svg new file mode 100644 index 0000000000..e7bb3f0751 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCommerceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCustodyProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCustodyProduct-0.svg new file mode 100644 index 0000000000..2c28b83390 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoCustodyProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDataMarketplace-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDataMarketplace-0.svg new file mode 100644 index 0000000000..49d94512b7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDataMarketplace-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDelegate-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDelegate-1.svg new file mode 100644 index 0000000000..51d2eb0fbe --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDelegate-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDerivativesProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDerivativesProduct-0.svg new file mode 100644 index 0000000000..f83a77a3dc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoDerivativesProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoFast-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoFast-1.svg new file mode 100644 index 0000000000..c1870d2d93 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoFast-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoHelpCenterProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoHelpCenterProduct-0.svg new file mode 100644 index 0000000000..59477eb561 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoHelpCenterProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoIdVerification-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoIdVerification-0.svg new file mode 100644 index 0000000000..9344bf1f12 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoIdVerification-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLayeredNetworks-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLayeredNetworks-0.svg new file mode 100644 index 0000000000..77737258ba --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLayeredNetworks-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLearningRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLearningRewardsProduct-0.svg new file mode 100644 index 0000000000..45372fe847 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoLearningRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoMultiCoin-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoMultiCoin-0.svg new file mode 100644 index 0000000000..4c624be5f7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoMultiCoin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPaySDKProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPaySDKProduct-0.svg new file mode 100644 index 0000000000..ac254cff50 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPaySDKProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPieChart-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPieChart-0.svg new file mode 100644 index 0000000000..b5feabd7b7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPieChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrimeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrimeProduct-0.svg new file mode 100644 index 0000000000..46c7aaf128 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrimeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrivateClientProduct-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrivateClientProduct-1.svg new file mode 100644 index 0000000000..cbb0b8d614 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoPrivateClientProduct-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCoinbaseCard-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCoinbaseCard-0.svg new file mode 100644 index 0000000000..a806a4b92a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCoinbaseCard-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCompliance-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCompliance-0.svg new file mode 100644 index 0000000000..07a45971ad --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductCompliance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductPro-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductPro-0.svg new file mode 100644 index 0000000000..66ad885b4b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductPro-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductWallet-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductWallet-0.svg new file mode 100644 index 0000000000..5c15682484 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoProductWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRecurringPurchases-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRecurringPurchases-1.svg new file mode 100644 index 0000000000..55ece3866c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRecurringPurchases-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRewardsProduct-0.svg new file mode 100644 index 0000000000..36e7d2d8d3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoShield-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoShield-0.svg new file mode 100644 index 0000000000..8e7d465989 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoSignInProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoSignInProduct-0.svg new file mode 100644 index 0000000000..99eac44172 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoSignInProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoStakingProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoStakingProduct-0.svg new file mode 100644 index 0000000000..8f7b126063 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoStakingProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/light/instoWalletAsAServiceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoWalletAsAServiceProduct-0.svg new file mode 100644 index 0000000000..f1e6640daa --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/light/instoWalletAsAServiceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instantAccess-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instantAccess-1.svg new file mode 100644 index 0000000000..b35cdc6127 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instantAccess-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAdvancedTradeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAdvancedTradeProduct-0.svg new file mode 100644 index 0000000000..aa51b2da20 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAdvancedTradeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAssetHubProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAssetHubProduct-0.svg new file mode 100644 index 0000000000..9bf262a9f8 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAssetHubProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAuthenticator-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAuthenticator-0.svg new file mode 100644 index 0000000000..f096f8e581 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoAuthenticator-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBorrowProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBorrowProduct-0.svg new file mode 100644 index 0000000000..d66c6e82a1 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBorrowProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBusinessProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBusinessProduct-0.svg new file mode 100644 index 0000000000..80b4ebe41b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoBusinessProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoChat-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoChat-0.svg new file mode 100644 index 0000000000..b2f83ed91a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoChat-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCloudProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCloudProduct-0.svg new file mode 100644 index 0000000000..2b66da1120 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCloudProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCoinbaseOneEarn-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCoinbaseOneEarn-0.svg new file mode 100644 index 0000000000..18f7b3c1d9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCoinbaseOneEarn-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCommerceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCommerceProduct-0.svg new file mode 100644 index 0000000000..3a057f18b8 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCommerceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCustodyProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCustodyProduct-0.svg new file mode 100644 index 0000000000..b389a7f9cd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoCustodyProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDataMarketplace-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDataMarketplace-0.svg new file mode 100644 index 0000000000..5498993b36 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDataMarketplace-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDelegate-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDelegate-1.svg new file mode 100644 index 0000000000..777d1339b7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDelegate-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDerivativesProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDerivativesProduct-0.svg new file mode 100644 index 0000000000..47ce287ad2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoDerivativesProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoFast-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoFast-1.svg new file mode 100644 index 0000000000..3e4daf561d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoFast-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoHelpCenterProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoHelpCenterProduct-0.svg new file mode 100644 index 0000000000..97a506e98f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoHelpCenterProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoIdVerification-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoIdVerification-0.svg new file mode 100644 index 0000000000..802f909bd9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoIdVerification-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLayeredNetworks-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLayeredNetworks-0.svg new file mode 100644 index 0000000000..7d8fba73eb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLayeredNetworks-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLearningRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLearningRewardsProduct-0.svg new file mode 100644 index 0000000000..a67e791c25 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoLearningRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoMultiCoin-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoMultiCoin-0.svg new file mode 100644 index 0000000000..1ef64061fc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoMultiCoin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPaySDKProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPaySDKProduct-0.svg new file mode 100644 index 0000000000..a237739dcf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPaySDKProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPieChart-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPieChart-0.svg new file mode 100644 index 0000000000..2433f2daad --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPieChart-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrimeProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrimeProduct-0.svg new file mode 100644 index 0000000000..6e1cd2ffb0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrimeProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrivateClientProduct-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrivateClientProduct-1.svg new file mode 100644 index 0000000000..757bb071c2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoPrivateClientProduct-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCoinbaseCard-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCoinbaseCard-0.svg new file mode 100644 index 0000000000..97f5b1d402 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCoinbaseCard-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCompliance-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCompliance-0.svg new file mode 100644 index 0000000000..9523ff3eaa --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductCompliance-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductPro-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductPro-0.svg new file mode 100644 index 0000000000..3b972acdb7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductPro-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductWallet-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductWallet-0.svg new file mode 100644 index 0000000000..dd070afa4b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoProductWallet-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRecurringPurchases-1.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRecurringPurchases-1.svg new file mode 100644 index 0000000000..11b0c037e6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRecurringPurchases-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRewardsProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRewardsProduct-0.svg new file mode 100644 index 0000000000..312e7b8be6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoRewardsProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoShield-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoShield-0.svg new file mode 100644 index 0000000000..415a75258b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoShield-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoSignInProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoSignInProduct-0.svg new file mode 100644 index 0000000000..1b16077473 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoSignInProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoStakingProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoStakingProduct-0.svg new file mode 100644 index 0000000000..e005a53571 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoStakingProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoWalletAsAServiceProduct-0.svg b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoWalletAsAServiceProduct-0.svg new file mode 100644 index 0000000000..583786f474 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svg/themeable/instoWalletAsAServiceProduct-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instantAccess-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instantAccess-1.js new file mode 100644 index 0000000000..042ab26b8a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instantAccess-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAdvancedTradeProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAdvancedTradeProduct-0.js new file mode 100644 index 0000000000..baa049cecc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAdvancedTradeProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAssetHubProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAssetHubProduct-0.js new file mode 100644 index 0000000000..ab86fab73d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAssetHubProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAuthenticator-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAuthenticator-0.js new file mode 100644 index 0000000000..1f4b004765 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoAuthenticator-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBorrowProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBorrowProduct-0.js new file mode 100644 index 0000000000..15db72b790 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBorrowProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBusinessProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBusinessProduct-0.js new file mode 100644 index 0000000000..9cc798920c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoBusinessProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoChat-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoChat-0.js new file mode 100644 index 0000000000..dd63c43515 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoChat-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCloudProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCloudProduct-0.js new file mode 100644 index 0000000000..6ba2c16224 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCloudProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCoinbaseOneEarn-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCoinbaseOneEarn-0.js new file mode 100644 index 0000000000..324023e6fd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCoinbaseOneEarn-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCommerceProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCommerceProduct-0.js new file mode 100644 index 0000000000..34b96aaa56 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCommerceProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCustodyProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCustodyProduct-0.js new file mode 100644 index 0000000000..ac90733c63 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoCustodyProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDataMarketplace-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDataMarketplace-0.js new file mode 100644 index 0000000000..604592e410 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDataMarketplace-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDelegate-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDelegate-1.js new file mode 100644 index 0000000000..9aabb2cd83 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDelegate-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDerivativesProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDerivativesProduct-0.js new file mode 100644 index 0000000000..6469b8c0f2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoDerivativesProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoFast-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoFast-1.js new file mode 100644 index 0000000000..9588581e18 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoFast-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoHelpCenterProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoHelpCenterProduct-0.js new file mode 100644 index 0000000000..e36ba601f9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoHelpCenterProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoIdVerification-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoIdVerification-0.js new file mode 100644 index 0000000000..beb2c3c0bf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoIdVerification-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLayeredNetworks-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLayeredNetworks-0.js new file mode 100644 index 0000000000..e54c8aadc9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLayeredNetworks-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLearningRewardsProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLearningRewardsProduct-0.js new file mode 100644 index 0000000000..b468a3f192 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoLearningRewardsProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoMultiCoin-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoMultiCoin-0.js new file mode 100644 index 0000000000..75811ee35b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoMultiCoin-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPaySDKProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPaySDKProduct-0.js new file mode 100644 index 0000000000..95b5349029 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPaySDKProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPieChart-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPieChart-0.js new file mode 100644 index 0000000000..34c0aec0eb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPieChart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrimeProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrimeProduct-0.js new file mode 100644 index 0000000000..c54e6e96f6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrimeProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrivateClientProduct-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrivateClientProduct-1.js new file mode 100644 index 0000000000..29ea2b8b00 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoPrivateClientProduct-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCoinbaseCard-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCoinbaseCard-0.js new file mode 100644 index 0000000000..a363cbdba4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCoinbaseCard-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCompliance-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCompliance-0.js new file mode 100644 index 0000000000..190a795b65 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductCompliance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductPro-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductPro-0.js new file mode 100644 index 0000000000..665ee4630e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductPro-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductWallet-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductWallet-0.js new file mode 100644 index 0000000000..0266aee5e2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoProductWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRecurringPurchases-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRecurringPurchases-1.js new file mode 100644 index 0000000000..37c906427e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRecurringPurchases-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRewardsProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRewardsProduct-0.js new file mode 100644 index 0000000000..b4f8aad4a1 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoRewardsProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoShield-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoShield-0.js new file mode 100644 index 0000000000..e5942f79d5 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoShield-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoSignInProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoSignInProduct-0.js new file mode 100644 index 0000000000..25e3b035dd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoSignInProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoStakingProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoStakingProduct-0.js new file mode 100644 index 0000000000..2a81d07a5f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoStakingProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoWalletAsAServiceProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoWalletAsAServiceProduct-0.js new file mode 100644 index 0000000000..747068bc93 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/dark/instoWalletAsAServiceProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instantAccess-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instantAccess-1.js new file mode 100644 index 0000000000..d60157de3b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instantAccess-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAdvancedTradeProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAdvancedTradeProduct-0.js new file mode 100644 index 0000000000..435e313d4b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAdvancedTradeProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAssetHubProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAssetHubProduct-0.js new file mode 100644 index 0000000000..ed04f46bec --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAssetHubProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAuthenticator-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAuthenticator-0.js new file mode 100644 index 0000000000..44c3e251fe --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoAuthenticator-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBorrowProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBorrowProduct-0.js new file mode 100644 index 0000000000..0388d812c4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBorrowProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBusinessProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBusinessProduct-0.js new file mode 100644 index 0000000000..d4214c7489 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoBusinessProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoChat-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoChat-0.js new file mode 100644 index 0000000000..359cc99b94 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoChat-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCloudProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCloudProduct-0.js new file mode 100644 index 0000000000..964240503f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCloudProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCoinbaseOneEarn-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCoinbaseOneEarn-0.js new file mode 100644 index 0000000000..3a370d7d06 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCoinbaseOneEarn-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCommerceProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCommerceProduct-0.js new file mode 100644 index 0000000000..3d4979bedf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCommerceProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCustodyProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCustodyProduct-0.js new file mode 100644 index 0000000000..7cc46a7cf3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoCustodyProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDataMarketplace-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDataMarketplace-0.js new file mode 100644 index 0000000000..e41143eb05 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDataMarketplace-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDelegate-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDelegate-1.js new file mode 100644 index 0000000000..5e3c43e0e0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDelegate-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDerivativesProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDerivativesProduct-0.js new file mode 100644 index 0000000000..cfeb19352e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoDerivativesProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoFast-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoFast-1.js new file mode 100644 index 0000000000..6bec415bfe --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoFast-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoHelpCenterProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoHelpCenterProduct-0.js new file mode 100644 index 0000000000..01312bdd6e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoHelpCenterProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoIdVerification-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoIdVerification-0.js new file mode 100644 index 0000000000..102af4236b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoIdVerification-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLayeredNetworks-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLayeredNetworks-0.js new file mode 100644 index 0000000000..e0225c72e5 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLayeredNetworks-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLearningRewardsProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLearningRewardsProduct-0.js new file mode 100644 index 0000000000..2945387fc9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoLearningRewardsProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoMultiCoin-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoMultiCoin-0.js new file mode 100644 index 0000000000..0aef86152c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoMultiCoin-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPaySDKProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPaySDKProduct-0.js new file mode 100644 index 0000000000..e49a51b289 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPaySDKProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPieChart-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPieChart-0.js new file mode 100644 index 0000000000..d17babf565 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPieChart-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrimeProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrimeProduct-0.js new file mode 100644 index 0000000000..cf8e07f22a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrimeProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrivateClientProduct-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrivateClientProduct-1.js new file mode 100644 index 0000000000..5c3ef5f3b3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoPrivateClientProduct-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCoinbaseCard-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCoinbaseCard-0.js new file mode 100644 index 0000000000..4070c5a0ad --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCoinbaseCard-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCompliance-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCompliance-0.js new file mode 100644 index 0000000000..79f29a40de --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductCompliance-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductPro-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductPro-0.js new file mode 100644 index 0000000000..76af312441 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductPro-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductWallet-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductWallet-0.js new file mode 100644 index 0000000000..35dac1d64f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoProductWallet-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRecurringPurchases-1.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRecurringPurchases-1.js new file mode 100644 index 0000000000..e955bfe522 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRecurringPurchases-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRewardsProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRewardsProduct-0.js new file mode 100644 index 0000000000..f72aa59874 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoRewardsProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoShield-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoShield-0.js new file mode 100644 index 0000000000..4740a4e176 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoShield-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoSignInProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoSignInProduct-0.js new file mode 100644 index 0000000000..6fb2a0b76f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoSignInProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoStakingProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoStakingProduct-0.js new file mode 100644 index 0000000000..be0cb4730a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoStakingProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoWalletAsAServiceProduct-0.js b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoWalletAsAServiceProduct-0.js new file mode 100644 index 0000000000..e0d55749bf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotIcon/svgJs/light/instoWalletAsAServiceProduct-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotIcon/types/SpotIconName.ts b/packages/illustrations/src/__generated__/spotIcon/types/SpotIconName.ts index f4f4bf85a0..612241525e 100644 --- a/packages/illustrations/src/__generated__/spotIcon/types/SpotIconName.ts +++ b/packages/illustrations/src/__generated__/spotIcon/types/SpotIconName.ts @@ -64,7 +64,41 @@ export type SpotIconName = | 'fast' | 'helpCenterProduct' | 'idVerification' + | 'instantAccess' | 'institutionalProduct' + | 'instoAdvancedTradeProduct' + | 'instoAssetHubProduct' + | 'instoAuthenticator' + | 'instoBorrowProduct' + | 'instoBusinessProduct' + | 'instoChat' + | 'instoCloudProduct' + | 'instoCoinbaseOneEarn' + | 'instoCommerceProduct' + | 'instoCustodyProduct' + | 'instoDataMarketplace' + | 'instoDelegate' + | 'instoDerivativesProduct' + | 'instoFast' + | 'instoHelpCenterProduct' + | 'instoIdVerification' + | 'instoLayeredNetworks' + | 'instoLearningRewardsProduct' + | 'instoMultiCoin' + | 'instoPaySDKProduct' + | 'instoPieChart' + | 'instoPrimeProduct' + | 'instoPrivateClientProduct' + | 'instoProductCoinbaseCard' + | 'instoProductCompliance' + | 'instoProductPro' + | 'instoProductWallet' + | 'instoRecurringPurchases' + | 'instoRewardsProduct' + | 'instoShield' + | 'instoSignInProduct' + | 'instoStakingProduct' + | 'instoWalletAsAServiceProduct' | 'internationalExchangeProduct' | 'layeredNetworks' | 'learningRewardsProduct' diff --git a/packages/illustrations/src/__generated__/spotRectangle/data/descriptionMap.ts b/packages/illustrations/src/__generated__/spotRectangle/data/descriptionMap.ts index 937ad7bdf2..0ce524db7d 100644 --- a/packages/illustrations/src/__generated__/spotRectangle/data/descriptionMap.ts +++ b/packages/illustrations/src/__generated__/spotRectangle/data/descriptionMap.ts @@ -11,1620 +11,1295 @@ const descriptionMap: Record = { '1': ['layeredNetworks'], '2': ['layeredNetworks'], - decentralized: [ - 'decentralizedWebWeb3', - 'moneyDecentralized', - 'defiDecentralizedTradingExchange', - 'didDecentralizedIdentity', - 'defiDecentralizedBorrowingLending', - 'blockchain', - 'cryptoWallet', - 'decentralization', - 'backedByUsDollar', - ], - web: ['decentralizedWebWeb3', 'browserExtension'], - web3: ['decentralizedWebWeb3'], - network: [ - 'decentralizedWebWeb3', - 'referralsGenericCoin', - 'moneyDecentralized', - 'poweredByEthereum', - 'referralsCoinbaseOne', - 'encryptedEverything', - 'blockchain', - 'referralsBitcoin', - 'cryptoAssets', - 'decentralization', - 'lightningNetworkSend', + leverage: [ + 'leverage', + 'browserExtension', + 'liquidationBufferGreen', + 'liquidationBufferRed', + 'liquidationBufferYellow', ], - self: ['decentralizedWebWeb3', 'selfCustody', 'stayInControlSelfHostedWalletsStorage'], - custody: ['decentralizedWebWeb3', 'selfCustody'], - ownership: ['decentralizedWebWeb3'], - data: [ - 'decentralizedWebWeb3', - 'fileYourCryptoTaxesCheck', + trading: [ + 'leverage', 'accessToAdvancedCharts', - 'advancedTradeCharts', - 'trade', 'ratDashboard', - ], - stable: ['stableValue'], - scale: ['stableValue'], - stablecoin: ['stableValue'], - value: [ - 'stableValue', - 'coinbaseOneRewards', - 'moneyDecentralized', - 'mining', - 'bigBtc', - 'p2pPayments', - 'retailUSDCRewards', - ], - store: [ - 'stableValue', - 'selfCustody', - 'bigBtc', - 'defiDecentralizedBorrowingLending', - 'cryptoWallet', - 'holdingCrypto', - 'secureStorage', - 'secureAndTrusted', - 'holdCrypto', - ], - earn: [ - 'stableValue', - 'completeAQuiz', - 'defiEarn', - 'coinbaseOneRewards', - 'freeBtc', - 'earnInterest', - 'staking', - 'earn', - 'defiRisk', - 'startToday', - 'invest', - 'watchVideos', - 'retailUSDCRewards', - 'backedByUsDollar', - ], - '': [ - 'secureAccount', - 'derivativesLoop', - 'unauthorizedTransfers', - 'trustedContacts', - 'graphChartTrading', - 'coinGateway', - 'leadingProtocolMorpho', - 'lendGraph', - 'fileYourCryptoTaxes', - 'basedInUsa', - 'appUpdate', - 'giftBoxRewards', - 'concierge', - 'update', - 'tokenSales', - 'calendar', - 'coinbaseFees', - ], - margin: ['loanValue', 'marginWarning', 'margin'], - trading: [ - 'loanValue', - 'readyToTrade', + 'trade', + 'primeOrderConfirmation', + 'margin', + 'advancedTradeCharts', 'marginWarning', + 'primeTradePreferences', 'primePriceLadder', - 'defiDecentralizedTradingExchange', 'futures', - 'advancedTradingUi', - 'margin', - 'liquidationBufferRed', - 'primeOrderConfirmation', - 'accessToAdvancedCharts', - 'liquidationBufferGreen', - 'leverage', 'emptyTrading', - 'advancedTrading', + 'defiDecentralizedTradingExchange', + 'ethTrading', 'ethTradingTwo', - 'advancedTradeCharts', + 'readyToTrade', + 'advancedTradingUi', + 'advancedTrading', + 'loanValue', + 'liquidationBufferGreen', + 'liquidationBufferRed', 'liquidationBufferYellow', - 'ethTrading', - 'trade', - 'primeTradePreferences', - 'ratDashboard', + 'instoEmptyTrading', + 'instoMargin', ], add: [ - 'loanValue', - 'creditCardExcitementCoinbaseOne', - 'coinbaseCardPocket', - 'marginWarning', - 'margin', - 'sendingCrypto', - 'addPhoneNumber', 'leverage', - 'coinbaseCardLock', + 'creditCardExcitement', + 'sendingCrypto', + 'margin', + 'marginWarning', + 'referralsBonus', 'addBank', 'cbEth', - 'referralsBonus', - 'creditCardExcitement', - ], - stack: ['loanValue', 'marginWarning', 'margin', 'leverage'], - more: ['loanValue', 'marginWarning', 'staking', 'margin', 'cryptoAndMore', 'leverage'], - lever: ['loanValue', 'marginWarning', 'margin', 'leverage'], - up: [ + 'coinbaseCardLock', + 'coinbaseCardPocket', + 'addPhoneNumber', 'loanValue', - 'marginWarning', - 'portfolioPerformance', - 'trendingHotAssets', + 'creditCardExcitementCoinbaseOne', + 'instoMargin', + ], + stack: ['leverage', 'margin', 'marginWarning', 'loanValue', 'instoMargin'], + more: [ + 'leverage', 'margin', - 'advancedTradingChartsIndicatorsCandles', - 'earn', + 'marginWarning', + 'staking', + 'cryptoAndMore', + 'loanValue', + 'instoStaking', + 'instoCryptoAndMore', + 'instoMargin', + ], + lever: ['leverage', 'margin', 'marginWarning', 'loanValue', 'instoMargin'], + up: [ 'leverage', 'trade', + 'margin', + 'marginWarning', 'highFees', + 'advancedTradingChartsIndicatorsCandles', + 'portfolioPerformance', + 'earn', + 'trendingHotAssets', + 'loanValue', + 'instoMargin', ], - buy: ['loanValue', 'marginWarning', 'futures', 'margin', 'leverage'], + buy: ['leverage', 'margin', 'marginWarning', 'futures', 'loanValue', 'instoMargin'], sell: [ - 'loanValue', + 'leverage', + 'margin', 'marginWarning', 'futures', - 'margin', - 'wrapEthTwo', 'eth2SellSend', - 'leverage', 'wrapEth', + 'wrapEthTwo', + 'loanValue', + 'instoMargin', ], - put: ['loanValue', 'marginWarning', 'futures', 'margin', 'leverage'], - options: ['loanValue', 'marginWarning', 'margin', 'leverage'], + put: ['leverage', 'margin', 'marginWarning', 'futures', 'loanValue', 'instoMargin'], + options: ['leverage', 'margin', 'marginWarning', 'loanValue', 'instoMargin'], trade: [ - 'loanValue', - 'marginWarning', - 'margin', - 'primeOrderConfirmation', - 'tradeImmediately', 'leverage', - 'ethStakingRewards', + 'primeOrderConfirmation', + 'margin', + 'marginWarning', 'ethWrappedStakingRewards', + 'ethStakingRewards', + 'tradeImmediately', + 'loanValue', + 'instoMargin', ], - risk: ['loanValue', 'marginWarning', 'futures', 'margin', 'defiRisk', 'leverage'], - clock: ['loanValue', 'marginWarning', 'quickAndSimple', 'futures', 'getStartedInMinutes'], - 'error state': ['loanValue', 'marginWarning', 'ratMigrationerror', 'ledgerSignatureRejected'], - open: ['openEmail'], - email: ['openEmail', 'verifyEmail'], - envelope: ['openEmail', 'verifyEmail'], - letter: ['openEmail'], - '📧 📥 📤 ✉ 📩 📨': ['openEmail'], - storage: [ - 'selfCustody', - 'stayInControlSelfHostedWalletsStorage', - 'hardwareWallets', - 'stressTestedColdStorage', - 'insuranceProtection', - 'secureStorage', - 'cryptoPortfolio', - ], - wallet: [ - 'selfCustody', - 'borrowWallet', - 'stayInControlSelfHostedWalletsStorage', - 'downloadCoinbaseWalletArrow', - 'linkingYourWalletToYourCoinbaseAccount', - 'defiDecentralizedBorrowingLending', - 'walletNotifications', - 'emptyNfts', - 'browserExtension', - 'cryptoWallet', - 'walletSecurity', + risk: ['leverage', 'margin', 'marginWarning', 'futures', 'defiRisk', 'loanValue', 'instoMargin'], + coinbase: [ + 'cardBoosted', + 'coinbaseOneLogo', + 'coinbaseOnePhoneLightning', 'linkCoinbaseWallet', + 'browserExtension', + 'cbEth', + 'referralsBitcoin', + 'exploreDecentralizedApps', + 'referralsCoinbaseOne', + 'referralsGenericCoin', + ], + card: [ + 'cardBoosted', + 'creditCardExcitement', + 'cardWaitlist', + 'coinbaseCardLock', + 'automaticPayments', + 'coinbaseCardPocket', + 'creditCardExcitementCoinbaseOne', + ], + boosted: ['cardBoosted'], + rewards: ['cardBoosted', 'diamond', 'congratulationsOnEarningCrypto'], + coin: [ + 'cardBoosted', + 'noTransactions', 'walletReconnectSuccess', + 'transferCoins', + 'sendingCrypto', + 'portfolioOverview', + 'governance', + 'futures', + 'cardWaitlist', + 'cryptoPortfolio', + 'semiCustodial', + 'congratulationsOnEarningCrypto', 'secureStorage', - 'walletReconnect', - 'transferFunds', + 'secureAndTrusted', + 'cryptoForBeginners', + 'trendingHotAssets', 'transferEth', - 'connectWalletTutorial', - 'exploreDecentralizedApps', - ], - coins: [ - 'selfCustody', - 'defiEarn', - 'crossBorderPayments', - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'borrowWallet', - 'stayInControlSelfHostedWalletsStorage', - 'multicoinSupport', - 'cryptoEconomy', - 'leadingProtocol', - 'digitalCollectibles', - 'portfolioPerformance', - 'moneyDecentralized', - 'stressTestedColdStorage', - 'defiDecentralizedTradingExchange', - 'mining', - 'linkingYourWalletToYourCoinbaseAccount', - 'staking', - 'cryptoAndMore', - 'defiDecentralizedBorrowingLending', - 'encryptedEverything', - 'multiPlatformMobileAppBrowserExtension', - 'cryptoWallet', + 'walletReconnect', + 'readyToTrade', + 'coinbaseOneSavingFunds', + 'estimatedAmount', 'defiHow', - 'insuranceProtection', - 'cryptoEconomyUSDC', - 'holdingCrypto', - 'diamond', - 'trade', - 'sendCryptoFaster', - 'invest', - 'shareOnSocialMedia', - 'ethStakingRewards', - 'backedByUsDollar', - 'holdCrypto', - 'globalTransactions', - 'ethWrappedStakingRewards', - ], - user: [ - 'selfCustody', - 'borrowLoan', - 'linkingYourWalletToYourCoinbaseAccount', - 'didDecentralizedIdentity', - 'semiCustodial', - 'cb1BankTransfers', - ], - avatar: [ - 'selfCustody', - 'referralsGenericCoin', + 'cryptoAndMore', + 'tradeImmediately', + 'coinbaseFees', + 'securityShield', + 'freeBtc', + 'portfolioOverviewRelaunch', + 'cbbtc', + 'usdcLoan', 'borrowLoan', - 'collectingNfts', - 'digitalCollectibles', - 'moneyDecentralized', - 'linkingYourWalletToYourCoinbaseAccount', - 'didDecentralizedIdentity', - 'referralsCoinbaseOne', - 'nft', - 'semiCustodial', - 'uob', 'cb1BankTransfers', - 'referralsBitcoin', - ], - quiz: ['completeAQuiz'], - complete: ['completeAQuiz', 'documentSuccess'], - check: [ - 'completeAQuiz', - 'stressTestedColdStorage', - 'didDecentralizedIdentity', - 'appTrackingTransparency', - 'cardWaitlist', - 'walletReconnectSuccess', + 'fiatInterest', + 'usdcLoanEth', + 'instoSemiCustodial', + 'instoCryptoAndMore', ], - checkmark: [ - 'completeAQuiz', - 'stressTestedColdStorage', - 'quickAndSimple', - 'documentSuccess', - 'primeOrderConfirmation', - 'documentCertified', - 'verifyEmail', - 'onTheList', - 'cardWaitlist', + crypto: [ + 'cardBoosted', + 'noTransactions', + 'ratMigrationerror', 'walletReconnectSuccess', - ], - X: ['completeAQuiz'], - wrong: ['completeAQuiz'], - right: ['completeAQuiz', 'portfolioPerformance', 'trendingHotAssets'], - pencil: ['completeAQuiz'], - money: [ - 'completeAQuiz', - 'crossBorderPayments', - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'borrowWallet', - 'coinbaseCardPocket', + 'yieldHolding', + 'transferCoins', + 'sendingCrypto', + 'trade', + 'primeTradePreferences', + 'portfolioOverview', + 'sendCryptoFaster', + 'diamond', + 'primePriceLadder', + 'emptyNfts', + 'transferFunds', + 'hardwareWallets', + 'blockchain', + 'cryptoPortfolio', + 'congratulationsOnEarningCrypto', + 'mining', + 'decentralization', + 'insuranceProtection', + 'cryptoForBeginners', 'cryptoEconomy', + 'cryptoAssets', + 'transferEth', + 'walletReconnect', + 'estimatedAmount', + 'holdingCrypto', + 'holdCrypto', + 'portfolioOverviewRelaunch', 'leadingProtocol', - 'moneyDecentralized', - 'coinbaseOneSavingFunds', - 'freeBtc', - 'noFees', - 'bigBtc', - 'earn', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', + ], + chip: ['cardBoosted'], + visa: ['cardBoosted'], + select: ['cardBoosted', 'primePriceLadder'], + award: ['cardBoosted'], + money: [ 'cardBoosted', 'transferCoins', - 'coinbaseCardLock', + 'trade', + 'sendCryptoFaster', + 'highFees', 'addBank', - 'cryptoEconomyUSDC', - 'p2pPayments', 'currency', + 'borrowWallet', + 'crossBorderPayments', 'secureStorage', - 'trade', - 'highFees', - 'sendCryptoFaster', + 'noFees', + 'moneyDecentralized', + 'earn', 'invest', + 'completeAQuiz', + 'cryptoEconomy', 'backedByUsDollar', 'globalTransactions', + 'p2pPayments', + 'coinbaseOneSavingFunds', 'estimatedAmount', - 'fiatInterest', - ], - defi: ['defiEarn', 'defiHow'], - percentage: ['defiEarn', 'earnInterest'], - arrows: ['defiEarn', 'poweredByEthereum'], - card: [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseCardPocket', - 'automaticPayments', - 'cardBoosted', 'coinbaseCardLock', - 'cardWaitlist', - 'creditCardExcitement', + 'coinbaseCardPocket', + 'freeBtc', + 'bigBtc', + 'leadingProtocol', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', + 'fiatInterest', ], - bank: [ - 'creditCardExcitementCoinbaseOne', - 'borrowLoan', + 'no transaction': ['noTransactions'], + arrow: [ + 'noTransactions', + 'downloadingStatement', + 'downloadCoinbaseWalletArrow', + 'highFees', + 'futures', + 'portfolioPerformance', + 'trendingHotAssets', 'coinbaseOneSavingFunds', - 'semiCustodial', - 'addBank', - 'uob', - 'cb1BankTransfers', - 'currency', - 'creditCardExcitement', + 'holdingCrypto', + 'defiHow', + 'coinbaseFees', + 'automaticPayments', + 'focusLimitOrders', + 'commerceAccounting', 'fiatInterest', ], - details: [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseCardPocket', - 'addPhoneNumber', - 'coinbaseCardLock', - 'onTheList', - 'creditCardExcitement', - ], - credit: [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseCardPocket', - 'coinbaseCardLock', - 'cardWaitlist', - 'creditCardExcitement', - ], - excitement: ['creditCardExcitementCoinbaseOne', 'diamond', 'creditCardExcitement'], - hype: ['creditCardExcitementCoinbaseOne', 'diamond', 'creditCardExcitement'], - sparkle: [ - 'creditCardExcitementCoinbaseOne', - 'fileYourCryptoTaxesCheck', - 'freeBtc', - 'sendingCrypto', + warning: ['noTransactions', 'ledgerFailed', 'contactsListWarning', 'verifyInfo'], + send: [ + 'noTransactions', + 'ratMigrationerror', + 'walletReconnectSuccess', 'yieldHolding', 'transferCoins', - 'portfolioOverviewRelaunch', - 'referralsBonus', - 'portfolioOverview', - 'cardWaitlist', - 'walletReconnectSuccess', - 'diamond', - 'creditCardExcitement', - ], - '✨': [ - 'creditCardExcitementCoinbaseOne', - 'fileYourCryptoTaxesCheck', + 'bridging', 'sendingCrypto', - 'bigBtc', - 'primeDeFi', - 'nft', - 'ratMigration', - 'transferCoins', - 'primeStaking', + 'sendCryptoFaster', 'referralsBonus', - 'cardWaitlist', - 'walletReconnectSuccess', - 'diamond', - 'creditCardExcitement', - 'primeEarn', - ], - '❇️': [ - 'creditCardExcitementCoinbaseOne', - 'fileYourCryptoTaxesCheck', - 'sendingCrypto', - 'transferCoins', - 'referralsBonus', - 'cardWaitlist', - 'walletReconnectSuccess', - 'diamond', - 'creditCardExcitement', - 'connectWalletTutorial', + 'emptyNfts', + 'transferFunds', + 'crossBorderPayments', + 'eth2SellSend', + 'ethStakingMovement', + 'transferEth', + 'secureGlobalTransactions', + 'walletReconnect', + 'globalTransactions', + 'p2pPayments', + 'wrapEth', + 'wrapEthTwo', + 'lightningNetworkSend', + 'usdcLoan', + 'leadingProtocol', + 'usdcLoanEth', + 'instoEthStakingMovement', ], - '💳': ['creditCardExcitementCoinbaseOne', 'cardWaitlist', 'creditCardExcitement'], - '➕': [ - 'creditCardExcitementCoinbaseOne', - 'commerceInvoices', - 'addBank', - 'referralsBonus', - 'creditCardExcitement', + 'browser History chart 📝': ['browserHistory'], + login: ['login'], + signIn: ['login'], + computer: ['login'], + screen: ['login'], + useraccount: ['login'], + mouse: ['login'], + cursor: ['login'], + password: ['login'], + enter: ['login'], + light: ['login'], + chart: [ + 'accessToAdvancedCharts', + 'ratDashboard', + 'trade', + 'advancedTradeCharts', + 'primeTradePreferences', + 'advancedTradingChartsIndicatorsCandles', + 'staking', + 'portfolioPerformance', + 'earn', + 'invest', + 'earnInterest', + 'advancedTradingUi', + 'focusLimitOrders', + 'instoStaking', ], - '🏧': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'addBank', - 'currency', - 'creditCardExcitement', - 'fiatInterest', + candles: ['accessToAdvancedCharts', 'ratDashboard', 'advancedTradeCharts'], + graph: [ + 'accessToAdvancedCharts', + 'ratDashboard', + 'trade', + 'advancedTradeCharts', + 'fileYourCryptoTaxesCheck', + 'advancedTradingChartsIndicatorsCandles', + 'staking', + 'earn', + 'invest', + 'ethWrappedStakingRewards', + 'ethStakingRewards', + 'switchAdvancedToSimpleTrading', + 'exploreDecentralizedApps', + 'instoStaking', ], - '🏦': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'addBank', - 'currency', - 'creditCardExcitement', - 'fiatInterest', + numbers: [ + 'accessToAdvancedCharts', + 'ratDashboard', + 'trade', + 'advancedTradeCharts', + 'fileYourCryptoTaxesCheck', ], - '💸': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'transferCoins', - 'addBank', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'currency', + data: [ + 'accessToAdvancedCharts', + 'ratDashboard', 'trade', - 'creditCardExcitement', - 'highFees', - 'fiatInterest', + 'advancedTradeCharts', + 'fileYourCryptoTaxesCheck', + 'decentralizedWebWeb3', ], - '💵': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'transferCoins', - 'addBank', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'currency', + visualization: [ + 'accessToAdvancedCharts', + 'ratDashboard', 'trade', - 'creditCardExcitement', - 'highFees', - 'fiatInterest', + 'advancedTradeCharts', + 'fileYourCryptoTaxesCheck', ], - '💶': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'transferCoins', - 'addBank', - 'portfolioOverviewRelaunch', + positive: ['accessToAdvancedCharts', 'advancedTradeCharts'], + negative: ['accessToAdvancedCharts', 'advancedTradeCharts'], + trending: [ + 'accessToAdvancedCharts', + 'advancedTradeCharts', 'portfolioOverview', - 'currency', - 'creditCardExcitement', - 'highFees', - 'fiatInterest', + 'trendingHotAssets', + 'portfolioOverviewRelaunch', ], - '💷': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', - 'transferCoins', - 'addBank', + advanced: [ + 'accessToAdvancedCharts', + 'advancedTradeCharts', + 'switchAdvancedToSimpleTrading', + 'advancedTradingUi', + 'advancedTrading', + 'focusLimitOrders', + ], + '🕯': ['accessToAdvancedCharts', 'advancedTradeCharts'], + '🪔': ['accessToAdvancedCharts', 'advancedTradeCharts'], + '📈': [ + 'accessToAdvancedCharts', + 'ratDashboard', + 'trade', + 'advancedTradeCharts', + 'portfolioOverview', + 'fileYourCryptoTaxesCheck', + 'coinbaseOneRewards', + 'retailUSDCRewards', + 'earnInterest', + 'advancedTrading', 'portfolioOverviewRelaunch', + ], + '📉': [ + 'accessToAdvancedCharts', + 'ratDashboard', + 'trade', + 'advancedTradeCharts', 'portfolioOverview', - 'currency', + 'fileYourCryptoTaxesCheck', + 'earnInterest', + 'advancedTrading', + 'portfolioOverviewRelaunch', + ], + '📊': [ + 'accessToAdvancedCharts', + 'ratDashboard', 'trade', - 'creditCardExcitement', - 'highFees', - 'fiatInterest', + 'advancedTradeCharts', + 'portfolioOverview', + 'fileYourCryptoTaxesCheck', + 'earnInterest', + 'advancedTrading', + 'exploreDecentralizedApps', + 'portfolioOverviewRelaunch', ], - '💴': [ - 'creditCardExcitementCoinbaseOne', - 'coinbaseOneSavingFunds', + Prime: ['ratMigration', 'primeEarn', 'primeDeFi', 'primeStaking', 'instoPrimeStaking'], + Wallet: ['ratMigration', 'primeEarn', 'ratFoundWallet', 'hardwareWallets'], + Earn: ['ratMigration', 'primeEarn', 'primeStaking', 'earnToLearn', 'instoPrimeStaking'], + Rewards: ['ratMigration', 'primeEarn'], + Coins: ['ratMigration', 'primeEarn', 'primeDeFi', 'primeStaking', 'bigBtc', 'instoPrimeStaking'], + Assets: ['ratMigration', 'primeEarn', 'primeDeFi', 'primeStaking', 'instoPrimeStaking'], + Coin: ['ratMigration', 'primeEarn', 'primeDeFi', 'earnToLearn', 'bigBtc'], + Crypto: ['ratMigration', 'primeEarn', 'primeDeFi', 'primeStaking', 'bigBtc', 'instoPrimeStaking'], + Currency: ['ratMigration', 'primeEarn', 'bigBtc'], + Money: ['ratMigration', 'primeEarn', 'gainsAndLosses', 'earnToLearn'], + Cash: ['ratMigration', 'primeEarn'], + '✨': [ + 'ratMigration', + 'primeEarn', + 'primeDeFi', + 'walletReconnectSuccess', 'transferCoins', - 'addBank', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'currency', - 'trade', 'creditCardExcitement', - 'highFees', - 'fiatInterest', - ], - '🪙': [ - 'creditCardExcitementCoinbaseOne', + 'primeStaking', 'sendingCrypto', - 'transferCoins', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'walletReconnectSuccess', + 'referralsBonus', 'diamond', - 'trade', - 'creditCardExcitement', - 'walletReconnect', - 'transferEth', - ], - '💎': ['creditCardExcitementCoinbaseOne', 'diamond', 'creditCardExcitement'], - 'success state': [ - 'creditCardExcitementCoinbaseOne', - 'readyToTrade', - 'documentSuccess', - 'bigBtc', - 'appTrackingTransparency', - 'documentCertified', - 'verifyEmail', - 'onTheList', + 'fileYourCryptoTaxesCheck', + 'nft', 'cardWaitlist', - 'diamond', - 'creditCardExcitement', + 'bigBtc', + 'creditCardExcitementCoinbaseOne', + 'instoPrimeStaking', ], - success: [ - 'readyToTrade', - 'documentSuccess', - 'walletReconnectSuccess', - 'congratulationsOnEarningCrypto', - ], - coin: [ - 'readyToTrade', - 'usdcLoan', - 'borrowLoan', - 'coinbaseOneSavingFunds', - 'trendingHotAssets', - 'freeBtc', - 'futures', - 'governance', - 'cryptoForBeginners', - 'noTransactions', - 'sendingCrypto', - 'cryptoAndMore', - 'cardBoosted', - 'cbbtc', - 'tradeImmediately', - 'semiCustodial', - 'defiHow', - 'transferCoins', - 'cb1BankTransfers', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'cardWaitlist', + 'error state': ['ratMigrationerror', 'ledgerSignatureRejected', 'marginWarning', 'loanValue'], + invalid: ['ratMigrationerror'], + broken: ['ratMigrationerror'], + DeFi: ['primeDeFi', 'defiDecentralizedTradingExchange'], + Decentralized: ['primeDeFi'], + Finance: ['primeDeFi'], + Explore: ['primeDeFi'], + Universe: ['primeDeFi', 'primeStaking', 'instoPrimeStaking'], + Circles: ['primeDeFi', 'primeStaking', 'instoPrimeStaking'], + Stars: ['primeDeFi'], + wallet: [ 'walletReconnectSuccess', + 'downloadCoinbaseWalletArrow', + 'linkCoinbaseWallet', + 'emptyNfts', + 'transferFunds', + 'borrowWallet', + 'stayInControlSelfHostedWalletsStorage', + 'selfCustody', + 'walletSecurity', 'secureStorage', - 'walletReconnect', + 'browserExtension', + 'defiDecentralizedBorrowingLending', + 'linkingYourWalletToYourCoinbaseAccount', + 'cryptoWallet', 'transferEth', - 'congratulationsOnEarningCrypto', - 'cryptoPortfolio', - 'secureAndTrusted', - 'securityShield', - 'coinbaseFees', - 'estimatedAmount', - 'fiatInterest', - 'usdcLoanEth', + 'walletReconnect', + 'connectWalletTutorial', + 'exploreDecentralizedApps', + 'walletNotifications', ], - balloon: ['readyToTrade'], - welcome: ['readyToTrade'], - account: [ + success: [ + 'walletReconnectSuccess', + 'congratulationsOnEarningCrypto', 'readyToTrade', - 'stayInControlSelfHostedWalletsStorage', - 'coinbaseCardPocket', - 'hardwareWallets', - 'linkingYourWalletToYourCoinbaseAccount', - 'addPhoneNumber', + 'documentSuccess', + ], + check: [ + 'walletReconnectSuccess', + 'cardWaitlist', + 'stressTestedColdStorage', + 'completeAQuiz', + 'didDecentralizedIdentity', 'appTrackingTransparency', - 'coinbaseCardLock', - 'apiKey', ], - created: ['readyToTrade'], - start: ['readyToTrade', 'tradeImmediately', 'startToday'], - cross: ['crossBorderPayments'], - border: ['crossBorderPayments'], - international: [ - 'crossBorderPayments', - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'cryptoEconomy', - 'cryptoEconomyUSDC', - 'secureGlobalTransactions', - 'globalTransactions', + checkmark: [ + 'walletReconnectSuccess', + 'primeOrderConfirmation', + 'cardWaitlist', + 'stressTestedColdStorage', + 'quickAndSimple', + 'completeAQuiz', + 'verifyEmail', + 'documentSuccess', + 'documentCertified', + 'onTheList', ], - payments: ['crossBorderPayments', 'automaticPayments', 'p2pPayments'], - send: [ - 'crossBorderPayments', - 'usdcLoan', - 'leadingProtocol', - 'noTransactions', - 'sendingCrypto', - 'wrapEthTwo', - 'emptyNfts', - 'ratMigrationerror', - 'eth2SellSend', + sparkle: [ + 'walletReconnectSuccess', 'yieldHolding', 'transferCoins', - 'wrapEth', - 'secureGlobalTransactions', - 'bridging', - 'p2pPayments', + 'creditCardExcitement', + 'sendingCrypto', + 'portfolioOverview', 'referralsBonus', + 'diamond', + 'fileYourCryptoTaxesCheck', + 'cardWaitlist', + 'freeBtc', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + ], + connection: ['walletReconnectSuccess', 'decentralization', 'walletReconnect'], + connect: [ 'walletReconnectSuccess', + 'apiKey', + 'linkCoinbaseWallet', + 'linkingYourWalletToYourCoinbaseAccount', 'walletReconnect', - 'transferFunds', - 'sendCryptoFaster', - 'transferEth', - 'globalTransactions', - 'ethStakingMovement', - 'lightningNetworkSend', - 'usdcLoanEth', - ], - receive: [ - 'crossBorderPayments', - 'borrowWallet', - 'sendingCrypto', - 'transferCoins', - 'secureGlobalTransactions', - 'p2pPayments', - 'transferEth', - 'globalTransactions', - ], - globe: [ - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'cryptoEconomy', - 'cryptoEconomyUSDC', - 'secureGlobalTransactions', - 'globalTransactions', + 'connectWalletTutorial', + 'instoApiKey', ], - economy: ['cryptoEconomyCoin', 'cryptoEconomyEurc', 'cryptoEconomy', 'cryptoEconomyUSDC'], - freedom: ['cryptoEconomyCoin', 'cryptoEconomyEurc', 'cryptoEconomy', 'cryptoEconomyUSDC'], - growth: [ - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'coinbaseOneRewards', - 'cryptoEconomy', - 'cryptoEconomyUSDC', - 'retailUSDCRewards', + link: [ + 'walletReconnectSuccess', + 'linkCoinbaseWallet', + 'linkingYourWalletToYourCoinbaseAccount', + 'walletReconnect', + 'connectWalletTutorial', ], - crypto: [ - 'cryptoEconomyCoin', - 'cryptoEconomyEurc', - 'hardwareWallets', - 'cryptoEconomy', - 'leadingProtocol', - 'primePriceLadder', - 'mining', - 'cryptoForBeginners', - 'noTransactions', - 'sendingCrypto', - 'cardBoosted', - 'emptyNfts', - 'blockchain', - 'ratMigrationerror', - 'yieldHolding', - 'insuranceProtection', + '🪙': [ + 'walletReconnectSuccess', 'transferCoins', - 'cryptoEconomyUSDC', - 'portfolioOverviewRelaunch', + 'creditCardExcitement', + 'sendingCrypto', + 'trade', 'portfolioOverview', - 'holdingCrypto', - 'walletReconnectSuccess', 'diamond', - 'trade', + 'transferEth', 'walletReconnect', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + ], + '📲': [ + 'walletReconnectSuccess', + 'emptyNfts', 'transferFunds', - 'primeTradePreferences', - 'sendCryptoFaster', - 'transferEth', - 'cryptoAssets', - 'congratulationsOnEarningCrypto', - 'cryptoPortfolio', - 'decentralization', - 'holdCrypto', - 'estimatedAmount', + 'walletReconnect', + 'exploreDecentralizedApps', ], - economic: ['cryptoEconomyCoin', 'cryptoEconomyEurc', 'cryptoEconomy', 'cryptoEconomyUSDC'], - face: ['faceId'], - photo: ['faceId'], - camera: ['faceId'], - phone: [ - 'faceId', + '📱': [ + 'walletReconnectSuccess', 'coinbaseOnePhoneLightning', - 'downloadCoinbaseWalletArrow', - 'addPhoneNumber', - 'walletNotifications', - 'appTrackingTransparency', 'emptyNfts', - 'phoneNumber', + 'transferFunds', 'cbEth', 'walletReconnect', - 'transferFunds', 'connectWalletTutorial', 'exploreDecentralizedApps', + 'appTrackingTransparency', + 'walletNotifications', ], - onboarding: ['faceId', 'addPhoneNumber', 'verifyEmail', 'securityShield'], - security: [ - 'faceId', - 'stressTestedColdStorage', - 'addPhoneNumber', - 'defiDecentralizedBorrowingLending', - 'cryptoWallet', - 'phoneNumber', - 'walletSecurity', - 'insuranceProtection', - 'secureStorage', - 'secureAndTrusted', - 'securityShield', - ], - coinbase: [ + '🤳': [ + 'walletReconnectSuccess', 'coinbaseOnePhoneLightning', - 'referralsGenericCoin', - 'coinbaseOneLogo', - 'cardBoosted', - 'referralsCoinbaseOne', - 'browserExtension', - 'linkCoinbaseWallet', - 'cbEth', - 'referralsBitcoin', - 'exploreDecentralizedApps', + 'emptyNfts', + 'transferFunds', + 'walletReconnect', ], - one: [ - 'coinbaseOnePhoneLightning', - 'coinbaseOneDiscountedAmount', - 'automaticPayments', - 'coinbaseOneLogo', + '📳': ['walletReconnectSuccess', 'coinbaseOnePhoneLightning', 'walletReconnect'], + '📞': ['walletReconnectSuccess', 'coinbaseOnePhoneLightning', 'walletReconnect'], + '☎️': ['walletReconnectSuccess', 'coinbaseOnePhoneLightning', 'walletReconnect'], + '🔗': [ + 'walletReconnectSuccess', + 'linkCoinbaseWallet', + 'walletReconnect', + 'connectWalletTutorial', ], - cb1: ['coinbaseOnePhoneLightning', 'coinbaseOneLogo'], - authentication: ['coinbaseOnePhoneLightning', 'hardwareWallets', 'walletSecurity'], - device: ['coinbaseOnePhoneLightning', 'walletReconnect'], - mobile: ['coinbaseOnePhoneLightning', 'multiPlatformMobileAppBrowserExtension'], - support: ['coinbaseOnePhoneLightning', 'multicoinSupport'], - fast: [ + '🖇': ['walletReconnectSuccess', 'linkCoinbaseWallet', 'walletReconnect'], + '✅': ['walletReconnectSuccess', 'cardWaitlist', 'verifyEmail', 'documentSuccess'], + '✔️': ['walletReconnectSuccess', 'cardWaitlist', 'appTrackingTransparency'], + '❇️': [ + 'walletReconnectSuccess', + 'transferCoins', + 'creditCardExcitement', + 'sendingCrypto', + 'referralsBonus', + 'diamond', + 'fileYourCryptoTaxesCheck', + 'cardWaitlist', + 'connectWalletTutorial', + 'creditCardExcitementCoinbaseOne', + ], + download: ['downloadingStatement', 'downloadCoinbaseWalletArrow'], + statement: ['downloadingStatement'], + taxes: ['downloadingStatement', 'fileYourCryptoTaxesCheck'], + sparkles: ['downloadingStatement', 'primeStaking', 'bigBtc', 'instoPrimeStaking'], + ledger: ['ledgerSignatureRejected', 'ledgerFailed', 'cryptoAssets'], + plugin: ['ledgerSignatureRejected', 'ledgerFailed'], + instructional: ['ledgerSignatureRejected', 'ledgerFailed'], + rejection: ['ledgerSignatureRejected'], + decline: ['ledgerSignatureRejected'], + no: ['ledgerSignatureRejected', 'primeOrderConfirmation', 'governance', 'noFees'], + '❌': ['ledgerSignatureRejected'], + alert: ['ledgerFailed', 'notificationsAlt'], + failed: ['ledgerFailed'], + declined: ['ledgerFailed'], + 'warning state': ['ledgerFailed', 'contactsListWarning', 'verifyInfo'], + yield: ['yieldHolding', 'backedByUsDollar', 'holdCrypto', 'defiRisk'], + hold: ['yieldHolding', 'diamond', 'holdCrypto'], + down: ['yieldHolding', 'trade', 'holdingCrypto'], + one: [ + 'coinbaseOneLogo', 'coinbaseOnePhoneLightning', - 'quickAndSimple', - 'getStartedInMinutes', + 'automaticPayments', + 'coinbaseOneDiscountedAmount', + ], + cb1: ['coinbaseOneLogo', 'coinbaseOnePhoneLightning'], + logo: ['coinbaseOneLogo'], + logomark: ['coinbaseOneLogo'], + brand: ['coinbaseOneLogo'], + move: ['transferCoins', 'sendCryptoFaster', 'leadingProtocol'], + give: ['transferCoins', 'sendingCrypto'], + transmit: ['transferCoins'], + receive: [ + 'transferCoins', + 'sendingCrypto', + 'borrowWallet', + 'crossBorderPayments', + 'transferEth', + 'secureGlobalTransactions', + 'globalTransactions', 'p2pPayments', - 'lightningNetworkSend', ], - quick: ['coinbaseOnePhoneLightning', 'quickAndSimple', 'p2pPayments'], - lightning: ['coinbaseOnePhoneLightning', 'leadingProtocol', 'sendCryptoFaster'], - '⚡️': ['coinbaseOnePhoneLightning', 'leadingProtocol', 'sendCryptoFaster'], - '📞': ['coinbaseOnePhoneLightning', 'walletReconnectSuccess', 'walletReconnect'], - '☎️': ['coinbaseOnePhoneLightning', 'walletReconnectSuccess', 'walletReconnect'], - '📱': [ + '💸': [ + 'transferCoins', + 'creditCardExcitement', + 'trade', + 'portfolioOverview', + 'highFees', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💵': [ + 'transferCoins', + 'creditCardExcitement', + 'trade', + 'portfolioOverview', + 'highFees', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💶': [ + 'transferCoins', + 'creditCardExcitement', + 'portfolioOverview', + 'highFees', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💷': [ + 'transferCoins', + 'creditCardExcitement', + 'trade', + 'portfolioOverview', + 'highFees', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💴': [ + 'transferCoins', + 'creditCardExcitement', + 'trade', + 'portfolioOverview', + 'highFees', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💰': ['transferCoins', 'trade', 'highFees'], + bridge: ['bridging'], + blockchain: ['bridging', 'blockchain'], + 'one to another': ['bridging'], + tokens: ['bridging'], + '🌁': ['bridging'], + '🌉': ['bridging'], + authentication: ['coinbaseOnePhoneLightning', 'hardwareWallets', 'walletSecurity'], + phone: [ 'coinbaseOnePhoneLightning', - 'walletNotifications', - 'appTrackingTransparency', + 'downloadCoinbaseWalletArrow', 'emptyNfts', + 'transferFunds', 'cbEth', - 'walletReconnectSuccess', 'walletReconnect', - 'transferFunds', 'connectWalletTutorial', + 'phoneNumber', 'exploreDecentralizedApps', + 'appTrackingTransparency', + 'addPhoneNumber', + 'walletNotifications', + 'faceId', ], - '🤳': [ + device: ['coinbaseOnePhoneLightning', 'walletReconnect'], + mobile: ['coinbaseOnePhoneLightning', 'multiPlatformMobileAppBrowserExtension'], + support: ['coinbaseOnePhoneLightning', 'multicoinSupport'], + fast: [ 'coinbaseOnePhoneLightning', - 'emptyNfts', - 'walletReconnectSuccess', - 'walletReconnect', - 'transferFunds', + 'quickAndSimple', + 'getStartedInMinutes', + 'p2pPayments', + 'lightningNetworkSend', + 'instoGetStartedInMinutes', ], - '📳': ['coinbaseOnePhoneLightning', 'walletReconnectSuccess', 'walletReconnect'], - cbone: ['coinbaseOneRewards'], - interest: ['coinbaseOneRewards', 'earnInterest', 'retailUSDCRewards'], - APY: ['coinbaseOneRewards', 'retailUSDCRewards'], - rate: ['coinbaseOneRewards', 'retailUSDCRewards'], - '📈': [ - 'coinbaseOneRewards', - 'fileYourCryptoTaxesCheck', - 'earnInterest', - 'accessToAdvancedCharts', - 'advancedTrading', - 'advancedTradeCharts', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'trade', - 'retailUSDCRewards', - 'ratDashboard', + quick: ['coinbaseOnePhoneLightning', 'quickAndSimple', 'p2pPayments'], + lightning: ['coinbaseOnePhoneLightning', 'sendCryptoFaster', 'leadingProtocol'], + '⚡️': ['coinbaseOnePhoneLightning', 'sendCryptoFaster', 'leadingProtocol'], + bank: [ + 'creditCardExcitement', + 'uob', + 'addBank', + 'currency', + 'semiCustodial', + 'coinbaseOneSavingFunds', + 'borrowLoan', + 'cb1BankTransfers', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + 'instoSemiCustodial', ], - Layered: ['layeredNetworks'], - Networks: ['layeredNetworks'], - ethereum: [ - 'layeredNetworks', - 'poweredByEthereum', - 'wrapEthTwo', - 'eth2SellSend', - 'wrapEth', - 'cbEth', - 'eth2SendSell', - 'eth2SendSellTwo', + details: [ + 'creditCardExcitement', + 'coinbaseCardLock', + 'onTheList', + 'coinbaseCardPocket', + 'addPhoneNumber', + 'creditCardExcitementCoinbaseOne', ], - layer: ['layeredNetworks'], - eth: [ - 'layeredNetworks', - 'ethAddress', - 'poweredByEthereum', - 'ethStakeOrWrap', - 'ethStakeOrWrapTwo', - 'transferEth', - 'ethStakingRewards', - 'ethStakingMovement', - 'ethWrappedStakingRewards', + credit: [ + 'creditCardExcitement', + 'cardWaitlist', + 'coinbaseCardLock', + 'coinbaseCardPocket', + 'creditCardExcitementCoinbaseOne', ], - side: ['layeredNetworks'], - chain: ['layeredNetworks', 'blockchain', 'sidechain'], - borrow: ['borrowWallet', 'borrowLoan', 'defiDecentralizedBorrowingLending', 'cryptoWallet'], - finance: ['borrowWallet', 'staking'], - referral: ['referralsGenericCoin', 'freeBtc', 'referralsCoinbaseOne', 'referralsBitcoin'], - magic: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBitcoin'], - share: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBitcoin', 'shareOnSocialMedia'], - heads: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBitcoin'], - people: [ - 'referralsGenericCoin', - 'referralsCoinbaseOne', + excitement: ['creditCardExcitement', 'diamond', 'creditCardExcitementCoinbaseOne'], + hype: ['creditCardExcitement', 'diamond', 'creditCardExcitementCoinbaseOne'], + '💳': ['creditCardExcitement', 'cardWaitlist', 'creditCardExcitementCoinbaseOne'], + '➕': [ + 'creditCardExcitement', 'referralsBonus', - 'concierge', - 'referralsBitcoin', - ], - profile: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBitcoin'], - pic: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBitcoin'], - PFP: ['referralsGenericCoin', 'digitalCollectibles', 'referralsCoinbaseOne', 'referralsBitcoin'], - reward: ['referralsGenericCoin', 'referralsCoinbaseOne', 'referralsBonus', 'referralsBitcoin'], - usdc: ['usdcLoan', 'usdcLoanEth'], - loan: ['usdcLoan', 'borrowLoan', 'automaticPayments', 'usdcLoanEth'], - portal: ['usdcLoan', 'usdcLoanEth'], - stars: [ - 'usdcLoan', - 'bigBtc', - 'concierge', - 'ethStakingRewards', - 'ethWrappedStakingRewards', - 'usdcLoanEth', + 'addBank', + 'commerceInvoices', + 'creditCardExcitementCoinbaseOne', ], - Opt: ['optInPushNotificationsEmail'], - In: ['optInPushNotificationsEmail'], - Push: ['optInPushNotificationsEmail'], - Notifications: ['optInPushNotificationsEmail'], - Email: ['optInPushNotificationsEmail'], - Bubble: ['optInPushNotificationsEmail'], - Window: ['optInPushNotificationsEmail'], - Notify: ['optInPushNotificationsEmail'], - Account: ['optInPushNotificationsEmail'], - Security: ['optInPushNotificationsEmail'], - Prices: ['optInPushNotificationsEmail'], - hosted: ['stayInControlSelfHostedWalletsStorage'], - stay: ['stayInControlSelfHostedWalletsStorage'], - in: ['stayInControlSelfHostedWalletsStorage'], - control: ['stayInControlSelfHostedWalletsStorage'], - your: ['stayInControlSelfHostedWalletsStorage'], - access: ['stayInControlSelfHostedWalletsStorage', 'apiKey'], - custodial: ['borrowLoan', 'semiCustodial', 'cb1BankTransfers'], - 'semi custodial': ['borrowLoan', 'semiCustodial', 'cb1BankTransfers'], - plastic: ['coinbaseCardPocket', 'coinbaseCardLock'], - plus: ['coinbaseCardPocket', 'futures', 'commerceInvoices', 'coinbaseCardLock', 'addBank'], - payment: ['coinbaseCardPocket', 'coinbaseCardLock'], - method: ['coinbaseCardPocket', 'coinbaseCardLock'], - confirm: ['coinbaseCardPocket', 'documentSuccess', 'coinbaseCardLock'], - multi: ['multicoinSupport', 'multiPlatformMobileAppBrowserExtension'], - multicoin: ['multicoinSupport'], - networks: ['multicoinSupport'], - many: ['multicoinSupport'], - Wallet: ['hardwareWallets', 'ratMigration', 'ratFoundWallet', 'primeEarn'], - Hardware: ['hardwareWallets'], - Ledger: ['hardwareWallets'], - USB: ['hardwareWallets'], - cold: ['hardwareWallets', 'stressTestedColdStorage'], - leading: ['leadingProtocol'], - protocol: ['leadingProtocol'], - faster: ['leadingProtocol', 'sendCryptoFaster'], - bolt: ['leadingProtocol', 'sendCryptoFaster', 'lightningNetworkSend'], - move: ['leadingProtocol', 'transferCoins', 'sendCryptoFaster'], - quicker: ['leadingProtocol', 'sendCryptoFaster'], - currency: [ - 'leadingProtocol', - 'tradeImmediately', + '🏧': [ + 'creditCardExcitement', 'addBank', - 'holdingCrypto', 'currency', - 'trade', - 'highFees', - 'sendCryptoFaster', - 'holdCrypto', + 'coinbaseOneSavingFunds', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', ], - asset: ['leadingProtocol', 'bigBtc', 'tradeImmediately', 'sendCryptoFaster'], - nfts: ['collectingNfts'], - music: ['collectingNfts', 'digitalCollectibles'], - play: ['collectingNfts', 'watchVideos'], - file: ['collectingNfts', 'fileYourCryptoTaxesCheck', 'protectedNotes'], - document: [ - 'collectingNfts', - 'uploadDocument', - 'verifyInfo', - 'commerceAccounting', - 'commerceInvoices', + '🏦': [ + 'creditCardExcitement', + 'addBank', + 'currency', + 'coinbaseOneSavingFunds', + 'creditCardExcitementCoinbaseOne', + 'fiatInterest', + ], + '💎': ['creditCardExcitement', 'diamond', 'creditCardExcitementCoinbaseOne'], + 'success state': [ + 'creditCardExcitement', + 'diamond', + 'cardWaitlist', + 'verifyEmail', + 'readyToTrade', + 'documentSuccess', 'documentCertified', - 'protectedNotes', 'onTheList', + 'bigBtc', + 'appTrackingTransparency', + 'creditCardExcitementCoinbaseOne', ], - non: ['collectingNfts'], - fungible: ['collectingNfts'], - token: ['collectingNfts'], - upload: ['uploadDocument'], - proof: ['uploadDocument'], - address: ['uploadDocument', 'ethAddress'], - paper: ['uploadDocument', 'onTheList'], - mailing: ['uploadDocument'], - 'letter papers': ['uploadDocument'], - prime: ['primePriceLadder', 'primeOrderConfirmation', 'primeTradePreferences'], + dashboard: ['ratDashboard', 'portfolioOverview', 'portfolioOverviewRelaunch'], interface: [ - 'primePriceLadder', - 'primeOrderConfirmation', - 'primeTradePreferences', 'ratDashboard', - ], - price: ['primePriceLadder', 'noFees'], - ladder: ['primePriceLadder'], - prices: ['primePriceLadder', 'estimatedAmount'], - select: ['primePriceLadder', 'cardBoosted'], - match: ['primePriceLadder'], - interact: ['primePriceLadder'], - Gains: ['gainsAndLosses'], - Losses: ['gainsAndLosses'], - Scale: ['gainsAndLosses'], - Growth: ['gainsAndLosses'], - Money: ['gainsAndLosses', 'earnToLearn', 'ratMigration', 'primeEarn'], - Chart: ['gainsAndLosses', 'gasFeesNetworkFees', 'taxesDetails'], - Up: ['gainsAndLosses', 'earnToLearn'], - Down: ['gainsAndLosses'], - Arrow: ['gainsAndLosses'], - NFT: ['digitalCollectibles', 'clawMachinePig'], - digital: ['digitalCollectibles'], - collect: ['digitalCollectibles'], - collectibles: ['digitalCollectibles'], - art: ['digitalCollectibles', 'nft', 'exploreDecentralizedApps'], - portfolio: [ - 'portfolioPerformance', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'cryptoPortfolio', - ], - performance: ['portfolioPerformance'], - chart: [ - 'portfolioPerformance', - 'earnInterest', - 'advancedTradingUi', - 'staking', - 'advancedTradingChartsIndicatorsCandles', - 'focusLimitOrders', - 'earn', - 'accessToAdvancedCharts', - 'advancedTradeCharts', - 'trade', + 'primeOrderConfirmation', 'primeTradePreferences', - 'invest', - 'ratDashboard', - ], - to: [ - 'portfolioPerformance', - 'trendingHotAssets', - 'linkingYourWalletToYourCoinbaseAccount', - 'p2pPayments', - ], - the: ['portfolioPerformance', 'trendingHotAssets'], - arrow: [ - 'portfolioPerformance', - 'coinbaseOneSavingFunds', - 'trendingHotAssets', - 'downloadCoinbaseWalletArrow', - 'futures', - 'noTransactions', - 'commerceAccounting', - 'automaticPayments', - 'focusLimitOrders', - 'defiHow', - 'downloadingStatement', - 'holdingCrypto', - 'highFees', - 'coinbaseFees', - 'fiatInterest', - ], - ledger: ['ledgerFailed', 'cryptoAssets', 'ledgerSignatureRejected'], - plugin: ['ledgerFailed', 'ledgerSignatureRejected'], - instructional: ['ledgerFailed', 'ledgerSignatureRejected'], - warning: ['ledgerFailed', 'verifyInfo', 'noTransactions', 'contactsListWarning'], - alert: ['ledgerFailed', 'notificationsAlt'], - failed: ['ledgerFailed'], - declined: ['ledgerFailed'], - 'warning state': ['ledgerFailed', 'verifyInfo', 'contactsListWarning'], - users: [ - 'moneyDecentralized', - 'multipleAccountsWalletsForOneUser', - 'multiPlatformMobileAppBrowserExtension', - ], - taxes: ['fileYourCryptoTaxesCheck', 'downloadingStatement'], - calculator: ['fileYourCryptoTaxesCheck'], - charts: ['fileYourCryptoTaxesCheck', 'cryptoApps'], - pie: ['fileYourCryptoTaxesCheck'], - visualization: [ - 'fileYourCryptoTaxesCheck', - 'accessToAdvancedCharts', - 'advancedTradeCharts', - 'trade', - 'ratDashboard', - ], - numbers: [ - 'fileYourCryptoTaxesCheck', - 'accessToAdvancedCharts', - 'advancedTradeCharts', - 'trade', - 'ratDashboard', + 'primePriceLadder', ], - graph: [ - 'fileYourCryptoTaxesCheck', - 'staking', - 'advancedTradingChartsIndicatorsCandles', - 'earn', - 'accessToAdvancedCharts', - 'switchAdvancedToSimpleTrading', - 'advancedTradeCharts', - 'trade', - 'invest', - 'ethStakingRewards', - 'exploreDecentralizedApps', + browser: [ 'ratDashboard', - 'ethWrappedStakingRewards', - ], - organize: ['fileYourCryptoTaxesCheck'], - '%': ['fileYourCryptoTaxesCheck'], - '📊': [ - 'fileYourCryptoTaxesCheck', - 'earnInterest', - 'accessToAdvancedCharts', - 'advancedTrading', - 'advancedTradeCharts', - 'portfolioOverviewRelaunch', 'portfolioOverview', - 'trade', - 'exploreDecentralizedApps', - 'ratDashboard', - ], - '📉': [ - 'fileYourCryptoTaxesCheck', - 'earnInterest', - 'accessToAdvancedCharts', - 'advancedTrading', - 'advancedTradeCharts', + 'browserExtension', + 'watchVideos', + 'estimatedAmount', + 'switchAdvancedToSimpleTrading', 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'trade', - 'ratDashboard', ], - '🥧': ['fileYourCryptoTaxesCheck', 'portfolioOverviewRelaunch', 'portfolioOverview'], - '🧮': ['fileYourCryptoTaxesCheck'], - '🗄': ['fileYourCryptoTaxesCheck'], - '🗃': ['fileYourCryptoTaxesCheck'], - '📁': ['fileYourCryptoTaxesCheck'], - '📂': ['fileYourCryptoTaxesCheck'], - '🗂': ['fileYourCryptoTaxesCheck'], - contact: ['ethAddress', 'contactsListWarning'], - unique: ['ethAddress'], - number: ['ethAddress', 'addPhoneNumber', 'phoneNumber'], - code: ['ethAddress'], - '👶': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👧': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧒': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👦': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🦱': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🦱': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🦱': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🦰': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🦰': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🦰': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👱‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👱': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👱‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🦳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🦳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🦳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🦲': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🦲': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🦲': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧔': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👵': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧓': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👴': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👲': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👳‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👳‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧕': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👮‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👮': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👮‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👷‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👷': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👷‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '💂‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '💂': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '💂‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🕵️‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🕵️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🕵️‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍⚕️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍⚕️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍⚕️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🌾': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🌾': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🌾': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🍳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🍳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🍳': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🎓': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🎓': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🎓': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🎤': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🎤': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🎤': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🏫': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🏫': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🏫': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🏭': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🏭': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🏭': ['ethAddress', 'sendingCrypto', 'referralsBonus'], + analyze: ['ratDashboard'], + breakdown: ['ratDashboard'], + '🐀': ['ratDashboard'], + '💻': ['ratDashboard'], + '🖥': ['ratDashboard'], + Staking: ['primeStaking', 'instoPrimeStaking'], + Stake: ['primeStaking', 'instoPrimeStaking'], + Interest: ['primeStaking', 'instoPrimeStaking'], + offer: ['sendingCrypto'], + '👶': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👧': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧒': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👦': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🦱': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🦱': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🦱': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🦰': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🦰': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🦰': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👱‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👱': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👱‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🦳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🦳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🦳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🦲': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🦲': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🦲': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧔': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👵': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧓': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👴': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👲': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👳‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👳‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧕': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👮‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👮': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👮‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👷‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👷': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👷‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '💂‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '💂': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '💂‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🕵️‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🕵️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🕵️‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍⚕️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍⚕️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍⚕️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🌾': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🌾': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🌾': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🍳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🍳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🍳': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🎓': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🎓': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🎓': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🎤': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🎤': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🎤': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🏫': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🏫': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🏫': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🏭': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🏭': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🏭': ['sendingCrypto', 'ethAddress', 'referralsBonus'], '👩‍💻': [ - 'ethAddress', 'sendingCrypto', - 'portfolioOverviewRelaunch', - 'referralsBonus', + 'ethAddress', 'portfolioOverview', + 'referralsBonus', + 'portfolioOverviewRelaunch', ], '🧑‍💻': [ - 'ethAddress', 'sendingCrypto', - 'portfolioOverviewRelaunch', - 'referralsBonus', + 'ethAddress', 'portfolioOverview', + 'referralsBonus', + 'portfolioOverviewRelaunch', ], '👨‍💻': [ - 'ethAddress', 'sendingCrypto', - 'portfolioOverviewRelaunch', - 'referralsBonus', + 'ethAddress', 'portfolioOverview', + 'referralsBonus', + 'portfolioOverviewRelaunch', ], - '👩‍💼': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍💼': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍💼': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🔧': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🔧': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🔧': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🔬': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🔬': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🔬': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🎨': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🎨': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🎨': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🚒': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🚒': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🚒': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍✈️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍✈️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍✈️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍🚀': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🚀': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👨‍🚀': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👩‍⚖️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🤵‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🤵': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🤵‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '👸': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🤴': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🥷': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦸‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦸': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦸‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦹‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦹': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🦹‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🤶': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧑‍🎄': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🎅': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧙‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧙': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧙‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧝‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧝': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧝‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧛‍♀️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧛': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - '🧛‍♂️': ['ethAddress', 'sendingCrypto', 'referralsBonus'], - tag: ['coinbaseOneDiscountedAmount', 'noFees'], - coinbaseone: ['coinbaseOneDiscountedAmount'], - discounted: ['coinbaseOneDiscountedAmount'], - amount: ['coinbaseOneDiscountedAmount', 'estimatedAmount'], - clipboard: ['verifyInfo', 'onTheList'], - verify: ['verifyInfo', 'verifyEmail'], - info: ['verifyInfo'], - information: ['verifyInfo'], - error: ['verifyInfo'], - issue: ['verifyInfo'], - concern: ['verifyInfo'], - '⚠️': ['verifyInfo'], - mark: ['stressTestedColdStorage'], - secure: [ + '👩‍💼': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍💼': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍💼': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🔧': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🔧': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🔧': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🔬': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🔬': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🔬': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🎨': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🎨': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🎨': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🚒': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🚒': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🚒': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍✈️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍✈️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍✈️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍🚀': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🚀': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👨‍🚀': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👩‍⚖️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🤵‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🤵': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🤵‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '👸': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🤴': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🥷': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦸‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦸': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦸‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦹‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦹': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🦹‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🤶': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧑‍🎄': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🎅': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧙‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧙': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧙‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧝‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧝': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧝‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧛‍♀️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧛': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + '🧛‍♂️': ['sendingCrypto', 'ethAddress', 'referralsBonus'], + coins: [ + 'trade', + 'sendCryptoFaster', + 'diamond', + 'borrowWallet', + 'shareOnSocialMedia', + 'crossBorderPayments', 'stressTestedColdStorage', - 'walletSecurity', + 'stayInControlSelfHostedWalletsStorage', + 'selfCustody', + 'multiPlatformMobileAppBrowserExtension', + 'staking', + 'multicoinSupport', + 'portfolioPerformance', + 'mining', + 'moneyDecentralized', + 'digitalCollectibles', + 'invest', + 'encryptedEverything', 'insuranceProtection', - 'secureGlobalTransactions', - 'secureStorage', - 'secureAndTrusted', - 'securityShield', - ], - trusted: ['stressTestedColdStorage', 'secureAndTrusted'], - piggy: ['coinbaseOneSavingFunds', 'fiatInterest'], - pig: ['coinbaseOneSavingFunds', 'clawMachinePig', 'fiatInterest'], - safe: ['coinbaseOneSavingFunds', 'secureAndTrusted', 'fiatInterest'], - funds: [ - 'coinbaseOneSavingFunds', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'fiatInterest', - ], - saving: ['coinbaseOneSavingFunds', 'fiatInterest'], - '🐖': ['coinbaseOneSavingFunds', 'fiatInterest'], - '💲': ['coinbaseOneSavingFunds', 'commerceInvoices', 'highFees', 'fiatInterest'], - trend: ['trendingHotAssets'], - trending: [ - 'trendingHotAssets', - 'accessToAdvancedCharts', - 'advancedTradeCharts', - 'portfolioOverviewRelaunch', - 'portfolioOverview', + 'defiDecentralizedBorrowingLending', + 'linkingYourWalletToYourCoinbaseAccount', + 'cryptoEconomy', + 'cryptoWallet', + 'defiDecentralizedTradingExchange', + 'backedByUsDollar', + 'ethWrappedStakingRewards', + 'ethStakingRewards', + 'globalTransactions', + 'holdingCrypto', + 'defiHow', + 'holdCrypto', + 'cryptoAndMore', + 'defiEarn', + 'leadingProtocol', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', + 'instoStaking', + 'instoCryptoAndMore', ], - assets: ['trendingHotAssets', 'holdingCrypto', 'cryptoAssets'], - hot: ['trendingHotAssets'], - and: ['trendingHotAssets'], - free: ['freeBtc'], - bitcoin: ['freeBtc', 'cbbtc'], - get: ['freeBtc', 'getStartedInMinutes'], - paid: ['freeBtc'], - star: ['freeBtc', 'basedInUsa'], - join: ['freeBtc'], - refer: ['freeBtc'], - BTC: ['freeBtc', 'bigBtc', 'referralsBitcoin'], - hodl: ['freeBtc', 'holdCrypto'], - DeFi: ['defiDecentralizedTradingExchange', 'primeDeFi'], - swap: ['defiDecentralizedTradingExchange', 'tradeImmediately', 'advancedTradeCharts'], - apps: ['cryptoApps', 'linkCoinbaseWallet'], - ghost: ['cryptoApps', 'exploreDecentralizedApps'], - unicorn: ['cryptoApps'], - simple: ['quickAndSimple', 'switchAdvancedToSimpleTrading'], - time: ['quickAndSimple', 'automaticPayments', 'getStartedInMinutes'], - efficient: ['quickAndSimple'], - mining: ['mining'], - MEV: ['mining'], - cart: ['mining'], - cryptocurrency: ['mining', 'holdingCrypto', 'cryptoPortfolio'], - download: ['downloadCoinbaseWalletArrow', 'downloadingStatement'], - link: [ + currency: [ + 'trade', + 'sendCryptoFaster', + 'highFees', + 'addBank', + 'currency', + 'holdingCrypto', + 'holdCrypto', + 'tradeImmediately', + 'leadingProtocol', + ], + '👇': ['trade'], + '⬇️': ['trade'], + '🔻': ['trade'], + '👆': ['trade', 'highFees'], + '☝️': ['trade', 'highFees'], + '🆙': ['trade', 'highFees'], + '⬆️': ['trade', 'highFees'], + '🔝': ['trade', 'highFees'], + '🔼': ['trade', 'highFees'], + '🔺': ['trade', 'highFees'], + prime: [ + 'primeOrderConfirmation', + 'primeTradePreferences', + 'primePriceLadder', + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + order: ['primeOrderConfirmation', 'advancedTradingUi'], + confirmation: ['primeOrderConfirmation', 'encryptedEverything'], + book: ['primeOrderConfirmation', 'advancedTradingUi'], + exclamation: ['primeOrderConfirmation'], + yes: ['primeOrderConfirmation', 'governance'], + API: ['apiKey', 'instoApiKey'], + key: ['apiKey', 'protectedNotes', 'walletSecurity', 'instoApiKey'], + access: ['apiKey', 'stayInControlSelfHostedWalletsStorage', 'instoApiKey'], + account: [ + 'apiKey', + 'hardwareWallets', + 'stayInControlSelfHostedWalletsStorage', 'linkingYourWalletToYourCoinbaseAccount', - 'linkCoinbaseWallet', - 'walletReconnectSuccess', - 'walletReconnect', - 'connectWalletTutorial', + 'readyToTrade', + 'coinbaseCardLock', + 'coinbaseCardPocket', + 'appTrackingTransparency', + 'addPhoneNumber', + 'instoApiKey', ], - linking: ['linkingYourWalletToYourCoinbaseAccount'], - connect: [ + unlock: ['apiKey', 'instoApiKey'], + gain: ['apiKey', 'instoApiKey'], + trust: ['apiKey', 'defiRisk', 'instoApiKey'], + eth: [ + 'ethAddress', + 'poweredByEthereum', + 'layeredNetworks', + 'ethWrappedStakingRewards', + 'ethStakeOrWrap', + 'ethStakingMovement', + 'transferEth', + 'ethStakingRewards', + 'ethStakeOrWrapTwo', + 'instoEthStakingMovement', + ], + address: ['ethAddress', 'uploadDocument'], + contact: ['ethAddress', 'contactsListWarning'], + unique: ['ethAddress'], + number: ['ethAddress', 'phoneNumber', 'addPhoneNumber'], + code: ['ethAddress'], + margin: ['margin', 'marginWarning', 'loanValue', 'instoMargin'], + switch: [ + 'advancedTradeCharts', + 'switchAdvancedToSimpleTrading', + 'tradeImmediately', + 'advancedTrading', + ], + swap: ['advancedTradeCharts', 'defiDecentralizedTradingExchange', 'tradeImmediately'], + improved: ['advancedTradeCharts'], + clock: [ + 'marginWarning', + 'futures', + 'quickAndSimple', + 'getStartedInMinutes', + 'loanValue', + 'instoGetStartedInMinutes', + ], + apps: ['linkCoinbaseWallet', 'cryptoApps'], + interaction: ['primeTradePreferences'], + candlesticks: [ + 'primeTradePreferences', + 'advancedTradingChartsIndicatorsCandles', + 'switchAdvancedToSimpleTrading', + 'advancedTrading', + ], + settings: ['primeTradePreferences'], + gear: ['primeTradePreferences'], + preferences: ['primeTradePreferences'], + portfolio: [ + 'portfolioOverview', + 'cryptoPortfolio', + 'portfolioPerformance', + 'portfolioOverviewRelaunch', + ], + investments: ['portfolioOverview', 'portfolioOverviewRelaunch'], + stocks: ['portfolioOverview', 'portfolioOverviewRelaunch'], + cash: ['portfolioOverview', 'highFees', 'portfolioOverviewRelaunch'], + funds: [ + 'portfolioOverview', + 'coinbaseOneSavingFunds', + 'portfolioOverviewRelaunch', + 'fiatInterest', + ], + management: ['portfolioOverview', 'portfolioOverviewRelaunch'], + summary: ['portfolioOverview', 'portfolioOverviewRelaunch'], + '💼': ['portfolioOverview', 'portfolioOverviewRelaunch'], + '🧐': ['portfolioOverview', 'portfolioOverviewRelaunch'], + '🤑': ['portfolioOverview', 'portfolioOverviewRelaunch'], + '🥧': ['portfolioOverview', 'fileYourCryptoTaxesCheck', 'portfolioOverviewRelaunch'], + '🔎': ['portfolioOverview', 'portfolioOverviewRelaunch'], + '🔍': ['portfolioOverview', 'portfolioOverviewRelaunch'], + '👀': ['portfolioOverview', 'portfolioOverviewRelaunch'], + faster: ['sendCryptoFaster', 'leadingProtocol'], + bolt: ['sendCryptoFaster', 'lightningNetworkSend', 'leadingProtocol'], + quicker: ['sendCryptoFaster', 'leadingProtocol'], + asset: ['sendCryptoFaster', 'tradeImmediately', 'bigBtc', 'leadingProtocol'], + uob: ['uob'], + 'coinbase logo': ['uob'], + avatar: [ + 'uob', + 'nft', + 'semiCustodial', + 'selfCustody', + 'moneyDecentralized', + 'collectingNfts', + 'digitalCollectibles', 'linkingYourWalletToYourCoinbaseAccount', - 'linkCoinbaseWallet', - 'apiKey', - 'walletReconnectSuccess', - 'walletReconnect', - 'connectWalletTutorial', + 'didDecentralizedIdentity', + 'referralsBitcoin', + 'referralsCoinbaseOne', + 'referralsGenericCoin', + 'borrowLoan', + 'cb1BankTransfers', + 'instoSemiCustodial', ], - both: ['linkingYourWalletToYourCoinbaseAccount'], - barchart: ['earnInterest'], - futures: ['futures'], - future: ['futures', 'earn'], - short: ['futures'], - hedge: ['futures'], - balance: ['futures', 'cryptoAssets'], - Eth: ['gasFeesNetworkFees'], - Gas: ['gasFeesNetworkFees'], - Ethereum: ['gasFeesNetworkFees'], - Fees: ['gasFeesNetworkFees'], - Network: ['gasFeesNetworkFees'], - Payment: ['gasFeesNetworkFees'], - Pump: ['gasFeesNetworkFees'], - Token: ['gasFeesNetworkFees'], - Range: ['gasFeesNetworkFees'], - notification: ['notificationsAlt'], - bell: ['notificationsAlt'], - '🔔': ['notificationsAlt'], - '🔕': ['notificationsAlt'], - list: ['didDecentralizedIdentity', 'contactsListWarning', 'onTheList', 'cardWaitlist'], - checklist: ['didDecentralizedIdentity'], - id: ['didDecentralizedIdentity'], - did: ['didDecentralizedIdentity'], - identity: ['didDecentralizedIdentity'], + institution: ['uob', 'addBank', 'currency'], + person: ['uob', 'referralsBonus'], + '💲': ['highFees', 'coinbaseOneSavingFunds', 'commerceInvoices', 'fiatInterest'], governance: ['governance'], vote: ['governance'], staking: [ 'governance', 'staking', - 'defiHow', - 'ethStakingRewards', - 'ethStakingMovement', 'ethWrappedStakingRewards', + 'ethStakingMovement', + 'ethStakingRewards', + 'defiHow', + 'instoStaking', + 'instoEthStakingMovement', ], proposal: ['governance'], ballot: ['governance'], box: ['governance'], - yes: ['governance', 'primeOrderConfirmation'], - no: ['governance', 'noFees', 'primeOrderConfirmation', 'ledgerSignatureRejected'], maybe: ['governance'], so: ['governance'], - UI: ['advancedTradingUi'], - advanced: [ - 'advancedTradingUi', - 'focusLimitOrders', - 'accessToAdvancedCharts', - 'switchAdvancedToSimpleTrading', - 'advancedTrading', - 'advancedTradeCharts', + recommendation: ['referralsBonus'], + people: [ + 'referralsBonus', + 'referralsBitcoin', + 'referralsCoinbaseOne', + 'referralsGenericCoin', + 'concierge', ], - candlestick: ['advancedTradingUi'], - order: ['advancedTradingUi', 'primeOrderConfirmation'], - book: ['advancedTradingUi', 'primeOrderConfirmation'], - depth: ['advancedTradingUi'], - beginners: ['cryptoForBeginners'], - education: ['cryptoForBeginners', 'connectWalletTutorial'], - understanding: ['cryptoForBeginners'], - learning: ['cryptoForBeginners'], - article: ['cryptoForBeginners'], - reading: ['cryptoForBeginners'], - stake: [ - 'staking', + human: ['referralsBonus'], + reward: ['referralsBonus', 'referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + invitation: ['referralsBonus'], + invite: ['referralsBonus'], + request: ['referralsBonus'], + '💌': ['referralsBonus'], + '✉️': ['referralsBonus'], + '📨': ['referralsBonus'], + '📩': ['referralsBonus'], + '📧': ['referralsBonus'], + '🎁': ['referralsBonus'], + '📇': ['referralsBonus'], + gem: ['diamond'], + hand: ['diamond'], + holding: ['diamond'], + movement: [ + 'diamond', 'ethStakeOrWrap', - 'holdingCrypto', + 'cbEth', + 'ethStakingMovement', 'eth2SendSell', 'ethStakeOrWrapTwo', 'eth2SendSellTwo', - 'holdCrypto', - ], - liquid: ['staking'], - bar: ['staking', 'advancedTradingChartsIndicatorsCandles', 'earn'], - 'no transaction': ['noTransactions'], - Light: ['earnToLearn'], - Bulb: ['earnToLearn'], - Earn: ['earnToLearn', 'ratMigration', 'primeStaking', 'primeEarn'], - Learn: ['earnToLearn'], - Coin: ['earnToLearn', 'bigBtc', 'primeDeFi', 'ratMigration', 'primeEarn'], - Make: ['earnToLearn'], - Documents: ['documentSuccess'], - reviewed: ['documentSuccess', 'documentCertified'], - '✅': ['documentSuccess', 'verifyEmail', 'cardWaitlist', 'walletReconnectSuccess'], - commerce: ['commerceAccounting', 'commerceInvoices'], - accounting: ['commerceAccounting'], - '📝': ['commerceAccounting', 'commerceInvoices'], - '📄': ['commerceAccounting', 'commerceInvoices', 'protectedNotes'], - '📃': ['commerceAccounting', 'commerceInvoices', 'protectedNotes'], - '📑': ['commerceAccounting', 'commerceInvoices', 'protectedNotes'], - '⬇': ['commerceAccounting'], - give: ['sendingCrypto', 'transferCoins'], - offer: ['sendingCrypto'], - recurring: ['automaticPayments'], - automatic: ['automaticPayments'], - pay: ['automaticPayments'], - calendar: ['automaticPayments', 'startToday'], - once: ['automaticPayments'], - month: ['automaticPayments'], - powered: ['poweredByEthereum'], - by: ['poweredByEthereum', 'backedByUsDollar'], - icon: ['poweredByEthereum'], - fees: ['noFees', 'coinbaseFees'], - save: ['noFees', 'holdingCrypto', 'invest'], - transactions: ['noFees', 'secureGlobalTransactions', 'cryptoAssets', 'globalTransactions'], - sale: ['noFees'], - reduced: ['noFees'], - costs: ['noFees'], - candle: ['advancedTradingChartsIndicatorsCandles', 'switchAdvancedToSimpleTrading'], - candlesticks: [ - 'advancedTradingChartsIndicatorsCandles', - 'switchAdvancedToSimpleTrading', - 'advancedTrading', - 'primeTradePreferences', + 'instoEthStakingMovement', ], - wick: ['advancedTradingChartsIndicatorsCandles'], - Coins: ['bigBtc', 'primeDeFi', 'ratMigration', 'primeStaking', 'primeEarn'], - Currency: ['bigBtc', 'ratMigration', 'primeEarn'], - Crypto: ['bigBtc', 'primeDeFi', 'ratMigration', 'primeStaking', 'primeEarn'], - Bitcoin: ['bigBtc', 'referralsBitcoin', 'lightningNetworkSend'], - sparkles: ['bigBtc', 'downloadingStatement', 'primeStaking'], - moon: ['cryptoAndMore'], - 'empty state': [ - 'cryptoAndMore', - 'emptyNfts', - 'tradeImmediately', - 'emptyTrading', - 'ethTradingTwo', - 'ethTrading', - 'transferFunds', - ], - invoices: ['commerceInvoices'], - focus: ['focusLimitOrders'], - limit: ['focusLimitOrders'], - limitorders: ['focusLimitOrders'], - advancedtrading: ['focusLimitOrders'], - grow: ['earn', 'invest'], - invest: ['earn', 'invest'], - 'browser History chart 📝': ['browserHistory'], - logo: ['coinbaseOneLogo'], - logomark: ['coinbaseOneLogo'], - brand: ['coinbaseOneLogo'], - boosted: ['cardBoosted'], - rewards: ['cardBoosted', 'diamond', 'congratulationsOnEarningCrypto'], - chip: ['cardBoosted'], - visa: ['cardBoosted'], - award: ['cardBoosted'], - lend: ['defiDecentralizedBorrowingLending', 'cryptoWallet'], - safety: [ - 'defiDecentralizedBorrowingLending', - 'cryptoWallet', - 'insuranceProtection', - 'securityShield', - ], - wallets: ['multipleAccountsWalletsForOneUser'], - multiple: ['multipleAccountsWalletsForOneUser'], - 'single account': ['multipleAccountsWalletsForOneUser'], - lots: ['multipleAccountsWalletsForOneUser'], - of: ['multipleAccountsWalletsForOneUser'], - Pie: ['taxesDetails'], - Doc: ['taxesDetails'], - Plus: ['taxesDetails'], - Minus: ['taxesDetails'], - Check: ['taxesDetails'], - Mark: ['taxesDetails'], - Done: ['taxesDetails'], - Taxes: ['taxesDetails'], - Details: ['taxesDetails'], - notifications: ['walletNotifications'], - green: ['walletNotifications', 'liquidationBufferGreen'], - started: ['getStartedInMinutes'], - stopwatch: ['getStartedInMinutes'], - going: ['getStartedInMinutes'], - please: ['getStartedInMinutes'], - Prime: ['primeDeFi', 'ratMigration', 'primeStaking', 'primeEarn'], - Decentralized: ['primeDeFi'], - Finance: ['primeDeFi'], - Explore: ['primeDeFi'], - Assets: ['primeDeFi', 'ratMigration', 'primeStaking', 'primeEarn'], - Universe: ['primeDeFi', 'primeStaking'], - Circles: ['primeDeFi', 'primeStaking'], - Stars: ['primeDeFi'], - tracking: ['appTrackingTransparency'], - transparency: ['appTrackingTransparency'], - '✔️': ['appTrackingTransparency', 'cardWaitlist', 'walletReconnectSuccess'], - cbbtc: ['cbbtc'], - conversion: ['cbbtc'], - convert: ['cbbtc'], - yellow: ['cbbtc', 'sidechain', 'liquidationBufferYellow', 'shareOnSocialMedia'], - blue: ['cbbtc', 'sidechain', 'shareOnSocialMedia'], - CB1: ['referralsCoinbaseOne', 'concierge'], - Coinbase: ['referralsCoinbaseOne'], - One: ['referralsCoinbaseOne'], - transfer: [ - 'wrapEthTwo', - 'emptyNfts', - 'eth2SellSend', - 'wrapEth', - 'transferFunds', - 'transferEth', - 'ethStakingMovement', - ], - eth2: ['wrapEthTwo', 'eth2SellSend', 'wrapEth', 'ethStakingRewards', 'ethWrappedStakingRewards'], - '➡️': ['wrapEthTwo', 'eth2SellSend', 'wrapEth'], - liquidation: ['liquidationBufferRed', 'liquidationBufferGreen', 'liquidationBufferYellow'], - buffer: ['liquidationBufferRed', 'liquidationBufferGreen', 'liquidationBufferYellow'], - gauge: ['liquidationBufferRed', 'liquidationBufferGreen', 'liquidationBufferYellow'], - threshold: ['liquidationBufferRed', 'liquidationBufferGreen', 'liquidationBufferYellow'], - leverage: [ - 'liquidationBufferRed', - 'browserExtension', - 'liquidationBufferGreen', - 'leverage', - 'liquidationBufferYellow', - ], - derivatives: ['liquidationBufferRed', 'liquidationBufferGreen', 'liquidationBufferYellow'], - red: ['liquidationBufferRed'], - confirmation: ['primeOrderConfirmation', 'encryptedEverything'], - exclamation: ['primeOrderConfirmation'], - empty: ['emptyNfts', 'transferFunds'], - state: ['emptyNfts', 'transferFunds'], - '📲': [ - 'emptyNfts', - 'walletReconnectSuccess', - 'walletReconnect', - 'transferFunds', - 'exploreDecentralizedApps', - ], - deFi: ['defiRisk'], - banner: ['defiRisk'], - percent: ['defiRisk'], - sign: ['defiRisk'], - trust: ['defiRisk', 'apiKey'], - yield: ['defiRisk', 'yieldHolding', 'backedByUsDollar', 'holdCrypto'], - candles: ['accessToAdvancedCharts', 'advancedTradeCharts', 'ratDashboard'], - positive: ['accessToAdvancedCharts', 'advancedTradeCharts'], - negative: ['accessToAdvancedCharts', 'advancedTradeCharts'], - '🕯': ['accessToAdvancedCharts', 'advancedTradeCharts'], - '🪔': ['accessToAdvancedCharts', 'advancedTradeCharts'], - browser: [ - 'browserExtension', - 'switchAdvancedToSimpleTrading', - 'portfolioOverviewRelaunch', - 'portfolioOverview', - 'watchVideos', - 'estimatedAmount', - 'ratDashboard', - ], - extension: ['browserExtension'], - desktop: ['browserExtension', 'multiPlatformMobileAppBrowserExtension'], - integrate: ['browserExtension'], - website: ['browserExtension'], - encrypted: ['encryptedEverything'], - cryptography: ['encryptedEverything', 'cryptoAssets', 'decentralization'], - computers: ['encryptedEverything'], - computation: ['encryptedEverything'], - login: ['login'], - signIn: ['login'], - computer: ['login'], - screen: ['login'], - useraccount: ['login'], - mouse: ['login'], - cursor: ['login'], - password: ['login'], - enter: ['login'], - light: ['login'], - switch: [ - 'switchAdvancedToSimpleTrading', - 'tradeImmediately', - 'advancedTrading', - 'advancedTradeCharts', + '💍': ['diamond'], + '👋': ['diamond'], + '✋': ['diamond'], + '🤌': ['diamond'], + '': [ + 'fileYourCryptoTaxes', + 'giftBoxRewards', + 'basedInUsa', + 'update', + 'coinbaseFees', + 'appUpdate', + 'concierge', + 'trustedContacts', + 'unauthorizedTransfers', + 'secureAccount', + 'derivativesLoop', + 'lendGraph', + 'leadingProtocolMorpho', + 'graphChartTrading', + 'calendar', + 'tokenSales', + 'coinGateway', + 'stakingUpgrade', + 'insto', + 'instoCurrency', + 'instoRefreshKey', + 'instoKey', + 'instoSetupComplete', + 'instoDesignateSigner', + 'instoAboutOnchain', + 'instoSetupOnchain', + 'instoOnchainSetupInProgress', + 'instoConsensusWaitingForApprovals', + 'instoQRCode', ], - ui: ['switchAdvancedToSimpleTrading'], - change: ['switchAdvancedToSimpleTrading'], + calculator: ['fileYourCryptoTaxesCheck'], + charts: ['fileYourCryptoTaxesCheck', 'cryptoApps'], + pie: ['fileYourCryptoTaxesCheck'], + file: ['fileYourCryptoTaxesCheck', 'protectedNotes', 'collectingNfts'], + organize: ['fileYourCryptoTaxesCheck'], + '%': ['fileYourCryptoTaxesCheck'], + '🧮': ['fileYourCryptoTaxesCheck'], + '🗄': ['fileYourCryptoTaxesCheck'], + '🗃': ['fileYourCryptoTaxesCheck'], + '📁': ['fileYourCryptoTaxesCheck'], + '📂': ['fileYourCryptoTaxesCheck'], + '🗂': ['fileYourCryptoTaxesCheck'], + price: ['primePriceLadder', 'noFees'], + ladder: ['primePriceLadder'], + prices: ['primePriceLadder', 'estimatedAmount'], + match: ['primePriceLadder'], + interact: ['primePriceLadder'], + futures: ['futures'], + future: ['futures', 'earn'], + short: ['futures'], + hedge: ['futures'], + balance: ['futures', 'cryptoAssets'], + plus: ['futures', 'addBank', 'commerceInvoices', 'coinbaseCardLock', 'coinbaseCardPocket'], nft: ['nft', 'exploreDecentralizedApps'], 'non fungible': ['nft'], collectable: ['nft'], collectible: ['nft'], cat: ['nft'], nyan: ['nft'], + art: ['nft', 'digitalCollectibles', 'exploreDecentralizedApps'], artwork: ['nft'], '🖼': ['nft', 'exploreDecentralizedApps'], - hex: ['blockchain'], - block: ['blockchain'], - blockchain: ['blockchain', 'bridging'], - immediately: ['tradeImmediately'], - now: ['tradeImmediately'], - today: ['tradeImmediately', 'startToday'], - platform: ['multiPlatformMobileAppBrowserExtension'], - certified: ['documentCertified'], - correct: ['documentCertified'], - ribbon: ['documentCertified'], - confirmed: ['documentCertified', 'onTheList'], - approved: ['documentCertified', 'cardWaitlist'], - stamped: ['documentCertified'], - papers: ['documentCertified'], - semi: ['semiCustodial', 'cb1BankTransfers'], - invalid: ['ratMigrationerror'], - broken: ['ratMigrationerror'], - 'Empty state': ['clawMachinePig'], - 'Claw machine': ['clawMachinePig'], - 'Buy NFT': ['clawMachinePig'], - 'Notorious P.I.G': ['clawMachinePig'], - Scan: ['scanCode'], - QR: ['scanCode'], - Code: ['scanCode'], - '2FA': ['phoneNumber', 'walletSecurity'], - passcode: ['phoneNumber', 'walletSecurity'], + fund: ['addBank', 'currency'], + stock: ['addBank', 'currency'], + building: ['addBank', 'currency'], + addition: ['addBank'], + list: ['cardWaitlist', 'didDecentralizedIdentity', 'contactsListWarning', 'onTheList'], + waiting: ['cardWaitlist', 'onTheList'], + pending: ['cardWaitlist'], + delay: ['cardWaitlist'], + approved: ['cardWaitlist', 'documentCertified'], + '📋': ['cardWaitlist'], + document: [ + 'protectedNotes', + 'collectingNfts', + 'commerceInvoices', + 'documentCertified', + 'onTheList', + 'commerceAccounting', + 'verifyInfo', + 'uploadDocument', + ], + form: ['protectedNotes'], lock: [ - 'phoneNumber', 'protectedNotes', - 'secureGlobalTransactions', 'secureStorage', + 'secureGlobalTransactions', + 'phoneNumber', 'securityShield', ], - asterisk: ['phoneNumber'], - hold: ['yieldHolding', 'diamond', 'holdCrypto'], - down: ['yieldHolding', 'holdingCrypto', 'trade'], - form: ['protectedNotes'], - key: ['protectedNotes', 'walletSecurity', 'apiKey'], protection: ['protectedNotes', 'insuranceProtection'], privacy: ['protectedNotes'], investment: ['protectedNotes'], @@ -1634,192 +1309,643 @@ const descriptionMap: Record = { '🔐': ['protectedNotes'], '🔑': ['protectedNotes'], '🗝': ['protectedNotes'], + '📄': ['protectedNotes', 'commerceInvoices', 'commerceAccounting'], + '📃': ['protectedNotes', 'commerceInvoices', 'commerceAccounting'], '📜': ['protectedNotes'], - Lock: ['walletSecurity'], - how: ['defiHow'], - hexagon: ['sidechain'], - connections: ['sidechain'], - umbrella: ['insuranceProtection'], - insurance: ['insuranceProtection'], - made: ['basedInUsa'], - USA: ['basedInUsa'], - America: ['basedInUsa'], - fuck: ['basedInUsa'], - yeah: ['basedInUsa'], - location: ['basedInUsa'], - marker: ['basedInUsa'], - pin: ['basedInUsa'], - 'United States': ['basedInUsa'], - Rewards: ['ratMigration', 'primeEarn'], - Cash: ['ratMigration', 'primeEarn'], - contacts: ['contactsListWarning'], - '⚠': ['contactsListWarning'], - statement: ['downloadingStatement'], - nux: ['verifyEmail'], - exchange: ['emptyTrading', 'ethTradingTwo', 'ethTrading'], - transmit: ['transferCoins'], - '💰': ['transferCoins', 'trade', 'highFees'], - rat: ['advancedTrading'], - on: ['onTheList'], - waiting: ['onTheList', 'cardWaitlist'], - notify: ['onTheList'], - fund: ['addBank', 'currency'], - stock: ['addBank', 'currency'], - building: ['addBank', 'currency'], - institution: ['addBank', 'uob', 'currency'], - addition: ['addBank'], - watch: ['startToday', 'watchVideos'], - videos: ['startToday'], - week: ['startToday'], - learn: ['startToday'], - improved: ['advancedTradeCharts'], - '🔗': [ - 'linkCoinbaseWallet', - 'walletReconnectSuccess', - 'walletReconnect', - 'connectWalletTutorial', + '📑': ['protectedNotes', 'commerceInvoices', 'commerceAccounting'], + 'empty state': [ + 'emptyTrading', + 'emptyNfts', + 'transferFunds', + 'ethTrading', + 'ethTradingTwo', + 'cryptoAndMore', + 'tradeImmediately', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + exchange: ['emptyTrading', 'ethTrading', 'ethTradingTwo', 'instoEmptyTrading'], + empty: ['emptyNfts', 'transferFunds'], + state: ['emptyNfts', 'transferFunds'], + transfer: [ + 'emptyNfts', + 'transferFunds', + 'eth2SellSend', + 'ethStakingMovement', + 'transferEth', + 'wrapEth', + 'wrapEthTwo', + 'instoEthStakingMovement', + ], + 'Empty State': ['ratFoundWallet'], + 'ASAP Ratty': ['ratFoundWallet'], + 'Rat found wallet': ['ratFoundWallet'], + Rat: ['ratFoundWallet'], + 'Empty state': ['clawMachinePig'], + NFT: ['clawMachinePig', 'digitalCollectibles'], + 'Claw machine': ['clawMachinePig'], + pig: ['clawMachinePig', 'coinbaseOneSavingFunds', 'fiatInterest'], + 'Buy NFT': ['clawMachinePig'], + 'Notorious P.I.G': ['clawMachinePig'], + Hardware: ['hardwareWallets'], + Ledger: ['hardwareWallets'], + USB: ['hardwareWallets'], + storage: [ + 'hardwareWallets', + 'cryptoPortfolio', + 'stressTestedColdStorage', + 'stayInControlSelfHostedWalletsStorage', + 'selfCustody', + 'secureStorage', + 'insuranceProtection', + ], + cold: ['hardwareWallets', 'stressTestedColdStorage'], + Pie: ['taxesDetails'], + Chart: ['taxesDetails', 'gainsAndLosses', 'gasFeesNetworkFees'], + Doc: ['taxesDetails'], + Plus: ['taxesDetails'], + Minus: ['taxesDetails'], + Check: ['taxesDetails'], + Mark: ['taxesDetails'], + Done: ['taxesDetails'], + Taxes: ['taxesDetails'], + Details: ['taxesDetails'], + cbone: ['coinbaseOneRewards'], + earn: [ + 'coinbaseOneRewards', + 'retailUSDCRewards', + 'startToday', + 'stableValue', + 'staking', + 'earn', + 'invest', + 'completeAQuiz', + 'watchVideos', + 'backedByUsDollar', + 'defiEarn', + 'earnInterest', + 'freeBtc', + 'defiRisk', + 'instoStaking', + ], + interest: ['coinbaseOneRewards', 'retailUSDCRewards', 'earnInterest'], + APY: ['coinbaseOneRewards', 'retailUSDCRewards'], + growth: [ + 'coinbaseOneRewards', + 'retailUSDCRewards', + 'cryptoEconomy', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', + ], + rate: ['coinbaseOneRewards', 'retailUSDCRewards'], + value: [ + 'coinbaseOneRewards', + 'retailUSDCRewards', + 'stableValue', + 'mining', + 'moneyDecentralized', + 'p2pPayments', + 'bigBtc', + ], + USDC: ['retailUSDCRewards'], + borrow: ['borrowWallet', 'defiDecentralizedBorrowingLending', 'cryptoWallet', 'borrowLoan'], + finance: ['borrowWallet', 'staking', 'instoStaking'], + hex: ['blockchain'], + block: ['blockchain'], + chain: ['blockchain', 'layeredNetworks', 'sidechain'], + network: [ + 'blockchain', + 'poweredByEthereum', + 'decentralizedWebWeb3', + 'decentralization', + 'moneyDecentralized', + 'encryptedEverything', + 'cryptoAssets', + 'referralsBitcoin', + 'referralsCoinbaseOne', + 'lightningNetworkSend', + 'referralsGenericCoin', + ], + decentralized: [ + 'blockchain', + 'decentralizedWebWeb3', + 'decentralization', + 'moneyDecentralized', + 'defiDecentralizedBorrowingLending', + 'didDecentralizedIdentity', + 'cryptoWallet', + 'defiDecentralizedTradingExchange', + 'backedByUsDollar', + ], + folder: ['cryptoPortfolio'], + cryptocurrency: ['cryptoPortfolio', 'mining', 'holdingCrypto'], + candle: ['advancedTradingChartsIndicatorsCandles', 'switchAdvancedToSimpleTrading'], + wick: ['advancedTradingChartsIndicatorsCandles'], + bar: ['advancedTradingChartsIndicatorsCandles', 'staking', 'earn', 'instoStaking'], + semi: ['semiCustodial', 'cb1BankTransfers', 'instoSemiCustodial'], + custodial: ['semiCustodial', 'borrowLoan', 'cb1BankTransfers', 'instoSemiCustodial'], + 'semi custodial': ['semiCustodial', 'borrowLoan', 'cb1BankTransfers', 'instoSemiCustodial'], + user: [ + 'semiCustodial', + 'selfCustody', + 'linkingYourWalletToYourCoinbaseAccount', + 'didDecentralizedIdentity', + 'borrowLoan', + 'cb1BankTransfers', + 'instoSemiCustodial', + ], + share: ['shareOnSocialMedia', 'referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + social: ['shareOnSocialMedia'], + media: ['shareOnSocialMedia'], + circles: ['shareOnSocialMedia', 'ethStakingMovement', 'instoEthStakingMovement'], + blue: ['shareOnSocialMedia', 'sidechain', 'cbbtc'], + yellow: ['shareOnSocialMedia', 'sidechain', 'cbbtc', 'liquidationBufferYellow'], + cross: ['crossBorderPayments'], + border: ['crossBorderPayments'], + international: [ + 'crossBorderPayments', + 'cryptoEconomy', + 'secureGlobalTransactions', + 'globalTransactions', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', + ], + payments: ['crossBorderPayments', 'p2pPayments', 'automaticPayments'], + mark: ['stressTestedColdStorage'], + secure: [ + 'stressTestedColdStorage', + 'walletSecurity', + 'secureStorage', + 'secureAndTrusted', + 'insuranceProtection', + 'secureGlobalTransactions', + 'securityShield', + ], + trusted: ['stressTestedColdStorage', 'secureAndTrusted'], + security: [ + 'stressTestedColdStorage', + 'walletSecurity', + 'secureStorage', + 'secureAndTrusted', + 'insuranceProtection', + 'defiDecentralizedBorrowingLending', + 'cryptoWallet', + 'phoneNumber', + 'securityShield', + 'addPhoneNumber', + 'faceId', + ], + self: ['stayInControlSelfHostedWalletsStorage', 'selfCustody', 'decentralizedWebWeb3'], + hosted: ['stayInControlSelfHostedWalletsStorage'], + stay: ['stayInControlSelfHostedWalletsStorage'], + in: ['stayInControlSelfHostedWalletsStorage'], + control: ['stayInControlSelfHostedWalletsStorage'], + your: ['stayInControlSelfHostedWalletsStorage'], + trophy: ['congratulationsOnEarningCrypto'], + win: ['congratulationsOnEarningCrypto'], + custody: ['selfCustody', 'decentralizedWebWeb3'], + store: [ + 'selfCustody', + 'stableValue', + 'secureStorage', + 'secureAndTrusted', + 'defiDecentralizedBorrowingLending', + 'cryptoWallet', + 'holdingCrypto', + 'holdCrypto', + 'bigBtc', + ], + Lock: ['walletSecurity'], + '2FA': ['walletSecurity', 'phoneNumber'], + passcode: ['walletSecurity', 'phoneNumber'], + Gains: ['gainsAndLosses'], + Losses: ['gainsAndLosses'], + Scale: ['gainsAndLosses'], + Growth: ['gainsAndLosses'], + Up: ['gainsAndLosses', 'earnToLearn'], + Down: ['gainsAndLosses'], + Arrow: ['gainsAndLosses'], + start: ['startToday', 'readyToTrade', 'tradeImmediately'], + today: ['startToday', 'tradeImmediately'], + watch: ['startToday', 'watchVideos'], + videos: ['startToday'], + calendar: ['startToday', 'automaticPayments'], + week: ['startToday'], + learn: ['startToday'], + stable: ['stableValue'], + scale: ['stableValue'], + stablecoin: ['stableValue'], + keep: ['secureStorage'], + powered: ['poweredByEthereum'], + by: ['poweredByEthereum', 'backedByUsDollar'], + ethereum: [ + 'poweredByEthereum', + 'layeredNetworks', + 'eth2SellSend', + 'cbEth', + 'eth2SendSell', + 'wrapEth', + 'eth2SendSellTwo', + 'wrapEthTwo', + ], + icon: ['poweredByEthereum'], + arrows: ['poweredByEthereum', 'defiEarn'], + multi: ['multiPlatformMobileAppBrowserExtension', 'multicoinSupport'], + platform: ['multiPlatformMobileAppBrowserExtension'], + desktop: ['multiPlatformMobileAppBrowserExtension', 'browserExtension'], + users: [ + 'multiPlatformMobileAppBrowserExtension', + 'moneyDecentralized', + 'multipleAccountsWalletsForOneUser', + ], + stake: [ + 'staking', + 'ethStakeOrWrap', + 'eth2SendSell', + 'ethStakeOrWrapTwo', + 'eth2SendSellTwo', + 'holdingCrypto', + 'holdCrypto', + 'instoStaking', + ], + liquid: ['staking', 'instoStaking'], + multicoin: ['multicoinSupport'], + networks: ['multicoinSupport'], + many: ['multicoinSupport'], + performance: ['portfolioPerformance'], + to: [ + 'portfolioPerformance', + 'linkingYourWalletToYourCoinbaseAccount', + 'trendingHotAssets', + 'p2pPayments', + ], + the: ['portfolioPerformance', 'trendingHotAssets'], + right: ['portfolioPerformance', 'completeAQuiz', 'trendingHotAssets'], + Trust: ['secureAndTrusted'], + shield: ['secureAndTrusted'], + safe: ['secureAndTrusted', 'coinbaseOneSavingFunds', 'fiatInterest'], + mining: ['mining'], + MEV: ['mining'], + cart: ['mining'], + fees: ['noFees', 'coinbaseFees'], + save: ['noFees', 'invest', 'holdingCrypto'], + transactions: ['noFees', 'cryptoAssets', 'secureGlobalTransactions', 'globalTransactions'], + tag: ['noFees', 'coinbaseOneDiscountedAmount'], + sale: ['noFees'], + reduced: ['noFees'], + costs: ['noFees'], + web: ['decentralizedWebWeb3', 'browserExtension'], + web3: ['decentralizedWebWeb3'], + ownership: ['decentralizedWebWeb3'], + cryptography: ['decentralization', 'encryptedEverything', 'cryptoAssets'], + grow: ['earn', 'invest'], + invest: ['earn', 'invest'], + nfts: ['collectingNfts'], + music: ['collectingNfts', 'digitalCollectibles'], + play: ['collectingNfts', 'watchVideos'], + non: ['collectingNfts'], + fungible: ['collectingNfts'], + token: ['collectingNfts'], + digital: ['digitalCollectibles'], + collect: ['digitalCollectibles'], + collectibles: ['digitalCollectibles'], + PFP: ['digitalCollectibles', 'referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + encrypted: ['encryptedEverything'], + computers: ['encryptedEverything'], + computation: ['encryptedEverything'], + extension: ['browserExtension'], + integrate: ['browserExtension'], + website: ['browserExtension'], + simple: ['quickAndSimple', 'switchAdvancedToSimpleTrading'], + time: ['quickAndSimple', 'getStartedInMinutes', 'automaticPayments', 'instoGetStartedInMinutes'], + efficient: ['quickAndSimple'], + umbrella: ['insuranceProtection'], + insurance: ['insuranceProtection'], + safety: [ + 'insuranceProtection', + 'defiDecentralizedBorrowingLending', + 'cryptoWallet', + 'securityShield', + ], + beginners: ['cryptoForBeginners'], + education: ['cryptoForBeginners', 'connectWalletTutorial'], + understanding: ['cryptoForBeginners'], + learning: ['cryptoForBeginners'], + article: ['cryptoForBeginners'], + reading: ['cryptoForBeginners'], + lend: ['defiDecentralizedBorrowingLending', 'cryptoWallet'], + Layered: ['layeredNetworks'], + Networks: ['layeredNetworks'], + layer: ['layeredNetworks'], + side: ['layeredNetworks'], + linking: ['linkingYourWalletToYourCoinbaseAccount'], + both: ['linkingYourWalletToYourCoinbaseAccount'], + hexagon: ['sidechain'], + connections: ['sidechain'], + quiz: ['completeAQuiz'], + complete: ['completeAQuiz', 'documentSuccess'], + X: ['completeAQuiz'], + wrong: ['completeAQuiz'], + pencil: ['completeAQuiz'], + Eth: ['gasFeesNetworkFees'], + Gas: ['gasFeesNetworkFees'], + Ethereum: ['gasFeesNetworkFees'], + Fees: ['gasFeesNetworkFees'], + Network: ['gasFeesNetworkFees'], + Payment: ['gasFeesNetworkFees'], + Pump: ['gasFeesNetworkFees'], + Token: ['gasFeesNetworkFees'], + Range: ['gasFeesNetworkFees'], + video: ['watchVideos'], + eye: ['watchVideos'], + window: ['watchVideos'], + button: ['watchVideos'], + trend: ['trendingHotAssets'], + assets: ['trendingHotAssets', 'cryptoAssets', 'holdingCrypto'], + hot: ['trendingHotAssets'], + and: ['trendingHotAssets'], + made: ['basedInUsa'], + USA: ['basedInUsa'], + America: ['basedInUsa'], + fuck: ['basedInUsa'], + yeah: ['basedInUsa'], + star: ['basedInUsa', 'freeBtc'], + location: ['basedInUsa'], + marker: ['basedInUsa'], + pin: ['basedInUsa'], + 'United States': ['basedInUsa'], + globe: [ + 'cryptoEconomy', + 'secureGlobalTransactions', + 'globalTransactions', + 'cryptoEconomyUSDC', + 'cryptoEconomyEurc', + 'cryptoEconomyCoin', ], - '🖇': ['linkCoinbaseWallet', 'walletReconnectSuccess', 'walletReconnect'], - Staking: ['primeStaking'], - Stake: ['primeStaking'], - Interest: ['primeStaking'], - API: ['apiKey'], - unlock: ['apiKey'], - gain: ['apiKey'], - world: ['secureGlobalTransactions', 'globalTransactions'], - 'peer to peer': ['secureGlobalTransactions'], - uob: ['uob'], - 'coinbase logo': ['uob'], - person: ['uob', 'referralsBonus'], - sending: ['cbEth'], - movement: [ - 'cbEth', - 'ethStakeOrWrap', - 'eth2SendSell', - 'diamond', - 'ethStakeOrWrapTwo', - 'eth2SendSellTwo', - 'ethStakingMovement', + economy: ['cryptoEconomy', 'cryptoEconomyUSDC', 'cryptoEconomyEurc', 'cryptoEconomyCoin'], + freedom: ['cryptoEconomy', 'cryptoEconomyUSDC', 'cryptoEconomyEurc', 'cryptoEconomyCoin'], + economic: ['cryptoEconomy', 'cryptoEconomyUSDC', 'cryptoEconomyEurc', 'cryptoEconomyCoin'], + Opt: ['optInPushNotificationsEmail'], + In: ['optInPushNotificationsEmail'], + Push: ['optInPushNotificationsEmail'], + Notifications: ['optInPushNotificationsEmail'], + Email: ['optInPushNotificationsEmail'], + Bubble: ['optInPushNotificationsEmail'], + Window: ['optInPushNotificationsEmail'], + Notify: ['optInPushNotificationsEmail'], + Account: ['optInPushNotificationsEmail'], + Security: ['optInPushNotificationsEmail'], + Prices: ['optInPushNotificationsEmail'], + checklist: ['didDecentralizedIdentity'], + id: ['didDecentralizedIdentity'], + did: ['didDecentralizedIdentity'], + identity: ['didDecentralizedIdentity'], + wallets: ['multipleAccountsWalletsForOneUser'], + multiple: ['multipleAccountsWalletsForOneUser'], + 'single account': ['multipleAccountsWalletsForOneUser'], + lots: ['multipleAccountsWalletsForOneUser'], + of: ['multipleAccountsWalletsForOneUser'], + get: ['getStartedInMinutes', 'freeBtc', 'instoGetStartedInMinutes'], + started: ['getStartedInMinutes', 'instoGetStartedInMinutes'], + stopwatch: ['getStartedInMinutes', 'instoGetStartedInMinutes'], + going: ['getStartedInMinutes', 'instoGetStartedInMinutes'], + please: ['getStartedInMinutes', 'instoGetStartedInMinutes'], + backed: ['backedByUsDollar'], + dollars: ['backedByUsDollar'], + US: ['backedByUsDollar'], + stars: [ + 'ethWrappedStakingRewards', + 'ethStakingRewards', + 'bigBtc', + 'usdcLoan', + 'concierge', + 'usdcLoanEth', ], + eth2: ['ethWrappedStakingRewards', 'eth2SellSend', 'ethStakingRewards', 'wrapEth', 'wrapEthTwo'], + 'stacks of coins': ['ethWrappedStakingRewards', 'ethStakingRewards'], + '➡️': ['eth2SellSend', 'wrapEth', 'wrapEthTwo'], + wrapped: ['ethStakeOrWrap', 'ethStakeOrWrapTwo'], + lfg: ['ethStakeOrWrap', 'ethStakeOrWrapTwo'], + sending: ['cbEth'], minus: ['cbEth'], - bridge: ['bridging'], - 'one to another': ['bridging'], - tokens: ['bridging'], - '🌁': ['bridging'], - '🌉': ['bridging'], + forward: ['ethStakingMovement', 'eth2SendSell', 'eth2SendSellTwo', 'instoEthStakingMovement'], + exciting: ['ethStakingMovement', 'eth2SendSell', 'eth2SendSellTwo', 'instoEthStakingMovement'], + '🟣': ['ethStakingMovement', 'instoEthStakingMovement'], + '🟢': ['ethStakingMovement', 'instoEthStakingMovement'], + '🔵': ['ethStakingMovement', 'instoEthStakingMovement'], + Light: ['earnToLearn'], + Bulb: ['earnToLearn'], + Learn: ['earnToLearn'], + Make: ['earnToLearn'], + world: ['secureGlobalTransactions', 'globalTransactions'], + 'peer to peer': ['secureGlobalTransactions'], + i18n: ['globalTransactions'], + referral: ['referralsBitcoin', 'freeBtc', 'referralsCoinbaseOne', 'referralsGenericCoin'], + magic: ['referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + heads: ['referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + profile: ['referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + pic: ['referralsBitcoin', 'referralsCoinbaseOne', 'referralsGenericCoin'], + Bitcoin: ['referralsBitcoin', 'bigBtc', 'lightningNetworkSend'], + BTC: ['referralsBitcoin', 'freeBtc', 'bigBtc'], peer: ['p2pPayments'], P2P: ['p2pPayments'], - investments: ['portfolioOverviewRelaunch', 'portfolioOverview'], - stocks: ['portfolioOverviewRelaunch', 'portfolioOverview'], - cash: ['portfolioOverviewRelaunch', 'portfolioOverview', 'highFees'], - management: ['portfolioOverviewRelaunch', 'portfolioOverview'], - dashboard: ['portfolioOverviewRelaunch', 'portfolioOverview', 'ratDashboard'], - summary: ['portfolioOverviewRelaunch', 'portfolioOverview'], - '💼': ['portfolioOverviewRelaunch', 'portfolioOverview'], - '🧐': ['portfolioOverviewRelaunch', 'portfolioOverview'], - '🤑': ['portfolioOverviewRelaunch', 'portfolioOverview'], - '🔎': ['portfolioOverviewRelaunch', 'portfolioOverview'], - '🔍': ['portfolioOverviewRelaunch', 'portfolioOverview'], - '👀': ['portfolioOverviewRelaunch', 'portfolioOverview'], - recommendation: ['referralsBonus'], - human: ['referralsBonus'], - invitation: ['referralsBonus'], - invite: ['referralsBonus'], - request: ['referralsBonus'], - '💌': ['referralsBonus'], - '✉️': ['referralsBonus'], - '📨': ['referralsBonus'], - '📩': ['referralsBonus'], - '📧': ['referralsBonus'], - '🎁': ['referralsBonus'], - '📇': ['referralsBonus'], - wrapped: ['ethStakeOrWrap', 'ethStakeOrWrapTwo'], - lfg: ['ethStakeOrWrap', 'ethStakeOrWrapTwo'], - Hold: ['holdingCrypto'], - HODL: ['holdingCrypto'], - Concierge: ['concierge'], wrap: ['eth2SendSell', 'eth2SendSellTwo'], rush: ['eth2SendSell', 'eth2SendSellTwo'], - forward: ['eth2SendSell', 'eth2SendSellTwo', 'ethStakingMovement'], - exciting: ['eth2SendSell', 'eth2SendSellTwo', 'ethStakingMovement'], - pending: ['cardWaitlist'], - delay: ['cardWaitlist'], - '📋': ['cardWaitlist'], - connection: ['walletReconnectSuccess', 'walletReconnect', 'decentralization'], - gem: ['diamond'], - hand: ['diamond'], - holding: ['diamond'], - '💍': ['diamond'], - '👋': ['diamond'], - '✋': ['diamond'], - '🤌': ['diamond'], - keep: ['secureStorage'], - '👇': ['trade'], - '⬇️': ['trade'], - '🔻': ['trade'], - '👆': ['trade', 'highFees'], - '☝️': ['trade', 'highFees'], - '🆙': ['trade', 'highFees'], - '⬆️': ['trade', 'highFees'], - '🔝': ['trade', 'highFees'], - '🔼': ['trade', 'highFees'], - '🔺': ['trade', 'highFees'], - interaction: ['primeTradePreferences'], - settings: ['primeTradePreferences'], - gear: ['primeTradePreferences'], - preferences: ['primeTradePreferences'], - 'Empty State': ['ratFoundWallet'], - 'ASAP Ratty': ['ratFoundWallet'], - 'Rat found wallet': ['ratFoundWallet'], - Rat: ['ratFoundWallet'], - rejection: ['ledgerSignatureRejected'], - decline: ['ledgerSignatureRejected'], - '❌': ['ledgerSignatureRejected'], - trophy: ['congratulationsOnEarningCrypto'], - win: ['congratulationsOnEarningCrypto'], - folder: ['cryptoPortfolio'], - social: ['shareOnSocialMedia'], - media: ['shareOnSocialMedia'], - circles: ['shareOnSocialMedia', 'ethStakingMovement'], - video: ['watchVideos'], - eye: ['watchVideos'], - window: ['watchVideos'], - button: ['watchVideos'], - Trust: ['secureAndTrusted'], - shield: ['secureAndTrusted'], - USDC: ['retailUSDCRewards'], - 'stacks of coins': ['ethStakingRewards', 'ethWrappedStakingRewards'], tutorial: ['connectWalletTutorial'], attach: ['connectWalletTutorial'], '👛': ['connectWalletTutorial'], '👝': ['connectWalletTutorial'], '👜': ['connectWalletTutorial'], '🖇️': ['connectWalletTutorial'], - padlock: ['securityShield'], - backed: ['backedByUsDollar'], - dollars: ['backedByUsDollar'], - US: ['backedByUsDollar'], + verify: ['verifyEmail', 'verifyInfo'], + email: ['verifyEmail', 'openEmail'], + envelope: ['verifyEmail', 'openEmail'], + nux: ['verifyEmail'], + onboarding: ['verifyEmail', 'securityShield', 'addPhoneNumber', 'faceId'], + balloon: ['readyToTrade'], + welcome: ['readyToTrade'], + created: ['readyToTrade'], + ghost: ['cryptoApps', 'exploreDecentralizedApps'], + unicorn: ['cryptoApps'], + piggy: ['coinbaseOneSavingFunds', 'fiatInterest'], + saving: ['coinbaseOneSavingFunds', 'fiatInterest'], + '🐖': ['coinbaseOneSavingFunds', 'fiatInterest'], + contacts: ['contactsListWarning'], + '⚠': ['contactsListWarning'], + commerce: ['commerceInvoices', 'commerceAccounting'], + invoices: ['commerceInvoices'], + '📝': ['commerceInvoices', 'commerceAccounting'], + estimated: ['estimatedAmount'], + amount: ['estimatedAmount', 'coinbaseOneDiscountedAmount'], + calculation: ['estimatedAmount'], + asterisk: ['phoneNumber'], + Hold: ['holdingCrypto'], + HODL: ['holdingCrypto'], + defi: ['defiHow', 'defiEarn'], + how: ['defiHow'], + hodl: ['holdCrypto', 'freeBtc'], basket: ['holdCrypto'], bowl: ['holdCrypto'], + ui: ['switchAdvancedToSimpleTrading'], + change: ['switchAdvancedToSimpleTrading'], + moon: ['cryptoAndMore', 'instoCryptoAndMore'], + immediately: ['tradeImmediately'], + now: ['tradeImmediately'], + Documents: ['documentSuccess'], + reviewed: ['documentSuccess', 'documentCertified'], + confirm: ['documentSuccess', 'coinbaseCardLock', 'coinbaseCardPocket'], + percentage: ['defiEarn', 'earnInterest'], + barchart: ['earnInterest'], + padlock: ['securityShield'], + plastic: ['coinbaseCardLock', 'coinbaseCardPocket'], + payment: ['coinbaseCardLock', 'coinbaseCardPocket'], + method: ['coinbaseCardLock', 'coinbaseCardPocket'], + open: ['openEmail'], + letter: ['openEmail'], + '📧 📥 📤 ✉ 📩 📨': ['openEmail'], + UI: ['advancedTradingUi'], + candlestick: ['advancedTradingUi'], + depth: ['advancedTradingUi'], + rat: ['advancedTrading'], image: ['exploreDecentralizedApps'], magical: ['exploreDecentralizedApps'], '👻': ['exploreDecentralizedApps'], - i18n: ['globalTransactions'], - estimated: ['estimatedAmount'], - calculation: ['estimatedAmount'], - '🟣': ['ethStakingMovement'], - '🟢': ['ethStakingMovement'], - '🔵': ['ethStakingMovement'], - analyze: ['ratDashboard'], - breakdown: ['ratDashboard'], - '🐀': ['ratDashboard'], - '💻': ['ratDashboard'], - '🖥': ['ratDashboard'], + certified: ['documentCertified'], + correct: ['documentCertified'], + ribbon: ['documentCertified'], + confirmed: ['documentCertified', 'onTheList'], + stamped: ['documentCertified'], + papers: ['documentCertified'], + recurring: ['automaticPayments'], + automatic: ['automaticPayments'], + pay: ['automaticPayments'], + loan: ['automaticPayments', 'usdcLoan', 'borrowLoan', 'usdcLoanEth'], + once: ['automaticPayments'], + month: ['automaticPayments'], + clipboard: ['onTheList', 'verifyInfo'], + on: ['onTheList'], + notify: ['onTheList'], + paper: ['onTheList', 'uploadDocument'], + coinbaseone: ['coinbaseOneDiscountedAmount'], + discounted: ['coinbaseOneDiscountedAmount'], + focus: ['focusLimitOrders'], + limit: ['focusLimitOrders'], + limitorders: ['focusLimitOrders'], + advancedtrading: ['focusLimitOrders'], + free: ['freeBtc'], + bitcoin: ['freeBtc', 'cbbtc'], + paid: ['freeBtc'], + join: ['freeBtc'], + refer: ['freeBtc'], + accounting: ['commerceAccounting'], + '⬇': ['commerceAccounting'], + info: ['verifyInfo'], + information: ['verifyInfo'], + error: ['verifyInfo'], + issue: ['verifyInfo'], + concern: ['verifyInfo'], + '⚠️': ['verifyInfo'], + notification: ['notificationsAlt'], + bell: ['notificationsAlt'], + '🔔': ['notificationsAlt'], + '🔕': ['notificationsAlt'], + deFi: ['defiRisk'], + banner: ['defiRisk'], + percent: ['defiRisk'], + sign: ['defiRisk'], + tracking: ['appTrackingTransparency'], + transparency: ['appTrackingTransparency'], + notifications: ['walletNotifications'], + green: ['walletNotifications', 'liquidationBufferGreen'], + upload: ['uploadDocument'], + proof: ['uploadDocument'], + mailing: ['uploadDocument'], + 'letter papers': ['uploadDocument'], + Scan: ['scanCode'], + QR: ['scanCode'], + Code: ['scanCode'], + CB1: ['referralsCoinbaseOne', 'concierge'], + Coinbase: ['referralsCoinbaseOne'], + One: ['referralsCoinbaseOne'], Lighting: ['lightningNetworkSend'], Lightingnetwork: ['lightningNetworkSend'], speed: ['lightningNetworkSend'], lightingbolt: ['lightningNetworkSend'], '⚡': ['lightningNetworkSend'], + face: ['faceId'], + photo: ['faceId'], + camera: ['faceId'], + cbbtc: ['cbbtc'], + conversion: ['cbbtc'], + convert: ['cbbtc'], + usdc: ['usdcLoan', 'usdcLoanEth'], + portal: ['usdcLoan', 'usdcLoanEth'], + leading: ['leadingProtocol'], + protocol: ['leadingProtocol'], + liquidation: ['liquidationBufferGreen', 'liquidationBufferRed', 'liquidationBufferYellow'], + buffer: ['liquidationBufferGreen', 'liquidationBufferRed', 'liquidationBufferYellow'], + gauge: ['liquidationBufferGreen', 'liquidationBufferRed', 'liquidationBufferYellow'], + threshold: ['liquidationBufferGreen', 'liquidationBufferRed', 'liquidationBufferYellow'], + derivatives: ['liquidationBufferGreen', 'liquidationBufferRed', 'liquidationBufferYellow'], + red: ['liquidationBufferRed'], + Concierge: ['concierge'], + insto: [ + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + negroni: [ + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + orange: [ + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + institutional: [ + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], + 'institutional investor': [ + 'insto', + 'instoPrimeStaking', + 'instoStaking', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoCurrency', + 'instoSemiCustodial', + 'instoCryptoAndMore', + 'instoEmptyTrading', + ], }; export default descriptionMap; diff --git a/packages/illustrations/src/__generated__/spotRectangle/data/names.ts b/packages/illustrations/src/__generated__/spotRectangle/data/names.ts index 51b72ebe61..f369b66f1a 100644 --- a/packages/illustrations/src/__generated__/spotRectangle/data/names.ts +++ b/packages/illustrations/src/__generated__/spotRectangle/data/names.ts @@ -120,6 +120,26 @@ const names: SpotRectangleName[] = [ 'highFees', 'holdCrypto', 'holdingCrypto', + 'insto', + 'instoAboutOnchain', + 'instoApiKey', + 'instoConsensusWaitingForApprovals', + 'instoCryptoAndMore', + 'instoCurrency', + 'instoDesignateSigner', + 'instoEmptyTrading', + 'instoEthStakingMovement', + 'instoGetStartedInMinutes', + 'instoKey', + 'instoMargin', + 'instoOnchainSetupInProgress', + 'instoPrimeStaking', + 'instoQRCode', + 'instoRefreshKey', + 'instoSemiCustodial', + 'instoSetupComplete', + 'instoSetupOnchain', + 'instoStaking', 'insuranceProtection', 'invest', 'layeredNetworks', @@ -189,6 +209,7 @@ const names: SpotRectangleName[] = [ 'sidechain', 'stableValue', 'staking', + 'stakingUpgrade', 'startToday', 'stayInControlSelfHostedWalletsStorage', 'stressTestedColdStorage', diff --git a/packages/illustrations/src/__generated__/spotRectangle/data/svgJsMap.ts b/packages/illustrations/src/__generated__/spotRectangle/data/svgJsMap.ts index 522edc3ca0..81ee767e11 100644 --- a/packages/illustrations/src/__generated__/spotRectangle/data/svgJsMap.ts +++ b/packages/illustrations/src/__generated__/spotRectangle/data/svgJsMap.ts @@ -446,6 +446,86 @@ const svgJsMap = { light: () => require('../svgJs/light/holdingCrypto-3.js').content, dark: () => require('../svgJs/dark/holdingCrypto-3.js').content, }, + insto: { + light: () => require('../svgJs/light/insto-0.js').content, + dark: () => require('../svgJs/dark/insto-0.js').content, + }, + instoAboutOnchain: { + light: () => require('../svgJs/light/instoAboutOnchain-0.js').content, + dark: () => require('../svgJs/dark/instoAboutOnchain-0.js').content, + }, + instoApiKey: { + light: () => require('../svgJs/light/instoApiKey-1.js').content, + dark: () => require('../svgJs/dark/instoApiKey-1.js').content, + }, + instoConsensusWaitingForApprovals: { + light: () => require('../svgJs/light/instoConsensusWaitingForApprovals-0.js').content, + dark: () => require('../svgJs/dark/instoConsensusWaitingForApprovals-0.js').content, + }, + instoCryptoAndMore: { + light: () => require('../svgJs/light/instoCryptoAndMore-2.js').content, + dark: () => require('../svgJs/dark/instoCryptoAndMore-2.js').content, + }, + instoCurrency: { + light: () => require('../svgJs/light/instoCurrency-0.js').content, + dark: () => require('../svgJs/dark/instoCurrency-0.js').content, + }, + instoDesignateSigner: { + light: () => require('../svgJs/light/instoDesignateSigner-0.js').content, + dark: () => require('../svgJs/dark/instoDesignateSigner-0.js').content, + }, + instoEmptyTrading: { + light: () => require('../svgJs/light/instoEmptyTrading-1.js').content, + dark: () => require('../svgJs/dark/instoEmptyTrading-1.js').content, + }, + instoEthStakingMovement: { + light: () => require('../svgJs/light/instoEthStakingMovement-1.js').content, + dark: () => require('../svgJs/dark/instoEthStakingMovement-1.js').content, + }, + instoGetStartedInMinutes: { + light: () => require('../svgJs/light/instoGetStartedInMinutes-0.js').content, + dark: () => require('../svgJs/dark/instoGetStartedInMinutes-0.js').content, + }, + instoKey: { + light: () => require('../svgJs/light/instoKey-0.js').content, + dark: () => require('../svgJs/dark/instoKey-0.js').content, + }, + instoMargin: { + light: () => require('../svgJs/light/instoMargin-0.js').content, + dark: () => require('../svgJs/dark/instoMargin-0.js').content, + }, + instoOnchainSetupInProgress: { + light: () => require('../svgJs/light/instoOnchainSetupInProgress-0.js').content, + dark: () => require('../svgJs/dark/instoOnchainSetupInProgress-0.js').content, + }, + instoPrimeStaking: { + light: () => require('../svgJs/light/instoPrimeStaking-0.js').content, + dark: () => require('../svgJs/dark/instoPrimeStaking-0.js').content, + }, + instoQRCode: { + light: () => require('../svgJs/light/instoQRCode-0.js').content, + dark: () => require('../svgJs/dark/instoQRCode-0.js').content, + }, + instoRefreshKey: { + light: () => require('../svgJs/light/instoRefreshKey-0.js').content, + dark: () => require('../svgJs/dark/instoRefreshKey-0.js').content, + }, + instoSemiCustodial: { + light: () => require('../svgJs/light/instoSemiCustodial-0.js').content, + dark: () => require('../svgJs/dark/instoSemiCustodial-0.js').content, + }, + instoSetupComplete: { + light: () => require('../svgJs/light/instoSetupComplete-0.js').content, + dark: () => require('../svgJs/dark/instoSetupComplete-0.js').content, + }, + instoSetupOnchain: { + light: () => require('../svgJs/light/instoSetupOnchain-0.js').content, + dark: () => require('../svgJs/dark/instoSetupOnchain-0.js').content, + }, + instoStaking: { + light: () => require('../svgJs/light/instoStaking-0.js').content, + dark: () => require('../svgJs/dark/instoStaking-0.js').content, + }, insuranceProtection: { light: () => require('../svgJs/light/insuranceProtection-5.js').content, dark: () => require('../svgJs/dark/insuranceProtection-5.js').content, @@ -722,6 +802,10 @@ const svgJsMap = { light: () => require('../svgJs/light/staking-5.js').content, dark: () => require('../svgJs/dark/staking-5.js').content, }, + stakingUpgrade: { + light: () => require('../svgJs/light/stakingUpgrade-0.js').content, + dark: () => require('../svgJs/dark/stakingUpgrade-0.js').content, + }, startToday: { light: () => require('../svgJs/light/startToday-4.js').content, dark: () => require('../svgJs/dark/startToday-4.js').content, diff --git a/packages/illustrations/src/__generated__/spotRectangle/data/versionMap.ts b/packages/illustrations/src/__generated__/spotRectangle/data/versionMap.ts index 36eee940cf..9497a6ae05 100644 --- a/packages/illustrations/src/__generated__/spotRectangle/data/versionMap.ts +++ b/packages/illustrations/src/__generated__/spotRectangle/data/versionMap.ts @@ -224,6 +224,27 @@ const versionMap: Record = { calendar: 0, graphChartTrading: 0, usdcLoanEth: 0, + instoEmptyTrading: 1, + instoSemiCustodial: 0, + instoCryptoAndMore: 2, + instoPrimeStaking: 0, + stakingUpgrade: 0, + instoEthStakingMovement: 1, + instoGetStartedInMinutes: 0, + insto: 0, + instoStaking: 0, + instoCurrency: 0, + instoQRCode: 0, + instoSetupOnchain: 0, + instoAboutOnchain: 0, + instoMargin: 0, + instoSetupComplete: 0, + instoApiKey: 1, + instoRefreshKey: 0, + instoConsensusWaitingForApprovals: 0, + instoKey: 0, + instoDesignateSigner: 0, + instoOnchainSetupInProgress: 0, }; export default versionMap; diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/insto-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/insto-0.png new file mode 100644 index 0000000000..65121035bc Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/insto-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoAboutOnchain-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoAboutOnchain-0.png new file mode 100644 index 0000000000..e379167b08 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoAboutOnchain-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoApiKey-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoApiKey-1.png new file mode 100644 index 0000000000..14a0d7cf96 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoApiKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoConsensusWaitingForApprovals-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoConsensusWaitingForApprovals-0.png new file mode 100644 index 0000000000..4e98077689 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoConsensusWaitingForApprovals-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCryptoAndMore-2.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCryptoAndMore-2.png new file mode 100644 index 0000000000..a2c78f6bae Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCryptoAndMore-2.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCurrency-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCurrency-0.png new file mode 100644 index 0000000000..e6e3cc37e1 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoCurrency-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoDesignateSigner-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoDesignateSigner-0.png new file mode 100644 index 0000000000..25af9b726f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoDesignateSigner-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEmptyTrading-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEmptyTrading-1.png new file mode 100644 index 0000000000..2d3b537461 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEmptyTrading-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEthStakingMovement-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEthStakingMovement-1.png new file mode 100644 index 0000000000..0b9ee59feb Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoEthStakingMovement-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoGetStartedInMinutes-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoGetStartedInMinutes-0.png new file mode 100644 index 0000000000..18dfd7ae32 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoGetStartedInMinutes-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoKey-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoKey-0.png new file mode 100644 index 0000000000..99d69c2adf Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoKey-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoMargin-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoMargin-0.png new file mode 100644 index 0000000000..411892a717 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoMargin-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoOnchainSetupInProgress-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoOnchainSetupInProgress-0.png new file mode 100644 index 0000000000..8f5d923f86 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoOnchainSetupInProgress-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoPrimeStaking-0.png new file mode 100644 index 0000000000..e51c58ecd9 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoQRCode-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoQRCode-0.png new file mode 100644 index 0000000000..279a868cba Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoQRCode-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoRefreshKey-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoRefreshKey-0.png new file mode 100644 index 0000000000..70b00aa15a Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoRefreshKey-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSemiCustodial-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSemiCustodial-0.png new file mode 100644 index 0000000000..78f5357cc0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSemiCustodial-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupComplete-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupComplete-0.png new file mode 100644 index 0000000000..cb0f2c83b2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupComplete-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupOnchain-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupOnchain-0.png new file mode 100644 index 0000000000..25de5cde41 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoSetupOnchain-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoStaking-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoStaking-0.png new file mode 100644 index 0000000000..4d316b9dd1 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/dark/stakingUpgrade-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/dark/stakingUpgrade-0.png new file mode 100644 index 0000000000..02bf440666 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/dark/stakingUpgrade-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/insto-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/insto-0.png new file mode 100644 index 0000000000..a6b5170443 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/insto-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoAboutOnchain-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoAboutOnchain-0.png new file mode 100644 index 0000000000..31252d80d0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoAboutOnchain-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoApiKey-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoApiKey-1.png new file mode 100644 index 0000000000..6e4d2f461b Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoApiKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoConsensusWaitingForApprovals-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoConsensusWaitingForApprovals-0.png new file mode 100644 index 0000000000..4db7106e41 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoConsensusWaitingForApprovals-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCryptoAndMore-2.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCryptoAndMore-2.png new file mode 100644 index 0000000000..9b7a20804b Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCryptoAndMore-2.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCurrency-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCurrency-0.png new file mode 100644 index 0000000000..a9c6e54af0 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoCurrency-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoDesignateSigner-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoDesignateSigner-0.png new file mode 100644 index 0000000000..3a7fb11775 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoDesignateSigner-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEmptyTrading-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEmptyTrading-1.png new file mode 100644 index 0000000000..5eac77f59a Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEmptyTrading-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEthStakingMovement-1.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEthStakingMovement-1.png new file mode 100644 index 0000000000..94057af629 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoEthStakingMovement-1.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoGetStartedInMinutes-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoGetStartedInMinutes-0.png new file mode 100644 index 0000000000..5c533ebf92 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoGetStartedInMinutes-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoKey-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoKey-0.png new file mode 100644 index 0000000000..f4ca33f081 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoKey-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoMargin-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoMargin-0.png new file mode 100644 index 0000000000..d5a1302f18 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoMargin-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoOnchainSetupInProgress-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoOnchainSetupInProgress-0.png new file mode 100644 index 0000000000..41d337c5ec Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoOnchainSetupInProgress-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoPrimeStaking-0.png new file mode 100644 index 0000000000..f90a07c2dd Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoQRCode-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoQRCode-0.png new file mode 100644 index 0000000000..04bc62ced1 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoQRCode-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoRefreshKey-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoRefreshKey-0.png new file mode 100644 index 0000000000..e2eb0b9168 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoRefreshKey-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSemiCustodial-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSemiCustodial-0.png new file mode 100644 index 0000000000..333d97b303 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSemiCustodial-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupComplete-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupComplete-0.png new file mode 100644 index 0000000000..a5e4292539 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupComplete-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupOnchain-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupOnchain-0.png new file mode 100644 index 0000000000..db61a372f2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoSetupOnchain-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/instoStaking-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoStaking-0.png new file mode 100644 index 0000000000..e4b0abd8ad Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/png/light/stakingUpgrade-0.png b/packages/illustrations/src/__generated__/spotRectangle/png/light/stakingUpgrade-0.png new file mode 100644 index 0000000000..8d983102e4 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotRectangle/png/light/stakingUpgrade-0.png differ diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/insto-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/insto-0.svg new file mode 100644 index 0000000000..142784f086 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/insto-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoAboutOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoAboutOnchain-0.svg new file mode 100644 index 0000000000..3db5accff0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoAboutOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoApiKey-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoApiKey-1.svg new file mode 100644 index 0000000000..2d059c14a9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoApiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoConsensusWaitingForApprovals-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoConsensusWaitingForApprovals-0.svg new file mode 100644 index 0000000000..7cd2069a04 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoConsensusWaitingForApprovals-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCryptoAndMore-2.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCryptoAndMore-2.svg new file mode 100644 index 0000000000..80eea5135e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCryptoAndMore-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCurrency-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCurrency-0.svg new file mode 100644 index 0000000000..b8dc77e1a7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoCurrency-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoDesignateSigner-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoDesignateSigner-0.svg new file mode 100644 index 0000000000..04cf637c6c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoDesignateSigner-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEmptyTrading-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEmptyTrading-1.svg new file mode 100644 index 0000000000..cfd6dc3aef --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEmptyTrading-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEthStakingMovement-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEthStakingMovement-1.svg new file mode 100644 index 0000000000..4ab26635b7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoEthStakingMovement-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoGetStartedInMinutes-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoGetStartedInMinutes-0.svg new file mode 100644 index 0000000000..5738d46025 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoGetStartedInMinutes-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoKey-0.svg new file mode 100644 index 0000000000..3c74037ef6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoMargin-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoMargin-0.svg new file mode 100644 index 0000000000..f9d987769e --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoMargin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoOnchainSetupInProgress-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoOnchainSetupInProgress-0.svg new file mode 100644 index 0000000000..c173d5ed02 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoOnchainSetupInProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..d9b2bcbda6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoQRCode-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoQRCode-0.svg new file mode 100644 index 0000000000..4fd485b2c0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoQRCode-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoRefreshKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoRefreshKey-0.svg new file mode 100644 index 0000000000..597e1f0f46 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoRefreshKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSemiCustodial-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSemiCustodial-0.svg new file mode 100644 index 0000000000..4937dfe754 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSemiCustodial-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupComplete-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupComplete-0.svg new file mode 100644 index 0000000000..da1c1f79fb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupComplete-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupOnchain-0.svg new file mode 100644 index 0000000000..d88156cf38 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoSetupOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoStaking-0.svg new file mode 100644 index 0000000000..3ec664c119 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/dark/stakingUpgrade-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/stakingUpgrade-0.svg new file mode 100644 index 0000000000..8d4fffe1f3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/dark/stakingUpgrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/insto-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/insto-0.svg new file mode 100644 index 0000000000..6c3a207f40 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/insto-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoAboutOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoAboutOnchain-0.svg new file mode 100644 index 0000000000..52e06d9133 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoAboutOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoApiKey-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoApiKey-1.svg new file mode 100644 index 0000000000..4d2fb9ca86 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoApiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoConsensusWaitingForApprovals-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoConsensusWaitingForApprovals-0.svg new file mode 100644 index 0000000000..ff85faae23 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoConsensusWaitingForApprovals-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCryptoAndMore-2.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCryptoAndMore-2.svg new file mode 100644 index 0000000000..a1159d9be7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCryptoAndMore-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCurrency-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCurrency-0.svg new file mode 100644 index 0000000000..012f6ac86b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoCurrency-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoDesignateSigner-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoDesignateSigner-0.svg new file mode 100644 index 0000000000..99aeb4a5c1 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoDesignateSigner-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEmptyTrading-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEmptyTrading-1.svg new file mode 100644 index 0000000000..eb393a4620 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEmptyTrading-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEthStakingMovement-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEthStakingMovement-1.svg new file mode 100644 index 0000000000..56a9e38f7f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoEthStakingMovement-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoGetStartedInMinutes-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoGetStartedInMinutes-0.svg new file mode 100644 index 0000000000..9e2a9a1545 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoGetStartedInMinutes-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoKey-0.svg new file mode 100644 index 0000000000..6ca0323709 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoMargin-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoMargin-0.svg new file mode 100644 index 0000000000..70a5c3c580 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoMargin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoOnchainSetupInProgress-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoOnchainSetupInProgress-0.svg new file mode 100644 index 0000000000..9f0ed114f3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoOnchainSetupInProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..e5e91e4d54 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoQRCode-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoQRCode-0.svg new file mode 100644 index 0000000000..e0beb095cd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoQRCode-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoRefreshKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoRefreshKey-0.svg new file mode 100644 index 0000000000..f959774977 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoRefreshKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSemiCustodial-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSemiCustodial-0.svg new file mode 100644 index 0000000000..6bb39edf25 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSemiCustodial-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupComplete-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupComplete-0.svg new file mode 100644 index 0000000000..680b2fcf24 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupComplete-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupOnchain-0.svg new file mode 100644 index 0000000000..fb333dc92a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoSetupOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoStaking-0.svg new file mode 100644 index 0000000000..62adae9aa4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/light/stakingUpgrade-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/light/stakingUpgrade-0.svg new file mode 100644 index 0000000000..c134a2c038 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/light/stakingUpgrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/insto-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/insto-0.svg new file mode 100644 index 0000000000..759ff9bfe4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/insto-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoAboutOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoAboutOnchain-0.svg new file mode 100644 index 0000000000..f067bb379a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoAboutOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoApiKey-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoApiKey-1.svg new file mode 100644 index 0000000000..4f0113c729 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoApiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoConsensusWaitingForApprovals-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoConsensusWaitingForApprovals-0.svg new file mode 100644 index 0000000000..f271213b39 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoConsensusWaitingForApprovals-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCryptoAndMore-2.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCryptoAndMore-2.svg new file mode 100644 index 0000000000..f941e00b7b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCryptoAndMore-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCurrency-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCurrency-0.svg new file mode 100644 index 0000000000..980297bc15 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoCurrency-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoDesignateSigner-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoDesignateSigner-0.svg new file mode 100644 index 0000000000..88e6e3602b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoDesignateSigner-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEmptyTrading-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEmptyTrading-1.svg new file mode 100644 index 0000000000..cff28cf713 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEmptyTrading-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEthStakingMovement-1.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEthStakingMovement-1.svg new file mode 100644 index 0000000000..2064024d59 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoEthStakingMovement-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoGetStartedInMinutes-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoGetStartedInMinutes-0.svg new file mode 100644 index 0000000000..248a1972c3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoGetStartedInMinutes-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoKey-0.svg new file mode 100644 index 0000000000..c9578e17cf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoMargin-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoMargin-0.svg new file mode 100644 index 0000000000..29fe79f3fb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoMargin-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoOnchainSetupInProgress-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoOnchainSetupInProgress-0.svg new file mode 100644 index 0000000000..56151bf9ff --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoOnchainSetupInProgress-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..4b8f420bec --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoQRCode-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoQRCode-0.svg new file mode 100644 index 0000000000..b4306d3acf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoQRCode-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoRefreshKey-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoRefreshKey-0.svg new file mode 100644 index 0000000000..17f07ca203 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoRefreshKey-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSemiCustodial-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSemiCustodial-0.svg new file mode 100644 index 0000000000..c6ddf5d702 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSemiCustodial-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupComplete-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupComplete-0.svg new file mode 100644 index 0000000000..06093936c9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupComplete-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupOnchain-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupOnchain-0.svg new file mode 100644 index 0000000000..3ac663d117 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoSetupOnchain-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoStaking-0.svg new file mode 100644 index 0000000000..84b3d1a225 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/stakingUpgrade-0.svg b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/stakingUpgrade-0.svg new file mode 100644 index 0000000000..23e70f77bc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svg/themeable/stakingUpgrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/insto-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/insto-0.js new file mode 100644 index 0000000000..9429241de4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/insto-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoAboutOnchain-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoAboutOnchain-0.js new file mode 100644 index 0000000000..468883cd6f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoAboutOnchain-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoApiKey-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoApiKey-1.js new file mode 100644 index 0000000000..077c92a267 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoApiKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoConsensusWaitingForApprovals-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoConsensusWaitingForApprovals-0.js new file mode 100644 index 0000000000..14b54b0a5d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoConsensusWaitingForApprovals-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCryptoAndMore-2.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCryptoAndMore-2.js new file mode 100644 index 0000000000..288b6b55f4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCryptoAndMore-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCurrency-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCurrency-0.js new file mode 100644 index 0000000000..b2cf7e3c4f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoCurrency-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoDesignateSigner-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoDesignateSigner-0.js new file mode 100644 index 0000000000..3b0b5953d7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoDesignateSigner-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEmptyTrading-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEmptyTrading-1.js new file mode 100644 index 0000000000..356796d531 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEmptyTrading-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEthStakingMovement-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEthStakingMovement-1.js new file mode 100644 index 0000000000..7723191238 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoEthStakingMovement-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoGetStartedInMinutes-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoGetStartedInMinutes-0.js new file mode 100644 index 0000000000..1dfaccb317 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoGetStartedInMinutes-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoKey-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoKey-0.js new file mode 100644 index 0000000000..3b3dbf507a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoKey-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoMargin-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoMargin-0.js new file mode 100644 index 0000000000..b08ccc1049 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoMargin-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoOnchainSetupInProgress-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoOnchainSetupInProgress-0.js new file mode 100644 index 0000000000..bdeddc4dee --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoOnchainSetupInProgress-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoPrimeStaking-0.js new file mode 100644 index 0000000000..24bbb5400f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoQRCode-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoQRCode-0.js new file mode 100644 index 0000000000..80017bb4c6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoQRCode-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoRefreshKey-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoRefreshKey-0.js new file mode 100644 index 0000000000..277b3f4004 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoRefreshKey-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSemiCustodial-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSemiCustodial-0.js new file mode 100644 index 0000000000..6043120b83 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSemiCustodial-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupComplete-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupComplete-0.js new file mode 100644 index 0000000000..a3c3ead66b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupComplete-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupOnchain-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupOnchain-0.js new file mode 100644 index 0000000000..2f05c1ef50 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoSetupOnchain-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoStaking-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoStaking-0.js new file mode 100644 index 0000000000..41e740f55b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/stakingUpgrade-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/stakingUpgrade-0.js new file mode 100644 index 0000000000..bbfba67751 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/dark/stakingUpgrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/insto-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/insto-0.js new file mode 100644 index 0000000000..a69a49fd65 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/insto-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoAboutOnchain-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoAboutOnchain-0.js new file mode 100644 index 0000000000..0260489fcb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoAboutOnchain-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoApiKey-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoApiKey-1.js new file mode 100644 index 0000000000..d614ed6ada --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoApiKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoConsensusWaitingForApprovals-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoConsensusWaitingForApprovals-0.js new file mode 100644 index 0000000000..18233df3ac --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoConsensusWaitingForApprovals-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCryptoAndMore-2.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCryptoAndMore-2.js new file mode 100644 index 0000000000..7e5a3661d6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCryptoAndMore-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCurrency-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCurrency-0.js new file mode 100644 index 0000000000..f9b523c2a9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoCurrency-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoDesignateSigner-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoDesignateSigner-0.js new file mode 100644 index 0000000000..4f31ef4c23 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoDesignateSigner-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEmptyTrading-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEmptyTrading-1.js new file mode 100644 index 0000000000..1bba9febc7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEmptyTrading-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEthStakingMovement-1.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEthStakingMovement-1.js new file mode 100644 index 0000000000..02a5cc7714 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoEthStakingMovement-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoGetStartedInMinutes-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoGetStartedInMinutes-0.js new file mode 100644 index 0000000000..bc56e4977d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoGetStartedInMinutes-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoKey-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoKey-0.js new file mode 100644 index 0000000000..b76b5335df --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoKey-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoMargin-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoMargin-0.js new file mode 100644 index 0000000000..ece80e05a3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoMargin-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoOnchainSetupInProgress-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoOnchainSetupInProgress-0.js new file mode 100644 index 0000000000..5ccff87ee4 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoOnchainSetupInProgress-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoPrimeStaking-0.js new file mode 100644 index 0000000000..8f495d28bc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoQRCode-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoQRCode-0.js new file mode 100644 index 0000000000..3307c01db7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoQRCode-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoRefreshKey-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoRefreshKey-0.js new file mode 100644 index 0000000000..f61cc12fc1 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoRefreshKey-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSemiCustodial-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSemiCustodial-0.js new file mode 100644 index 0000000000..0143e3f844 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSemiCustodial-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupComplete-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupComplete-0.js new file mode 100644 index 0000000000..9ef80a36d0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupComplete-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupOnchain-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupOnchain-0.js new file mode 100644 index 0000000000..1527cd7110 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoSetupOnchain-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoStaking-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoStaking-0.js new file mode 100644 index 0000000000..a16dd7506c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/stakingUpgrade-0.js b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/stakingUpgrade-0.js new file mode 100644 index 0000000000..192eb0d409 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotRectangle/svgJs/light/stakingUpgrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotRectangle/types/SpotRectangleName.ts b/packages/illustrations/src/__generated__/spotRectangle/types/SpotRectangleName.ts index e805d7ae9e..9cbf73b4bf 100644 --- a/packages/illustrations/src/__generated__/spotRectangle/types/SpotRectangleName.ts +++ b/packages/illustrations/src/__generated__/spotRectangle/types/SpotRectangleName.ts @@ -114,6 +114,26 @@ export type SpotRectangleName = | 'highFees' | 'holdCrypto' | 'holdingCrypto' + | 'insto' + | 'instoAboutOnchain' + | 'instoApiKey' + | 'instoConsensusWaitingForApprovals' + | 'instoCryptoAndMore' + | 'instoCurrency' + | 'instoDesignateSigner' + | 'instoEmptyTrading' + | 'instoEthStakingMovement' + | 'instoGetStartedInMinutes' + | 'instoKey' + | 'instoMargin' + | 'instoOnchainSetupInProgress' + | 'instoPrimeStaking' + | 'instoQRCode' + | 'instoRefreshKey' + | 'instoSemiCustodial' + | 'instoSetupComplete' + | 'instoSetupOnchain' + | 'instoStaking' | 'insuranceProtection' | 'invest' | 'layeredNetworks' @@ -183,6 +203,7 @@ export type SpotRectangleName = | 'sidechain' | 'stableValue' | 'staking' + | 'stakingUpgrade' | 'startToday' | 'stayInControlSelfHostedWalletsStorage' | 'stressTestedColdStorage' diff --git a/packages/illustrations/src/__generated__/spotSquare/data/descriptionMap.ts b/packages/illustrations/src/__generated__/spotSquare/data/descriptionMap.ts index 2cadb286b6..bbae87037d 100644 --- a/packages/illustrations/src/__generated__/spotSquare/data/descriptionMap.ts +++ b/packages/illustrations/src/__generated__/spotSquare/data/descriptionMap.ts @@ -11,956 +11,904 @@ const descriptionMap: Record = { '1': ['layeredNetworks'], '2': ['layeredNetworks'], - '': [ - 'coinbaseUnlockOffers', - 'coinbaseOneBoostedCardCB1', - 'baseMintNftMedium', - 'checkVerifacation', - 'baseNetworkMedium', - 'baseRewardPodium', - 'baseRewardTrophyStars', - 'baseRewardSun', - 'baseCautionMedium', - 'baseEmptyMedium', - 'baseChartMedium', - 'predictionsMarkets', - 'baseConnectMedium', - 'baseRewardPlate', - 'baseSecurityMedium', - 'basePiechartMedium', - 'baseCheckTrophyMedium', - 'baseRewardClam', - 'options', - 'baseRewardChest', - 'bonusTwoPercent', - 'baseSendMedium', - 'basePeopleMedium', - 'baseSwitch', - 'transferringCrypto', - 'baseRewardTrophyEmblem', - 'baseLoadingMedium', + asset: [ + 'unsupportedAsset', + 'coinbaseOneStarToken', + 'coinbaseOneStaking', + 'ethStaking', + 'nuxPopularAssets', + 'nuxEarnYield', + 'starToken', + 'gifting', + 'bigBtc', + 'tradeImmediately', + 'instoEthStaking', + ], + star: [ + 'unsupportedAsset', + 'coinbaseOneStarToken', 'basedInUsa', - 'baseNftMedium', - 'baseIdMedium', - 'baseCoinCryptoMedium', - 'baseDecentralizationMedium', - 'baseCoinNetworkMedium', - 'coinbaseFees', - 'miniGift', - 'basePaycoinMedium', - 'baseTargetMedium', - 'saveTheDate', - 'bonusFivePercent', - 'baseLocationMedium', - 'coinbaseOneBoostedCard', - 'baseQuickBuy', - 'baseCreatorCoin', - 'cbEthWrappingUnavailable', - 'baseUsdcMedium', - 'baseCheckMedium', - 'baseErrorButterflyMedium', - 'baseErrorMedium', - 'baseDiamondMedium', + 'moneyRewards', + 'nuxPopularAssets', + 'priceAlerts', + 'sparkleToken', + 'starToken', + 'freeBtc', + 'walletQuestsTrophy', ], - CoinbaseOne: ['coinbaseOneBoostedCardCB1', 'coinbaseOneBoostedCard'], - CoinbaseOneCard: ['coinbaseOneBoostedCardCB1', 'coinbaseOneBoostedCard'], - card: [ - 'coinbaseOneBoostedCardCB1', - 'cardAutoReload', + token: [ + 'unsupportedAsset', + 'coinbaseOneStarToken', + 'collectingNfts', + 'coinbaseOneTokenRewards', + 'nuxPopularAssets', + 'starToken', + 'defiNfts', + 'gifting', + ], + crypto: [ + 'unsupportedAsset', + 'boostedCard', + 'hardwareWallets', + 'blockchain', + 'cryptoPortfolio', + 'congratulationsOnEarningCrypto', + 'coinbaseOneStarToken', + 'cryptoForBeginners', + 'decentralization', + 'insuranceProtection', + 'cryptoAssets', + 'earnInterestOnCryptocurrency', + 'coinbaseOneStaking', + 'cryptoEconomy', + 'ethStaking', + 'mining', + 'assetForward', + 'cardDeclined', + 'coinbaseCardSparkle', + 'directDepositExcitement', + 'fileYourCryptoTaxesOther', + 'guideBullCase', + 'guideCryptoBeginner', + 'guideFiveThings', + 'guideStartInvesting', + 'nuxPopularAssets', + 'yieldCenter', + 'nuxEarnCrypto', + 'cardAnnouncement', + 'futures', + 'starToken', + 'addCard', + 'bullishCase', 'cardBlocked', - 'coinbaseCardLock', - 'confirmIDCard', + 'fileYourCryptoTaxesCheckOther', + 'gifting', + 'guideNftDefi', + 'sendCryptoFaster', + 'switchReward', + 'holdingCrypto', + 'estimatedAmount', + 'holdCrypto', + 'noPortfolio', + 'coinFifty', + 'cryptoEconomyArrows', + 'instoEthStaking', + ], + cryptocurrency: [ + 'unsupportedAsset', + 'cryptoPortfolio', + 'coinbaseOneStarToken', + 'mining', + 'guideBullCase', + 'guideStartInvesting', + 'nuxPopularAssets', + 'futures', + 'starToken', + 'sendCryptoFaster', + 'holdingCrypto', + 'noPortfolio', + ], + currency: [ + 'unsupportedAsset', 'boostedCard', + 'coinbaseOneStarToken', + 'coinbaseOneStaking', + 'ethStaking', 'coinbaseCardSparkle', - 'automaticPayments', - 'cardShipped', - 'confirmSocialSecurity', - 'cardDeclined', + 'guideStartInvesting', + 'nuxPopularAssets', + 'walletApp', 'cardAnnouncement', + 'starToken', + 'borrowLimitsAddressed', + 'bullishCase', + 'holdingCrypto', + 'holdCrypto', + 'tradeImmediately', + 'instoEthStaking', + ], + unsupported: ['unsupportedAsset'], + Referrals: ['referralsPeople'], + Friends: ['referralsPeople'], + Bitcoin: ['referralsPeople', 'bigBtc', 'lightningNetworkSend'], + BTC: ['referralsPeople', 'bigBtc', 'freeBtc'], + Reward: ['referralsPeople'], + Crypto: [ + 'referralsPeople', + 'primeEarn', + 'primeStaking', + 'primeDeFi', + 'bigBtc', + 'instoPrimeStaking', + ], + Coins: [ + 'referralsPeople', + 'primeEarn', + 'primeStaking', + 'primeDeFi', + 'bigBtc', + 'instoPrimeStaking', + ], + Sign: ['referralsPeople'], + Up: ['referralsPeople', 'gainsAndLosses', 'earnToLearn'], + Share: ['referralsPeople'], + Link: ['referralsPeople'], + Refer: ['referralsPeople'], + Friend: ['referralsPeople'], + 'empty frame art nft ⚠️': ['frameEmpty'], + 'warning state': ['frameEmpty', 'offersEmpty', 'contactsListWarning', 'verifyInfo', 'refresh'], + bridge: ['bridging'], + blockchain: ['bridging', 'blockchain'], + send: [ + 'bridging', + 'secureGlobalTransactions', + 'p2pPayments', + 'crossBorderPayments', + 'globalTransactions', + 'eth2SendSell', 'gifting', - 'addCard', - 'coinbaseOneBoostedCard', - 'coinbaseCardPocket', + 'sendCryptoFaster', + 'swapEth', + 'lightningNetworkSend', ], + 'one to another': ['bridging'], + tokens: ['bridging', 'recommendInvestments'], + '🌁': ['bridging'], + '🌉': ['bridging'], coinbase: [ - 'coinbaseOneBoostedCardCB1', - 'coinbaseOneZeroPortal', - 'browserExtension', - 'cardBlocked', - 'linkCoinbaseWallet', 'boostedCard', + 'coinbaseOneLogo', + 'linkCoinbaseWallet', + 'browserExtension', + 'cardDeclined', 'coinbaseCardSparkle', 'walletApp', - 'cardDeclined', 'cardAnnouncement', 'addCard', - 'coinbaseOneLogo', - 'coinbaseOneZero', + 'cardBlocked', 'coinbaseOneUSDC', 'coinbaseOneBoostedCard', - ], - One: [ + 'coinbaseOneZeroPortal', + 'coinbaseOneZero', 'coinbaseOneBoostedCardCB1', - 'coinbaseOneConcierge', - 'coinbaseOneUSDC', - 'coinbaseOneBoostedCard', ], - cb1: [ - 'coinbaseOneBoostedCardCB1', - 'coinbaseOneLogo', - 'coinbaseOneUSDC', + card: [ + 'boostedCard', + 'cardDeclined', + 'cardShipped', + 'coinbaseCardSparkle', + 'confirmIDCard', + 'confirmSocialSecurity', + 'cardAnnouncement', + 'addCard', + 'cardAutoReload', + 'cardBlocked', + 'gifting', + 'automaticPayments', + 'coinbaseCardPocket', + 'coinbaseCardLock', 'coinbaseOneBoostedCard', + 'coinbaseOneBoostedCardCB1', + ], + spend: [ + 'boostedCard', + 'cardDeclined', + 'coinbaseCardSparkle', + 'cardAnnouncement', + 'addCard', + 'cardBlocked', ], coin: [ - 'coinbaseOneBoostedCardCB1', - 'cardAutoReload', - 'cryptoForBeginners', - 'estimatedAmount', - 'switchReward', - 'cryptoAndMore', - 'guideCryptoBeginner', + 'boostedCard', + 'cryptoPortfolio', + 'congratulationsOnEarningCrypto', + 'secureAndTrusted', 'semiCustodial', - 'trendingHotAssets', - 'walletQuestsTrophy', - 'giftBoxCrypto', + 'cryptoForBeginners', + 'pixDeposits', + 'secureStorage', 'pixBankDeposits', - 'noPortfolio', - 'boostedCard', - 'assetRefresh', - 'tradeImmediately', + 'trendingHotAssets', + 'addMultipleCrypto', + 'assetForward', 'coinbaseCardSparkle', - 'freeBtc', - 'futures', - 'readyToTrade', + 'guideCryptoBeginner', + 'guideStartInvesting', 'sparkleToken', + 'yieldCenter', + 'cardAnnouncement', + 'interestForYou', + 'futures', + 'giftBoxCrypto', + 'assetRefresh', + 'cardAutoReload', + 'switchReward', + 'coinbaseFees', 'defiHow', + 'readyToTrade', + 'freeBtc', + 'estimatedAmount', 'securityShield', - 'coinbaseFees', - 'interestForYou', - 'pixDeposits', - 'cryptoPortfolio', - 'cardAnnouncement', - 'secureStorage', - 'walletQuestsChest', - 'guideStartInvesting', + 'cryptoAndMore', + 'tradeImmediately', 'coinbaseOneSavingFunds', + 'noPortfolio', 'coinbaseOneUSDC', + 'walletQuestsTrophy', + 'walletQuestsChest', 'coinbaseOneBoostedCard', - 'congratulationsOnEarningCrypto', - 'secureAndTrusted', - 'assetForward', - 'addMultipleCrypto', - 'yieldCenter', + 'coinbaseOneBoostedCardCB1', + 'goldSilverFutures', + 'instoPixDeposits', + 'inrTrade', ], - clipboard: ['refresh', 'onTheList', 'verifyInfo', 'nuxChecklist'], - verify: ['refresh', 'verifyEmail', 'verifyInfo'], - info: ['refresh', 'verifyInfo'], - information: ['refresh', 'verifyInfo'], - document: [ - 'refresh', - 'onTheList', - 'commerceInvoices', - 'documentCertified', - 'commerceAccounting', - 'collectingNfts', - 'verifyInfo', - 'taxDocuments', + credit: [ + 'boostedCard', + 'cardDeclined', + 'coinbaseCardSparkle', + 'cardAnnouncement', + 'addCard', + 'cardBlocked', + 'coinbaseCardPocket', + 'coinbaseCardLock', ], - warning: ['refresh', 'verifyInfo', 'cardDeclined', 'contactsListWarning', 'idError', 'outage'], - error: ['refresh', 'verifyInfo', 'cardDeclined', 'idError', 'outage', 'cbEthWrappingUnavailable'], - issue: ['refresh', 'verifyInfo'], - concern: ['refresh', 'verifyInfo'], - '⚠️': ['refresh', 'verifyInfo', 'idError'], - 'warning state': ['refresh', 'offersEmpty', 'frameEmpty', 'verifyInfo', 'contactsListWarning'], - gift: ['rewardExpiring', 'switchReward', 'giftBoxCrypto', 'coinbaseOneTokenRewards', 'gifting'], - reward: ['rewardExpiring', 'coinbaseOneStaking', 'yieldCenterUSDC', 'moneyRewards', 'ethStaking'], - moment: ['rewardExpiring'], - clock: ['rewardExpiring', 'futures', 'getStartedInMinutes', 'quickAndSimple'], - time: ['rewardExpiring', 'automaticPayments', 'getStartedInMinutes', 'quickAndSimple'], - record: ['rewardExpiring'], - minute: ['rewardExpiring'], - hour: ['rewardExpiring'], - day: ['rewardExpiring'], - '24 hours': ['rewardExpiring'], - expiring: ['rewardExpiring'], - end: ['rewardExpiring'], - countdown: ['rewardExpiring'], - '🕦': ['rewardExpiring'], - '🕐': ['rewardExpiring'], - '🕚': ['rewardExpiring'], - '🕥': ['rewardExpiring'], - '🕧': ['rewardExpiring'], - '🕙': ['rewardExpiring'], - '🕣': ['rewardExpiring'], - '🕠': ['rewardExpiring'], - '🕝': ['rewardExpiring'], - '🕢': ['rewardExpiring'], - '🕟': ['rewardExpiring'], - '🕜': ['rewardExpiring'], - '🕤': ['rewardExpiring'], - '🕡': ['rewardExpiring'], - '🕞': ['rewardExpiring'], - '🕘': ['rewardExpiring'], - '🕒': ['rewardExpiring'], - '🕗': ['rewardExpiring'], - '🕔': ['rewardExpiring'], - '🕑': ['rewardExpiring'], - '🕖': ['rewardExpiring'], - '🕓': ['rewardExpiring'], - '🕛': ['rewardExpiring'], - '⏰': ['rewardExpiring'], - '⏱': ['rewardExpiring'], - '🕰': ['rewardExpiring'], - '🔄': ['rewardExpiring', 'switchReward'], - '⏳': ['rewardExpiring'], - '⌛️': ['rewardExpiring'], - moon: ['darkModeIntroduction', 'cryptoAndMore'], - dark: ['darkModeIntroduction'], - darkmode: ['darkModeIntroduction'], sparkle: [ - 'darkModeIntroduction', - 'coinbaseOneStarToken', - 'starToken', 'boostedCard', + 'coinbaseOneStarToken', 'coinbaseCardSparkle', + 'nuxChecklist', 'nuxPopularAssets', - 'freeBtc', 'sparkleToken', 'cardAnnouncement', - 'nuxChecklist', - ], - night: ['darkModeIntroduction'], - portfolio: ['portfolioPerformance', 'noPortfolio', 'cryptoPortfolio'], - performance: ['portfolioPerformance', 'performance'], - chart: [ - 'portfolioPerformance', - 'advancedTradingChartsIndicatorsCandles', - 'announcementAdvancedTrading', - 'invest', - 'staking', - 'accessToAdvancedCharts', - 'focusLimitOrders', - 'earnInterest', - 'earn', - 'guideBullCase', - 'bullishCase', - 'advancedTradingUi', - 'coinbaseOneEarn', - ], - up: [ - 'portfolioPerformance', - 'advancedTradingChartsIndicatorsCandles', - 'trendingHotAssets', - 'announcementAdvancedTrading', - 'waitlistSignup', - 'earn', - 'guideBullCase', - 'borrowLimitsAddressed', - 'bullishCase', - 'coinbaseOneEarn', + 'starToken', + 'darkModeIntroduction', + 'freeBtc', ], - to: [ - 'portfolioPerformance', - 'p2pPayments', - 'trendingHotAssets', + excitement: ['boostedCard', 'coinbaseCardSparkle', 'directDepositExcitement'], + debit: ['boostedCard', 'coinbaseCardSparkle', 'cardAnnouncement'], + boost: ['boostedCard'], + boosted: ['boostedCard'], + dappwallet: ['dappWallet', 'instoDappWallet'], + wallet: [ + 'dappWallet', + 'linkCoinbaseWallet', 'linkingYourWalletToYourCoinbaseAccount', - 'coinbaseOneUSDC', - ], - the: ['portfolioPerformance', 'trendingHotAssets'], - right: [ - 'portfolioPerformance', - 'trendingHotAssets', - 'announcementAdvancedTrading', - 'completeAQuiz', - 'bullishCase', - ], - coins: [ - 'portfolioPerformance', - 'digitalCollectibles', - 'defiDecentralizedBorrowingLending', - 'cryptoAndMore', - 'multiPlatformMobileAppBrowserExtension', - 'defiEarn', - 'encryptedEverything', - 'cryptoEconomyArrows', 'stayInControlSelfHostedWalletsStorage', - 'holdCrypto', + 'walletSecurity', 'selfCustody', - 'multicoinSupport', - 'linkingYourWalletToYourCoinbaseAccount', - 'moneyDecentralized', - 'invest', - 'staking', - 'insuranceProtection', - 'stressTestedColdStorage', - 'ethStakingRewards', - 'holdingCrypto', - 'defiHow', - 'cryptoEconomy', - 'mining', - 'shareOnSocialMedia', - 'gifting', - 'walletQuestsChest', - 'globalTransactions', - 'coinbaseOneUSDC', - 'crossBorderPayments', - 'backedByUsDollar', 'borrowWallet', - 'defiDecentralizedTradingExchange', - 'sendCryptoFaster', + 'secureStorage', 'cryptoWallet', - 'addMultipleCrypto', - ], - arrow: [ - 'portfolioPerformance', - 'trendingHotAssets', - 'giftBoxCrypto', - 'pixBankDeposits', - 'announcementAdvancedTrading', - 'accessToAdvancedCharts', - 'commerceAccounting', - 'futures', - 'automaticPayments', - 'holdingCrypto', - 'defiHow', - 'focusLimitOrders', - 'performance', - 'coinbaseFees', - 'pixDeposits', - 'borrowLimitsAddressed', - 'bullishCase', - 'guideStartInvesting', - 'coinbaseOneSavingFunds', + 'browserExtension', + 'defiDecentralizedBorrowingLending', + 'walletApp', + 'walletNotifications', + 'walletQuestsTrophy', + 'walletQuestsChest', + 'instoDappWallet', ], - NFT: ['digitalCollectibles'], - digital: ['digitalCollectibles'], - collect: ['digitalCollectibles', 'defiNfts', 'guideNftDefi'], - collectibles: ['digitalCollectibles'], - art: ['digitalCollectibles', 'nft'], - music: ['digitalCollectibles', 'collectingNfts'], - PFP: ['digitalCollectibles'], - avatar: [ - 'digitalCollectibles', - 'semiCustodial', - 'selfCustody', - 'linkingYourWalletToYourCoinbaseAccount', - 'moneyDecentralized', - 'collectingNfts', - 'didDecentralizedIdentity', + '🌐': ['dappWallet', 'instoDappWallet'], + web3: ['dappWallet', 'decentralizedWebWeb3', 'instoDappWallet'], + 'art nft ❗️⚠️ offer': ['offersEmpty'], + one: ['coinbaseOneLogo', 'coinbaseOneDiscountedAmount', 'automaticPayments'], + cb1: [ + 'coinbaseOneLogo', 'coinbaseOneUSDC', + 'coinbaseOneBoostedCard', + 'coinbaseOneBoostedCardCB1', ], - crypto: [ - 'earnInterestOnCryptocurrency', - 'unsupportedAsset', - 'cryptoForBeginners', - 'estimatedAmount', - 'switchReward', - 'guideCryptoBeginner', - 'coinbaseOneStarToken', - 'cryptoEconomyArrows', - 'holdCrypto', - 'starToken', - 'cardBlocked', - 'decentralization', - 'noPortfolio', - 'coinbaseOneStaking', - 'boostedCard', - 'fileYourCryptoTaxesCheckOther', - 'cryptoAssets', - 'insuranceProtection', - 'coinbaseCardSparkle', - 'fileYourCryptoTaxesOther', - 'nuxPopularAssets', - 'blockchain', - 'futures', - 'holdingCrypto', - 'cryptoEconomy', - 'nuxEarnCrypto', - 'hardwareWallets', - 'mining', - 'guideFiveThings', - 'cardDeclined', - 'cryptoPortfolio', - 'directDepositExcitement', - 'guideBullCase', - 'cardAnnouncement', - 'gifting', - 'bullishCase', - 'addCard', + logo: ['coinbaseOneLogo'], + logomark: ['coinbaseOneLogo'], + brand: ['coinbaseOneLogo'], + link: ['linkCoinbaseWallet', 'linkingYourWalletToYourCoinbaseAccount'], + connect: ['linkCoinbaseWallet', 'linkingYourWalletToYourCoinbaseAccount', 'coinbaseOneUSDC'], + apps: ['linkCoinbaseWallet', 'cryptoApps'], + '🔗': ['linkCoinbaseWallet'], + '🖇': ['linkCoinbaseWallet'], + Pie: ['taxesDetails'], + Chart: ['taxesDetails', 'gainsAndLosses', 'gasFeesNetworkFees'], + Doc: ['taxesDetails'], + Plus: ['taxesDetails'], + Minus: ['taxesDetails'], + Check: ['taxesDetails'], + Mark: ['taxesDetails'], + Done: ['taxesDetails'], + Taxes: ['taxesDetails'], + Details: ['taxesDetails'], + Layered: ['layeredNetworks'], + Networks: ['layeredNetworks'], + ethereum: [ + 'layeredNetworks', + 'poweredByEthereum', + 'wrapEth', + 'addEth', 'ethStaking', - 'coinFifty', - 'guideStartInvesting', - 'guideNftDefi', - 'congratulationsOnEarningCrypto', - 'sendCryptoFaster', - 'assetForward', - 'yieldCenter', + 'ethStakeOrWrap', + 'eth2SendSell', + 'swapEth', + 'ethStakeOrWrapTwo', + 'instoEthStaking', ], - interest: [ - 'earnInterestOnCryptocurrency', - 'retailUSDCRewards', - 'defiEarnAnnouncement', - 'coinbaseOneStaking', - 'nuxEarnYield', - 'assetRefresh', - 'yieldCenterUSDC', - 'earnInterest', - 'nuxEarnCrypto', - 'interestForYou', - 'coinbaseOneRewards', + layer: ['layeredNetworks'], + eth: [ + 'layeredNetworks', + 'poweredByEthereum', + 'cbEthWrappingUnavailable', 'ethStaking', - 'assetForward', - 'yieldCenter', - ], - farming: ['earnInterestOnCryptocurrency'], - lending: ['earnInterestOnCryptocurrency'], - percentage: [ - 'earnInterestOnCryptocurrency', - 'defiEarn', - 'fileYourCryptoTaxesCheckOther', - 'assetRefresh', - 'fileYourCryptoTaxesOther', - 'taxDocuments', - 'earnInterest', - 'interestForYou', + 'ethStakingRewards', + 'instoEthStaking', + 'instoEthStakingRewards', ], - growth: [ - 'earnInterestOnCryptocurrency', - 'cryptoEconomyArrows', - 'retailUSDCRewards', + side: ['layeredNetworks'], + chain: ['layeredNetworks', 'sidechain', 'blockchain', 'instoSideChainSide'], + Wallet: ['hardwareWallets', 'primeEarn'], + Hardware: ['hardwareWallets'], + Ledger: ['hardwareWallets'], + USB: ['hardwareWallets'], + authentication: ['hardwareWallets', 'walletSecurity'], + account: [ + 'hardwareWallets', + 'linkingYourWalletToYourCoinbaseAccount', + 'stayInControlSelfHostedWalletsStorage', 'defiEarnAnnouncement', - 'coinbaseOneStaking', - 'nuxEarnYield', - 'yieldCenterUSDC', - 'cryptoEconomy', - 'coinbaseOneRewards', - 'ethStaking', - 'assetForward', - 'yieldCenter', - ], - '%': ['earnInterestOnCryptocurrency'], - reload: ['cardAutoReload'], - sparkles: ['cardAutoReload', 'bigBtc', 'primeStaking'], - 'debit card': ['cardAutoReload'], - arrows: ['cardAutoReload', 'defiEarn', 'poweredByEthereum'], - chip: ['cardAutoReload'], - asset: [ - 'unsupportedAsset', - 'coinbaseOneStarToken', - 'starToken', - 'coinbaseOneStaking', - 'nuxEarnYield', - 'tradeImmediately', - 'nuxPopularAssets', - 'bigBtc', - 'gifting', - 'ethStaking', - ], - star: [ - 'unsupportedAsset', - 'coinbaseOneStarToken', - 'walletQuestsTrophy', - 'starToken', - 'nuxPopularAssets', - 'freeBtc', - 'moneyRewards', - 'priceAlerts', - 'sparkleToken', - 'basedInUsa', - ], - token: [ - 'unsupportedAsset', - 'coinbaseOneStarToken', - 'starToken', - 'coinbaseOneTokenRewards', - 'collectingNfts', - 'nuxPopularAssets', - 'gifting', - 'defiNfts', + 'appTrackingTransparency', + 'addPhoneNumber', + 'readyToTrade', + 'coinbaseCardPocket', + 'coinbaseCardLock', ], - cryptocurrency: [ - 'unsupportedAsset', - 'coinbaseOneStarToken', - 'starToken', - 'noPortfolio', - 'nuxPopularAssets', - 'futures', - 'holdingCrypto', - 'mining', + storage: [ + 'hardwareWallets', 'cryptoPortfolio', - 'guideBullCase', - 'guideStartInvesting', - 'sendCryptoFaster', - ], - currency: [ - 'unsupportedAsset', - 'coinbaseOneStarToken', - 'holdCrypto', - 'starToken', - 'coinbaseOneStaking', - 'boostedCard', - 'tradeImmediately', - 'coinbaseCardSparkle', - 'nuxPopularAssets', - 'holdingCrypto', + 'insuranceProtection', + 'stayInControlSelfHostedWalletsStorage', + 'selfCustody', + 'secureStorage', + 'stressTestedColdStorage', 'walletApp', - 'cardAnnouncement', - 'borrowLimitsAddressed', - 'bullishCase', - 'ethStaking', - 'guideStartInvesting', + 'noPortfolio', ], - unsupported: ['unsupportedAsset'], - cat: ['nft'], - crown: ['nft'], - nft: ['nft', 'miniGift', 'walletApp', 'defiNfts', 'guideNftDefi'], - collectible: ['nft'], - decentralized: [ - 'defiDecentralizedBorrowingLending', - 'decentralizedWebWeb3', + cold: ['hardwareWallets', 'stressTestedColdStorage'], + Opt: ['optInPushNotificationsEmail'], + In: ['optInPushNotificationsEmail'], + Push: ['optInPushNotificationsEmail'], + Notifications: ['optInPushNotificationsEmail'], + Email: ['optInPushNotificationsEmail'], + Bubble: ['optInPushNotificationsEmail'], + Window: ['optInPushNotificationsEmail'], + Notify: ['optInPushNotificationsEmail'], + Account: ['optInPushNotificationsEmail'], + Security: ['optInPushNotificationsEmail'], + Prices: ['optInPushNotificationsEmail'], + hexagon: ['sidechain', 'instoSideChainSide'], + connections: ['sidechain', 'instoSideChainSide'], + yellow: ['sidechain', 'shareOnSocialMedia', 'outage', 'layerThree'], + blue: [ + 'sidechain', + 'shareOnSocialMedia', + 'cardDeclined', + 'addCard', + 'cardBlocked', + 'coinFifty', + 'layerThree', + 'instoSideChainSide', + ], + hex: ['blockchain'], + block: ['blockchain'], + network: [ + 'blockchain', 'decentralization', + 'poweredByEthereum', + 'decentralizedWebWeb3', + 'cryptoAssets', 'moneyDecentralized', + 'encryptedEverything', + 'lightningNetworkSend', + ], + decentralized: [ 'blockchain', 'didDecentralizedIdentity', - 'defiNfts', - 'backedByUsDollar', 'defiDecentralizedTradingExchange', + 'decentralization', + 'decentralizedWebWeb3', 'cryptoWallet', - ], - borrow: [ + 'backedByUsDollar', 'defiDecentralizedBorrowingLending', - 'borrowLimitsAddressed', - 'borrowWallet', - 'cryptoWallet', + 'moneyDecentralized', + 'defiNfts', ], - lend: ['defiDecentralizedBorrowingLending', 'cryptoWallet'], - store: [ - 'defiDecentralizedBorrowingLending', - 'holdCrypto', - 'stableValue', - 'selfCustody', - 'holdingCrypto', - 'bigBtc', - 'secureStorage', + user: [ + 'didDecentralizedIdentity', + 'linkingYourWalletToYourCoinbaseAccount', + 'semiCustodial', + 'selfCustody', + 'bullishCase', 'defiNfts', - 'secureAndTrusted', - 'cryptoWallet', - 'assetForward', - 'yieldCenter', - ], - safety: [ - 'defiDecentralizedBorrowingLending', - 'insuranceProtection', - 'securityShield', - 'cryptoWallet', + 'coinbaseOneUSDC', ], - security: [ - 'defiDecentralizedBorrowingLending', - 'phoneNumber', - 'addPhoneNumber', - 'walletSecurity', - 'insuranceProtection', + check: [ + 'didDecentralizedIdentity', + 'completeAQuiz', 'stressTestedColdStorage', - 'securityShield', + 'cardShipped', + 'confirmAddress', + 'confirmEmail', + 'confirmIDCard', 'confirmSocialSecurity', - 'secureStorage', - 'addPasswordProtection', - 'secureAndTrusted', - 'cryptoWallet', + 'directDepositExcitement', + 'guideFiveThings', + 'fileYourCryptoTaxesCheckOther', + 'saveTheDate', + 'appTrackingTransparency', ], - wallet: [ - 'defiDecentralizedBorrowingLending', - 'stayInControlSelfHostedWalletsStorage', - 'walletQuestsTrophy', - 'browserExtension', + list: [ + 'didDecentralizedIdentity', + 'guideFiveThings', + 'nuxChecklist', + 'waitlistSignup', + 'contactsListWarning', + 'onTheList', + ], + checklist: ['didDecentralizedIdentity', 'guideFiveThings', 'nuxChecklist'], + avatar: [ + 'didDecentralizedIdentity', + 'linkingYourWalletToYourCoinbaseAccount', + 'digitalCollectibles', + 'semiCustodial', + 'collectingNfts', 'selfCustody', + 'moneyDecentralized', + 'coinbaseOneUSDC', + ], + id: ['didDecentralizedIdentity', 'confirmIDCard', 'idError'], + did: ['didDecentralizedIdentity'], + identity: ['didDecentralizedIdentity'], + chart: [ + 'advancedTradingChartsIndicatorsCandles', + 'earn', + 'invest', + 'staking', + 'announcementAdvancedTrading', + 'guideBullCase', + 'bullishCase', + 'portfolioPerformance', + 'accessToAdvancedCharts', + 'earnInterest', + 'focusLimitOrders', + 'advancedTradingUi', + 'coinbaseOneEarn', + 'instoStaking', + ], + candle: [ + 'advancedTradingChartsIndicatorsCandles', + 'accessToAdvancedCharts', + 'switchAdvancedToSimpleTrading', + ], + candlesticks: [ + 'advancedTradingChartsIndicatorsCandles', + 'announcementAdvancedTrading', + 'guideBullCase', + 'bullishCase', + 'accessToAdvancedCharts', + 'switchAdvancedToSimpleTrading', + 'advancedTrading', + ], + wick: ['advancedTradingChartsIndicatorsCandles'], + up: [ + 'advancedTradingChartsIndicatorsCandles', + 'earn', + 'trendingHotAssets', + 'announcementAdvancedTrading', + 'guideBullCase', + 'borrowLimitsAddressed', + 'bullishCase', + 'portfolioPerformance', + 'waitlistSignup', + 'coinbaseOneEarn', + ], + bar: [ + 'advancedTradingChartsIndicatorsCandles', + 'earn', + 'staking', + 'performance', + 'coinbaseOneEarn', + 'instoStaking', + ], + graph: [ + 'advancedTradingChartsIndicatorsCandles', + 'earn', + 'invest', + 'staking', + 'ethStakingRewards', + 'performance', + 'switchAdvancedToSimpleTrading', + 'coinbaseOneEarn', + 'instoStaking', + 'instoEthStakingRewards', + ], + folder: ['cryptoPortfolio', 'noPortfolio'], + portfolio: ['cryptoPortfolio', 'portfolioPerformance', 'noPortfolio'], + share: ['shareOnSocialMedia'], + social: ['shareOnSocialMedia', 'confirmSocialSecurity'], + media: ['shareOnSocialMedia'], + circles: ['shareOnSocialMedia', 'coinFifty'], + coins: [ + 'shareOnSocialMedia', 'linkingYourWalletToYourCoinbaseAccount', - 'linkCoinbaseWallet', - 'dappWallet', - 'walletSecurity', - 'walletNotifications', - 'walletApp', - 'secureStorage', - 'walletQuestsChest', + 'multicoinSupport', + 'multiPlatformMobileAppBrowserExtension', + 'crossBorderPayments', + 'defiDecentralizedTradingExchange', + 'globalTransactions', + 'digitalCollectibles', + 'invest', + 'insuranceProtection', + 'stayInControlSelfHostedWalletsStorage', + 'staking', + 'selfCustody', 'borrowWallet', 'cryptoWallet', - ], - email: ['verifyEmail', 'openEmail', 'confirmEmail'], - envelope: ['verifyEmail', 'openEmail', 'confirmAddress', 'confirmEmail'], - checkmark: [ - 'verifyEmail', - 'confirmAddress', - 'onTheList', - 'confirmIDCard', - 'documentSuccess', - 'fileYourCryptoTaxesCheckOther', - 'documentCertified', - 'confirmEmail', + 'backedByUsDollar', 'stressTestedColdStorage', - 'completeAQuiz', - 'confirmSocialSecurity', - 'nuxChecklist', - 'quickAndSimple', + 'cryptoEconomy', + 'defiDecentralizedBorrowingLending', + 'mining', + 'moneyDecentralized', + 'addMultipleCrypto', + 'ethStakingRewards', + 'encryptedEverything', + 'gifting', + 'portfolioPerformance', + 'sendCryptoFaster', + 'defiHow', + 'holdingCrypto', + 'cryptoAndMore', + 'holdCrypto', + 'defiEarn', + 'coinbaseOneUSDC', + 'walletQuestsChest', + 'cryptoEconomyArrows', + 'goldSilverFutures', + 'instoStaking', + 'instoEthStakingRewards', ], - nux: [ - 'verifyEmail', - 'coinbaseOneStarToken', - 'starToken', - 'nuxEarnYield', - 'nuxPopularAssets', - 'nuxEarnCrypto', - 'nuxRecurringBuys', + international: [ + 'secureGlobalTransactions', + 'crossBorderPayments', + 'globalTransactions', + 'cryptoEconomy', + 'cryptoEconomyArrows', ], - onboarding: [ - 'verifyEmail', - 'confirmAddress', - 'addPhoneNumber', - 'confirmIDCard', - 'confirmEmail', + world: ['secureGlobalTransactions', 'globalTransactions'], + globe: ['secureGlobalTransactions', 'globalTransactions', 'cryptoEconomy', 'cryptoEconomyArrows'], + transactions: ['secureGlobalTransactions', 'globalTransactions', 'cryptoAssets', 'noFees'], + secure: [ + 'secureGlobalTransactions', + 'secureAndTrusted', + 'insuranceProtection', + 'walletSecurity', + 'secureStorage', + 'stressTestedColdStorage', + 'addPasswordProtection', 'securityShield', - 'confirmSocialSecurity', - ], - '✅': ['verifyEmail', 'documentSuccess', 'fileYourCryptoTaxesCheckOther'], - 'success state': [ - 'verifyEmail', - 'onTheList', - 'documentSuccess', - 'documentCertified', - 'readyToTrade', - 'appTrackingTransparency', - 'bigBtc', - ], - phone: [ - 'phoneNumber', - 'addPhoneNumber', - 'phoneNotifications', - 'priceAlerts', - 'appTrackingTransparency', - 'walletNotifications', - 'refreshMobileApp', ], - number: ['phoneNumber', 'addPhoneNumber', 'confirmSocialSecurity', 'guideBullCase'], - '2FA': ['phoneNumber', 'walletSecurity'], - passcode: ['phoneNumber', 'walletSecurity'], lock: [ - 'phoneNumber', - 'coinbaseLock', 'secureGlobalTransactions', - 'securityShield', 'secureStorage', 'addPasswordProtection', + 'securityShield', + 'phoneNumber', + 'coinbaseLock', ], - asterisk: ['phoneNumber'], - tag: ['coinbaseOneDiscountedAmount', 'noFees'], - coinbaseone: ['coinbaseOneDiscountedAmount'], - one: ['coinbaseOneDiscountedAmount', 'automaticPayments', 'coinbaseOneLogo'], - discounted: ['coinbaseOneDiscountedAmount'], - amount: ['coinbaseOneDiscountedAmount', 'estimatedAmount'], - beginners: ['cryptoForBeginners'], - education: ['cryptoForBeginners', 'bullishCase', 'defiNfts'], - understanding: ['cryptoForBeginners'], - learning: ['cryptoForBeginners'], - article: ['cryptoForBeginners'], - reading: ['cryptoForBeginners'], - estimated: ['estimatedAmount'], - prices: ['estimatedAmount'], - browser: ['estimatedAmount', 'watchVideos', 'browserExtension', 'switchAdvancedToSimpleTrading'], - money: [ - 'estimatedAmount', + receive: [ + 'secureGlobalTransactions', 'p2pPayments', - 'cryptoEconomyArrows', - 'defiEarnAnnouncement', - 'coinbaseCardLock', - 'moneyDecentralized', - 'invest', - 'coinbaseOneStaking', - 'nuxEarnYield', - 'noFees', - 'freeBtc', - 'yieldCenterUSDC', - 'moneyRewards', - 'bigBtc', - 'cryptoEconomy', - 'nuxEarnCrypto', - 'completeAQuiz', - 'earn', - 'directDepositExcitement', - 'secureStorage', - 'gifting', - 'ethStaking', - 'coinbaseOneEarn', - 'globalTransactions', - 'coinbaseOneSavingFunds', 'crossBorderPayments', - 'backedByUsDollar', + 'globalTransactions', 'borrowWallet', - 'coinbaseCardPocket', - ], - calculation: ['estimatedAmount'], - open: ['openEmail'], - letter: ['openEmail'], - '📧 📥 📤 ✉ 📩 📨': ['openEmail'], - rewards: [ - 'switchReward', - 'coinbaseOneTokenRewards', - 'moneyRewards', - 'congratulationsOnEarningCrypto', - ], - exchange: ['switchReward'], - switch: ['switchReward', 'tradeImmediately', 'advancedTrading', 'switchAdvancedToSimpleTrading'], - '🎁': ['switchReward', 'giftBoxCrypto', 'coinbaseOneTokenRewards', 'miniGift'], - '🪙': ['switchReward'], - more: ['cryptoAndMore', 'staking', 'nuxEarnYield', 'yieldCenterUSDC', 'nuxEarnCrypto'], - 'empty state': ['cryptoAndMore', 'tradeImmediately'], - confirm: [ - 'confirmAddress', - 'coinbaseCardLock', - 'confirmIDCard', - 'documentSuccess', - 'confirmEmail', - 'waitlistSignup', - 'confirmSocialSecurity', - 'coinbaseCardPocket', - ], - check: [ - 'confirmAddress', - 'confirmIDCard', - 'fileYourCryptoTaxesCheckOther', - 'confirmEmail', - 'didDecentralizedIdentity', - 'stressTestedColdStorage', - 'appTrackingTransparency', - 'completeAQuiz', - 'guideFiveThings', - 'cardShipped', - 'confirmSocialSecurity', 'directDepositExcitement', - 'saveTheDate', - ], - validate: ['confirmAddress', 'confirmIDCard', 'confirmEmail', 'confirmSocialSecurity'], - address: ['confirmAddress'], - mail: ['confirmAddress'], - kyc: ['confirmAddress', 'confirmIDCard', 'confirmEmail', 'confirmSocialSecurity'], - beginner: [ - 'guideCryptoBeginner', - 'guideFiveThings', - 'guideBullCase', - 'guideStartInvesting', - 'guideNftDefi', - ], - guide: [ - 'guideCryptoBeginner', - 'guideFiveThings', - 'guideBullCase', - 'guideStartInvesting', - 'guideNftDefi', - ], - start: [ - 'guideCryptoBeginner', - 'tradeImmediately', - 'readyToTrade', - 'startToday', - 'guideFiveThings', - 'guideStartInvesting', ], - here: ['guideCryptoBeginner', 'noPortfolio', 'guideFiveThings'], - get: ['guideCryptoBeginner', 'freeBtc', 'getStartedInMinutes'], - going: ['guideCryptoBeginner', 'getStartedInMinutes'], - doc: ['guideCryptoBeginner'], - paper: ['guideCryptoBeginner', 'onTheList', 'taxDocuments'], + 'peer to peer': ['secureGlobalTransactions'], peer: ['p2pPayments'], - payments: ['p2pPayments', 'automaticPayments', 'crossBorderPayments'], - P2P: ['p2pPayments'], - send: [ + to: [ 'p2pPayments', - 'lightningNetworkSend', - 'bridging', - 'secureGlobalTransactions', - 'eth2SendSell', - 'swapEth', - 'gifting', - 'globalTransactions', - 'crossBorderPayments', - 'sendCryptoFaster', + 'linkingYourWalletToYourCoinbaseAccount', + 'trendingHotAssets', + 'portfolioPerformance', + 'coinbaseOneUSDC', ], - receive: [ + payments: ['p2pPayments', 'crossBorderPayments', 'automaticPayments'], + P2P: ['p2pPayments'], + money: [ 'p2pPayments', - 'secureGlobalTransactions', - 'directDepositExcitement', - 'globalTransactions', 'crossBorderPayments', + 'globalTransactions', + 'earn', + 'invest', + 'noFees', 'borrowWallet', + 'coinbaseOneStaking', + 'secureStorage', + 'backedByUsDollar', + 'completeAQuiz', + 'cryptoEconomy', + 'ethStaking', + 'moneyDecentralized', + 'defiEarnAnnouncement', + 'directDepositExcitement', + 'moneyRewards', + 'nuxEarnCrypto', + 'nuxEarnYield', + 'gifting', + 'bigBtc', + 'freeBtc', + 'estimatedAmount', + 'coinbaseCardPocket', + 'coinbaseCardLock', + 'coinbaseOneSavingFunds', + 'yieldCenterUSDC', + 'coinbaseOneEarn', + 'cryptoEconomyArrows', + 'instoEthStaking', ], fast: [ 'p2pPayments', - 'lightningNetworkSend', - 'getStartedInMinutes', 'quickAndSimple', + 'getStartedInMinutes', 'sendCryptoFaster', + 'lightningNetworkSend', ], quick: ['p2pPayments', 'quickAndSimple'], value: [ 'p2pPayments', - 'retailUSDCRewards', + 'coinbaseOneRewards', 'stableValue', + 'retailUSDCRewards', + 'mining', 'moneyDecentralized', + 'assetForward', + 'yieldCenter', 'bigBtc', - 'mining', - 'coinbaseOneRewards', + ], + linking: ['linkingYourWalletToYourCoinbaseAccount', 'coinbaseOneUSDC'], + both: ['linkingYourWalletToYourCoinbaseAccount', 'coinbaseOneUSDC'], + multi: ['multicoinSupport', 'multiPlatformMobileAppBrowserExtension'], + multicoin: ['multicoinSupport'], + support: ['multicoinSupport'], + networks: ['multicoinSupport', 'layerThree'], + many: ['multicoinSupport'], + platform: ['multiPlatformMobileAppBrowserExtension'], + mobile: ['multiPlatformMobileAppBrowserExtension', 'refreshMobileApp'], + desktop: ['multiPlatformMobileAppBrowserExtension', 'browserExtension'], + users: [ + 'multiPlatformMobileAppBrowserExtension', + 'multipleAccountsWalletsForOneUser', + 'moneyDecentralized', + ], + trophy: ['congratulationsOnEarningCrypto', 'walletQuestsTrophy'], + win: ['congratulationsOnEarningCrypto'], + rewards: [ + 'congratulationsOnEarningCrypto', + 'coinbaseOneTokenRewards', + 'moneyRewards', + 'switchReward', + 'inrTrade', + ], + success: ['congratulationsOnEarningCrypto', 'readyToTrade', 'documentSuccess'], + CB1: ['coinbaseOneStarToken', 'coinbaseOneStakeOrWrap', 'coinbaseOneStaking'], + nux: [ + 'coinbaseOneStarToken', + 'nuxPopularAssets', + 'nuxRecurringBuys', + 'nuxEarnCrypto', + 'nuxEarnYield', + 'starToken', + 'verifyEmail', + ], + popular: ['coinbaseOneStarToken', 'nuxPopularAssets', 'starToken'], + '✨': [ + 'coinbaseOneStarToken', + 'nuxPopularAssets', + 'primeEarn', + 'starToken', + 'primeStaking', + 'primeDeFi', + 'bigBtc', + 'instoPrimeStaking', + ], + Trust: ['secureAndTrusted'], + trusted: ['secureAndTrusted', 'stressTestedColdStorage'], + security: [ + 'secureAndTrusted', + 'insuranceProtection', + 'walletSecurity', + 'secureStorage', + 'cryptoWallet', + 'stressTestedColdStorage', + 'defiDecentralizedBorrowingLending', + 'confirmSocialSecurity', + 'addPasswordProtection', + 'addPhoneNumber', + 'securityShield', + 'phoneNumber', + ], + shield: ['secureAndTrusted'], + store: [ + 'secureAndTrusted', + 'stableValue', + 'selfCustody', + 'secureStorage', + 'cryptoWallet', + 'defiDecentralizedBorrowingLending', 'assetForward', 'yieldCenter', + 'defiNfts', + 'holdingCrypto', + 'bigBtc', + 'holdCrypto', ], - watch: ['watchVideos', 'startToday'], - video: ['watchVideos'], - eye: ['watchVideos'], + safe: ['secureAndTrusted', 'coinbaseOneSavingFunds'], + cross: ['crossBorderPayments'], + border: ['crossBorderPayments'], + cbone: ['coinbaseOneRewards', 'coinbaseOneTokenRewards'], earn: [ - 'watchVideos', - 'defiEarn', - 'retailUSDCRewards', - 'defiEarnAnnouncement', - 'stableValue', - 'cardBlocked', + 'coinbaseOneRewards', + 'earn', 'invest', - 'coinbaseOneStaking', + 'stableValue', 'staking', - 'nuxEarnYield', - 'freeBtc', - 'yieldCenterUSDC', - 'defiRisk', - 'sparkleToken', - 'earnInterest', - 'nuxEarnCrypto', + 'coinbaseOneStaking', + 'retailUSDCRewards', + 'backedByUsDollar', + 'watchVideos', 'startToday', 'completeAQuiz', - 'earn', - 'coinbaseOneRewards', + 'ethStaking', 'cardDeclined', + 'defiEarnAnnouncement', + 'sparkleToken', + 'nuxEarnCrypto', + 'nuxEarnYield', 'addCard', - 'ethStaking', + 'cardBlocked', 'defiNfts', - 'coinbaseOneEarn', 'guideNftDefi', - 'backedByUsDollar', + 'earnInterest', + 'defiRisk', + 'freeBtc', + 'defiEarn', + 'yieldCenterUSDC', + 'coinbaseOneEarn', + 'instoEthStaking', + 'instoStaking', ], - window: ['watchVideos'], - play: ['watchVideos', 'collectingNfts'], - button: ['watchVideos'], - list: [ - 'onTheList', - 'waitlistSignup', - 'didDecentralizedIdentity', - 'guideFiveThings', - 'contactsListWarning', - 'nuxChecklist', + interest: [ + 'coinbaseOneRewards', + 'earnInterestOnCryptocurrency', + 'coinbaseOneStaking', + 'retailUSDCRewards', + 'ethStaking', + 'assetForward', + 'defiEarnAnnouncement', + 'yieldCenter', + 'nuxEarnCrypto', + 'nuxEarnYield', + 'interestForYou', + 'assetRefresh', + 'earnInterest', + 'yieldCenterUSDC', + 'instoEthStaking', ], - confirmed: ['onTheList', 'documentCertified'], - on: ['onTheList'], - waiting: ['onTheList'], - notify: ['onTheList'], - details: ['onTheList', 'coinbaseCardLock', 'addPhoneNumber', 'coinbaseCardPocket'], + APY: ['coinbaseOneRewards', 'retailUSDCRewards'], + growth: [ + 'coinbaseOneRewards', + 'earnInterestOnCryptocurrency', + 'coinbaseOneStaking', + 'retailUSDCRewards', + 'cryptoEconomy', + 'ethStaking', + 'assetForward', + 'defiEarnAnnouncement', + 'yieldCenter', + 'nuxEarnYield', + 'yieldCenterUSDC', + 'cryptoEconomyArrows', + 'instoEthStaking', + ], + rate: ['coinbaseOneRewards', 'retailUSDCRewards'], + '📈': ['coinbaseOneRewards', 'retailUSDCRewards', 'earnInterest', 'advancedTrading'], + trading: [ + 'defiDecentralizedTradingExchange', + 'announcementAdvancedTrading', + 'guideBullCase', + 'futures', + 'advancedTradingUi', + 'readyToTrade', + 'advancedTrading', + ], + DeFi: ['defiDecentralizedTradingExchange', 'primeDeFi'], + swap: ['defiDecentralizedTradingExchange', 'tradeImmediately'], + Gains: ['gainsAndLosses'], + Losses: ['gainsAndLosses'], + Scale: ['gainsAndLosses'], + Growth: ['gainsAndLosses'], + Money: ['gainsAndLosses', 'earnToLearn', 'primeEarn'], + Down: ['gainsAndLosses'], + Arrow: ['gainsAndLosses'], + i18n: ['globalTransactions'], + grow: ['earn', 'invest', 'guideNftDefi', 'coinbaseOneEarn'], + invest: ['earn', 'invest', 'recommendInvestments', 'coinbaseOneEarn'], + future: ['earn', 'futures', 'coinbaseOneEarn'], + NFT: ['digitalCollectibles'], + digital: ['digitalCollectibles'], + collect: ['digitalCollectibles', 'defiNfts', 'guideNftDefi'], + collectibles: ['digitalCollectibles'], + art: ['digitalCollectibles', 'nft'], + music: ['digitalCollectibles', 'collectingNfts'], + PFP: ['digitalCollectibles'], semi: ['semiCustodial'], custodial: ['semiCustodial'], 'semi custodial': ['semiCustodial'], bank: [ 'semiCustodial', - 'pixBankDeposits', 'pixDeposits', + 'pixBankDeposits', 'directDepositExcitement', 'coinbaseOneSavingFunds', + 'instoPixDeposits', ], - user: [ - 'semiCustodial', - 'selfCustody', - 'linkingYourWalletToYourCoinbaseAccount', - 'didDecentralizedIdentity', - 'bullishCase', - 'defiNfts', - 'coinbaseOneUSDC', - ], - CB1: ['coinbaseOneStarToken', 'coinbaseOneStaking', 'coinbaseOneStakeOrWrap'], - popular: ['coinbaseOneStarToken', 'starToken', 'nuxPopularAssets'], - '✨': [ - 'coinbaseOneStarToken', - 'starToken', - 'nuxPopularAssets', - 'bigBtc', - 'primeDeFi', - 'primeStaking', - 'primeEarn', - ], - candle: [ - 'advancedTradingChartsIndicatorsCandles', - 'accessToAdvancedCharts', - 'switchAdvancedToSimpleTrading', - ], - candlesticks: [ - 'advancedTradingChartsIndicatorsCandles', - 'announcementAdvancedTrading', - 'accessToAdvancedCharts', - 'guideBullCase', - 'bullishCase', - 'advancedTrading', - 'switchAdvancedToSimpleTrading', - ], - wick: ['advancedTradingChartsIndicatorsCandles'], - bar: [ - 'advancedTradingChartsIndicatorsCandles', - 'staking', - 'performance', - 'earn', - 'coinbaseOneEarn', - ], - graph: [ - 'advancedTradingChartsIndicatorsCandles', + save: [ 'invest', - 'staking', - 'ethStakingRewards', - 'performance', - 'earn', - 'coinbaseOneEarn', - 'switchAdvancedToSimpleTrading', - ], - multi: ['multiPlatformMobileAppBrowserExtension', 'multicoinSupport'], - platform: ['multiPlatformMobileAppBrowserExtension'], - mobile: ['multiPlatformMobileAppBrowserExtension', 'refreshMobileApp'], - desktop: ['multiPlatformMobileAppBrowserExtension', 'browserExtension'], - users: [ - 'multiPlatformMobileAppBrowserExtension', - 'moneyDecentralized', - 'multipleAccountsWalletsForOneUser', - ], - trend: ['trendingHotAssets'], - trending: ['trendingHotAssets'], - assets: [ - 'trendingHotAssets', - 'recommendInvestments', - 'cryptoAssets', - 'yieldCenterUSDC', + 'noFees', + 'fileYourCryptoTaxesOther', + 'fileYourCryptoTaxesCheckOther', 'holdingCrypto', - 'sendCryptoFaster', - 'assetForward', - 'yieldCenter', ], - hot: ['trendingHotAssets'], - and: ['trendingHotAssets'], - defi: ['defiEarn', 'defiEarnAnnouncement', 'defiHow', 'walletApp', 'defiNfts', 'guideNftDefi'], - encrypted: ['encryptedEverything'], - cryptography: ['encryptedEverything', 'decentralization', 'cryptoAssets'], - computers: ['encryptedEverything'], - computation: ['encryptedEverything'], - network: [ - 'encryptedEverything', - 'decentralizedWebWeb3', - 'lightningNetworkSend', - 'decentralization', - 'poweredByEthereum', - 'moneyDecentralized', - 'cryptoAssets', - 'blockchain', + beginners: ['cryptoForBeginners'], + education: ['cryptoForBeginners', 'bullishCase', 'defiNfts'], + understanding: ['cryptoForBeginners'], + learning: ['cryptoForBeginners'], + article: ['cryptoForBeginners'], + reading: ['cryptoForBeginners'], + cryptography: ['decentralization', 'cryptoAssets', 'encryptedEverything'], + connection: ['decentralization'], + stable: ['stableValue'], + scale: ['stableValue'], + stablecoin: ['stableValue'], + umbrella: ['insuranceProtection'], + insurance: ['insuranceProtection'], + protection: ['insuranceProtection', 'addPasswordProtection'], + safety: [ + 'insuranceProtection', + 'cryptoWallet', + 'defiDecentralizedBorrowingLending', + 'securityShield', ], - confirmation: ['encryptedEverything', 'waitlistSignup', 'idError'], + powered: ['poweredByEthereum'], + by: ['poweredByEthereum', 'backedByUsDollar'], + icon: ['poweredByEthereum', 'cardDeclined', 'addCard', 'cardBlocked', 'gifting'], + arrows: ['poweredByEthereum', 'cardAutoReload', 'defiEarn'], web: ['decentralizedWebWeb3', 'browserExtension'], - web3: ['decentralizedWebWeb3', 'dappWallet'], self: [ 'decentralizedWebWeb3', 'stayInControlSelfHostedWalletsStorage', @@ -970,610 +918,745 @@ const descriptionMap: Record = { custody: ['decentralizedWebWeb3', 'selfCustody'], ownership: ['decentralizedWebWeb3'], data: ['decentralizedWebWeb3'], - globe: ['cryptoEconomyArrows', 'secureGlobalTransactions', 'cryptoEconomy', 'globalTransactions'], - international: [ - 'cryptoEconomyArrows', - 'secureGlobalTransactions', - 'cryptoEconomy', - 'globalTransactions', - 'crossBorderPayments', + nfts: ['collectingNfts', 'walletApp'], + play: ['collectingNfts', 'watchVideos'], + file: ['collectingNfts', 'fileYourCryptoTaxesOther', 'fileYourCryptoTaxesCheckOther'], + document: [ + 'collectingNfts', + 'taxDocuments', + 'commerceInvoices', + 'onTheList', + 'commerceAccounting', + 'verifyInfo', + 'documentCertified', + 'refresh', ], - economy: ['cryptoEconomyArrows', 'cryptoEconomy'], - freedom: ['cryptoEconomyArrows', 'cryptoEconomy'], - economic: ['cryptoEconomyArrows', 'cryptoEconomy'], + non: ['collectingNfts', 'defiNfts'], + fungible: ['collectingNfts', 'defiNfts'], hosted: ['stayInControlSelfHostedWalletsStorage'], - storage: [ - 'stayInControlSelfHostedWalletsStorage', - 'selfCustody', - 'noPortfolio', - 'insuranceProtection', - 'stressTestedColdStorage', - 'hardwareWallets', - 'walletApp', - 'cryptoPortfolio', - 'secureStorage', - ], stay: ['stayInControlSelfHostedWalletsStorage'], in: ['stayInControlSelfHostedWalletsStorage'], control: ['stayInControlSelfHostedWalletsStorage'], your: ['stayInControlSelfHostedWalletsStorage'], access: ['stayInControlSelfHostedWalletsStorage', 'walletApp'], - account: [ - 'stayInControlSelfHostedWalletsStorage', - 'defiEarnAnnouncement', - 'linkingYourWalletToYourCoinbaseAccount', - 'coinbaseCardLock', - 'addPhoneNumber', - 'readyToTrade', - 'appTrackingTransparency', - 'hardwareWallets', - 'coinbaseCardPocket', - ], - USDC: ['retailUSDCRewards'], - APY: ['retailUSDCRewards', 'coinbaseOneRewards'], - rate: ['retailUSDCRewards', 'coinbaseOneRewards'], - '📈': ['retailUSDCRewards', 'earnInterest', 'coinbaseOneRewards', 'advancedTrading'], - yield: [ - 'holdCrypto', - 'coinbaseOneStaking', - 'nuxEarnYield', - 'yieldCenterUSDC', - 'defiRisk', - 'walletApp', - 'ethStaking', - 'outage', - 'guideNftDefi', - 'backedByUsDollar', + assets: [ + 'cryptoAssets', + 'trendingHotAssets', 'assetForward', + 'recommendInvestments', 'yieldCenter', + 'sendCryptoFaster', + 'holdingCrypto', + 'yieldCenterUSDC', ], - hold: ['holdCrypto', 'defiEarnAnnouncement'], - hodl: ['holdCrypto', 'freeBtc', 'sparkleToken'], - basket: ['holdCrypto'], + ledger: ['cryptoAssets'], + balance: ['cryptoAssets', 'defiEarnAnnouncement'], stake: [ - 'holdCrypto', - 'defiEarnAnnouncement', + 'coinbaseOneStakeOrWrap', 'staking', - 'nuxEarnYield', - 'yieldCenterUSDC', 'wrapEth', - 'holdingCrypto', - 'nuxEarnCrypto', 'ethStakeOrWrap', - 'ethStakeOrWrapTwo', - 'coinbaseOneStakeOrWrap', 'assetForward', - 'yieldCenter', - ], - bowl: ['holdCrypto'], - buy: ['defiEarnAnnouncement', 'guideStartInvesting'], - make: [ 'defiEarnAnnouncement', - 'coinbaseOneStaking', - 'nuxEarnYield', + 'yieldCenter', 'nuxEarnCrypto', - 'ethStaking', + 'nuxEarnYield', + 'ethStakeOrWrapTwo', + 'holdingCrypto', + 'holdCrypto', + 'yieldCenterUSDC', + 'instoStaking', ], - balance: ['defiEarnAnnouncement', 'cryptoAssets'], - apps: ['cryptoApps', 'linkCoinbaseWallet'], - ghost: ['cryptoApps'], - unicorn: ['cryptoApps'], - charts: ['cryptoApps'], - 'wallet quests': ['walletQuestsTrophy', 'walletQuestsChest'], - trophy: ['walletQuestsTrophy', 'congratulationsOnEarningCrypto'], - '🏆': ['walletQuestsTrophy', 'walletQuestsChest'], - '⭐': ['walletQuestsTrophy', 'walletQuestsChest'], - stable: ['stableValue'], - scale: ['stableValue'], - stablecoin: ['stableValue'], - Lighting: ['lightningNetworkSend'], - Lightingnetwork: ['lightningNetworkSend'], - speed: ['lightningNetworkSend', 'sendCryptoFaster'], - bolt: ['lightningNetworkSend', 'sendCryptoFaster'], - lightingbolt: ['lightningNetworkSend'], - '⚡': ['lightningNetworkSend'], - Bitcoin: ['lightningNetworkSend', 'referralsPeople', 'bigBtc'], - box: ['giftBoxCrypto', 'miniGift'], - present: ['giftBoxCrypto'], - coinbaseOneZero: ['coinbaseOneZeroPortal', 'coinbaseOneZero'], - Zero: ['coinbaseOneZeroPortal', 'coinbaseOneZero'], - commerce: ['commerceInvoices', 'commerceAccounting'], - invoices: ['commerceInvoices'], - plus: [ - 'commerceInvoices', - 'coinbaseCardLock', - 'addCard', - 'addPasswordProtection', - 'coinbaseCardPocket', + wrap: ['coinbaseOneStakeOrWrap', 'wrapEth', 'addEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo'], + rush: ['coinbaseOneStakeOrWrap', 'wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo'], + movement: ['coinbaseOneStakeOrWrap', 'wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo'], + forward: [ + 'coinbaseOneStakeOrWrap', + 'wrapEth', + 'ethStakeOrWrap', + 'guideStartInvesting', + 'ethStakeOrWrapTwo', ], - '📝': ['commerceInvoices', 'commerceAccounting'], - '📄': ['commerceInvoices', 'commerceAccounting'], - '📃': ['commerceInvoices', 'commerceAccounting'], - '📑': ['commerceInvoices', 'commerceAccounting'], - '➕': ['commerceInvoices', 'addMultipleCrypto'], - '💲': ['commerceInvoices', 'coinbaseOneSavingFunds'], - extension: ['browserExtension'], - integrate: ['browserExtension'], - leverage: ['browserExtension', 'borrowLimitsAddressed'], - website: ['browserExtension'], - Gains: ['gainsAndLosses'], - Losses: ['gainsAndLosses'], - Scale: ['gainsAndLosses'], - Growth: ['gainsAndLosses'], - Money: ['gainsAndLosses', 'earnToLearn', 'primeEarn'], - Chart: ['gainsAndLosses', 'gasFeesNetworkFees', 'taxesDetails'], - Up: ['gainsAndLosses', 'referralsPeople', 'earnToLearn'], - Down: ['gainsAndLosses'], - Arrow: ['gainsAndLosses'], - recommend: ['recommendInvestments'], - recommended: ['recommendInvestments'], - recommendation: ['recommendInvestments'], - invest: ['recommendInvestments', 'invest', 'earn', 'coinbaseOneEarn'], - investments: ['recommendInvestments'], - choose: ['recommendInvestments'], - tokens: ['recommendInvestments', 'bridging'], - multicoin: ['multicoinSupport'], - support: ['multicoinSupport'], - networks: ['multicoinSupport', 'layerThree'], - many: ['multicoinSupport'], - credit: [ - 'cardBlocked', - 'coinbaseCardLock', - 'boostedCard', - 'coinbaseCardSparkle', - 'cardDeclined', - 'cardAnnouncement', - 'addCard', - 'coinbaseCardPocket', + exciting: ['coinbaseOneStakeOrWrap', 'wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo'], + Lock: ['walletSecurity'], + key: ['walletSecurity', 'addPasswordProtection'], + '2FA': ['walletSecurity', 'phoneNumber'], + passcode: ['walletSecurity', 'phoneNumber'], + staking: [ + 'staking', + 'coinbaseOneStaking', + 'ethStaking', + 'ethStakingRewards', + 'defiHow', + 'instoEthStaking', + 'instoStaking', + 'instoEthStakingRewards', ], - status: ['cardBlocked', 'cardDeclined', 'addCard'], - icon: ['cardBlocked', 'poweredByEthereum', 'cardDeclined', 'gifting', 'addCard'], - blue: [ - 'cardBlocked', - 'sidechain', - 'cardDeclined', - 'shareOnSocialMedia', - 'layerThree', - 'addCard', - 'coinFifty', + liquid: ['staking', 'instoStaking'], + more: [ + 'staking', + 'nuxEarnCrypto', + 'nuxEarnYield', + 'cryptoAndMore', + 'yieldCenterUSDC', + 'goldSilverFutures', + 'instoStaking', ], - spend: [ - 'cardBlocked', - 'boostedCard', - 'coinbaseCardSparkle', - 'cardDeclined', - 'cardAnnouncement', - 'addCard', + finance: ['staking', 'borrowWallet', 'defiNfts', 'instoStaking'], + no: ['noFees', 'noPortfolio'], + fees: ['noFees', 'coinbaseFees'], + price: ['noFees', 'priceAlerts'], + tag: ['noFees', 'coinbaseOneDiscountedAmount'], + sale: ['noFees'], + reduced: ['noFees'], + costs: ['noFees'], + PIX: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + Deposits: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + brazil: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + south: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + america: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + latam: ['pixDeposits', 'pixBankDeposits', 'instoPixDeposits'], + arrow: [ + 'pixDeposits', + 'pixBankDeposits', + 'trendingHotAssets', + 'announcementAdvancedTrading', + 'guideStartInvesting', + 'futures', + 'giftBoxCrypto', + 'borrowLimitsAddressed', + 'bullishCase', + 'portfolioPerformance', + 'performance', + 'coinbaseFees', + 'defiHow', + 'automaticPayments', + 'accessToAdvancedCharts', + 'holdingCrypto', + 'focusLimitOrders', + 'commerceAccounting', + 'coinbaseOneSavingFunds', + 'instoPixDeposits', ], - blocked: ['cardBlocked', 'coinbaseLock'], - denied: ['cardBlocked'], - rejected: ['cardBlocked'], - connection: ['decentralization'], - link: ['linkingYourWalletToYourCoinbaseAccount', 'linkCoinbaseWallet'], - linking: ['linkingYourWalletToYourCoinbaseAccount', 'coinbaseOneUSDC'], - connect: ['linkingYourWalletToYourCoinbaseAccount', 'linkCoinbaseWallet', 'coinbaseOneUSDC'], - both: ['linkingYourWalletToYourCoinbaseAccount', 'coinbaseOneUSDC'], - '🔗': ['linkCoinbaseWallet'], - '🖇': ['linkCoinbaseWallet'], - dappwallet: ['dappWallet'], - '🌐': ['dappWallet'], - powered: ['poweredByEthereum'], - by: ['poweredByEthereum', 'backedByUsDollar'], - ethereum: [ - 'poweredByEthereum', - 'addEth', - 'wrapEth', - 'layeredNetworks', - 'eth2SendSell', - 'ethStakeOrWrap', - 'swapEth', + farming: ['earnInterestOnCryptocurrency'], + lending: ['earnInterestOnCryptocurrency'], + percentage: [ + 'earnInterestOnCryptocurrency', + 'fileYourCryptoTaxesOther', + 'taxDocuments', + 'interestForYou', + 'assetRefresh', + 'fileYourCryptoTaxesCheckOther', + 'earnInterest', + 'defiEarn', + ], + '%': ['earnInterestOnCryptocurrency'], + borrow: [ + 'borrowWallet', + 'cryptoWallet', + 'defiDecentralizedBorrowingLending', + 'borrowLimitsAddressed', + ], + made: ['basedInUsa', 'waitlistSignup'], + USA: ['basedInUsa'], + America: ['basedInUsa'], + fuck: ['basedInUsa'], + yeah: ['basedInUsa'], + location: ['basedInUsa'], + marker: ['basedInUsa'], + pin: ['basedInUsa'], + 'United States': ['basedInUsa'], + '': [ + 'basedInUsa', + 'cbEthWrappingUnavailable', + 'miniGift', + 'transferringCrypto', + 'saveTheDate', + 'coinbaseFees', + 'coinbaseOneBoostedCard', + 'coinbaseOneBoostedCardCB1', + 'baseCoinCryptoMedium', + 'basePiechartMedium', + 'baseChartMedium', + 'basePaycoinMedium', + 'baseCheckMedium', + 'baseErrorButterflyMedium', + 'baseMintNftMedium', + 'basePeopleMedium', + 'baseConnectMedium', + 'baseLocationMedium', + 'baseNetworkMedium', + 'baseSecurityMedium', + 'baseLoadingMedium', + 'baseErrorMedium', + 'baseDecentralizationMedium', + 'baseCoinNetworkMedium', + 'baseTargetMedium', + 'baseEmptyMedium', + 'baseSendMedium', + 'baseNftMedium', + 'baseIdMedium', + 'baseUsdcMedium', + 'baseCheckTrophyMedium', + 'baseCautionMedium', + 'baseDiamondMedium', + 'predictionsMarkets', + 'options', + 'checkVerifacation', + 'bonusTwoPercent', + 'bonusFivePercent', + 'baseRewardSun', + 'baseRewardChest', + 'baseRewardPlate', + 'baseRewardPodium', + 'baseSwitch', + 'baseRewardTrophyEmblem', + 'baseRewardTrophyStars', + 'baseRewardClam', + 'baseCreatorCoin', + 'coinbaseUnlockOffers', + 'baseQuickBuy', + 'pieChartWithArrow', + 'pieChartWithArrowBlue', + 'instantUnstaking', + 'instoWaiting', + 'instoSecurityKey', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + xtz: ['coinbaseOneStaking'], + yield: [ + 'coinbaseOneStaking', + 'backedByUsDollar', 'ethStaking', - 'ethStakeOrWrapTwo', + 'assetForward', + 'walletApp', + 'yieldCenter', + 'nuxEarnYield', + 'guideNftDefi', + 'defiRisk', + 'holdCrypto', + 'yieldCenterUSDC', + 'outage', + 'instoEthStaking', ], - eth: [ - 'poweredByEthereum', - 'ethStakingRewards', - 'layeredNetworks', + make: [ + 'coinbaseOneStaking', 'ethStaking', - 'cbEthWrappingUnavailable', + 'defiEarnAnnouncement', + 'nuxEarnCrypto', + 'nuxEarnYield', + 'instoEthStaking', + ], + increase: [ + 'coinbaseOneStaking', + 'ethStaking', + 'assetForward', + 'yieldCenter', + 'nuxEarnYield', + 'borrowLimitsAddressed', + 'instoEthStaking', + ], + reward: [ + 'coinbaseOneStaking', + 'ethStaking', + 'moneyRewards', + 'rewardExpiring', + 'yieldCenterUSDC', + 'instoEthStaking', + ], + keep: ['secureStorage'], + USDC: ['retailUSDCRewards'], + lend: ['cryptoWallet', 'defiDecentralizedBorrowingLending'], + simple: ['quickAndSimple', 'switchAdvancedToSimpleTrading'], + clock: ['quickAndSimple', 'getStartedInMinutes', 'rewardExpiring', 'futures'], + time: ['quickAndSimple', 'getStartedInMinutes', 'rewardExpiring', 'automaticPayments'], + efficient: ['quickAndSimple'], + checkmark: [ + 'quickAndSimple', + 'completeAQuiz', + 'stressTestedColdStorage', + 'confirmAddress', + 'confirmEmail', + 'confirmIDCard', + 'confirmSocialSecurity', + 'nuxChecklist', + 'fileYourCryptoTaxesCheckOther', + 'verifyEmail', + 'onTheList', + 'documentSuccess', + 'documentCertified', + ], + backed: ['backedByUsDollar'], + dollars: ['backedByUsDollar'], + US: ['backedByUsDollar'], + watch: ['watchVideos', 'startToday'], + video: ['watchVideos'], + eye: ['watchVideos'], + browser: ['watchVideos', 'browserExtension', 'switchAdvancedToSimpleTrading', 'estimatedAmount'], + window: ['watchVideos'], + button: ['watchVideos'], + incentives: ['coinbaseOneTokenRewards'], + gift: ['coinbaseOneTokenRewards', 'rewardExpiring', 'giftBoxCrypto', 'gifting', 'switchReward'], + surprise: ['coinbaseOneTokenRewards'], + '🎁': ['coinbaseOneTokenRewards', 'miniGift', 'giftBoxCrypto', 'switchReward'], + Light: ['earnToLearn'], + Bulb: ['earnToLearn'], + Earn: [ + 'earnToLearn', + 'assetForward', + 'primeEarn', + 'yieldCenter', + 'primeStaking', + 'instoPrimeStaking', + ], + Learn: ['earnToLearn'], + Coin: ['earnToLearn', 'primeEarn', 'primeDeFi', 'bigBtc', 'coinFifty'], + Make: ['earnToLearn'], + wallets: ['multipleAccountsWalletsForOneUser'], + multiple: ['multipleAccountsWalletsForOneUser', 'addMultipleCrypto'], + 'single account': ['multipleAccountsWalletsForOneUser'], + lots: ['multipleAccountsWalletsForOneUser'], + of: ['multipleAccountsWalletsForOneUser'], + start: [ + 'startToday', + 'guideCryptoBeginner', + 'guideFiveThings', + 'guideStartInvesting', + 'readyToTrade', + 'tradeImmediately', + ], + today: ['startToday', 'tradeImmediately'], + videos: ['startToday'], + calendar: ['startToday', 'nuxRecurringBuys', 'saveTheDate', 'automaticPayments'], + week: ['startToday'], + learn: ['startToday', 'nuxEarnCrypto'], + Eth: ['gasFeesNetworkFees'], + Gas: ['gasFeesNetworkFees'], + Ethereum: ['gasFeesNetworkFees'], + Fees: ['gasFeesNetworkFees'], + Network: ['gasFeesNetworkFees'], + Payment: ['gasFeesNetworkFees'], + Pump: ['gasFeesNetworkFees'], + Token: ['gasFeesNetworkFees'], + Range: ['gasFeesNetworkFees'], + extension: ['browserExtension'], + integrate: ['browserExtension'], + leverage: ['browserExtension', 'borrowLimitsAddressed'], + website: ['browserExtension'], + quiz: ['completeAQuiz'], + complete: ['completeAQuiz', 'documentSuccess'], + X: ['completeAQuiz'], + wrong: ['completeAQuiz'], + right: [ + 'completeAQuiz', + 'trendingHotAssets', + 'announcementAdvancedTrading', + 'bullishCase', + 'portfolioPerformance', ], + pencil: ['completeAQuiz'], + get: ['getStartedInMinutes', 'guideCryptoBeginner', 'freeBtc'], + started: ['getStartedInMinutes'], + stopwatch: ['getStartedInMinutes'], + going: ['getStartedInMinutes', 'guideCryptoBeginner'], + please: ['getStartedInMinutes'], + mark: ['stressTestedColdStorage', 'fileYourCryptoTaxesCheckOther'], add: [ - 'coinbaseCardLock', - 'addPhoneNumber', 'addEth', + 'addMultipleCrypto', 'walletApp', 'addCard', 'addPasswordProtection', + 'addPhoneNumber', 'coinbaseCardPocket', - 'addMultipleCrypto', + 'coinbaseCardLock', ], - plastic: ['coinbaseCardLock', 'coinbaseCardPocket'], - payment: ['coinbaseCardLock', 'coinbaseCardPocket'], - method: ['coinbaseCardLock', 'coinbaseCardPocket'], - 'art nft ❗️⚠️ offer': ['offersEmpty'], - PIX: ['pixBankDeposits', 'pixDeposits'], - Deposits: ['pixBankDeposits', 'pixDeposits'], - brazil: ['pixBankDeposits', 'pixDeposits'], - south: ['pixBankDeposits', 'pixDeposits'], - america: ['pixBankDeposits', 'pixDeposits'], - latam: ['pixBankDeposits', 'pixDeposits'], - id: ['confirmIDCard', 'didDecentralizedIdentity', 'idError'], - indentification: ['confirmIDCard'], - license: ['confirmIDCard'], - folder: ['noPortfolio', 'cryptoPortfolio'], - empty: ['noPortfolio'], - state: ['noPortfolio'], - none: ['noPortfolio'], - not: ['noPortfolio'], - no: ['noPortfolio', 'noFees'], + economy: ['cryptoEconomy', 'cryptoEconomyArrows'], + freedom: ['cryptoEconomy', 'cryptoEconomyArrows'], + economic: ['cryptoEconomy', 'cryptoEconomyArrows'], + trend: ['trendingHotAssets'], + trending: ['trendingHotAssets'], + hot: ['trendingHotAssets'], + and: ['trendingHotAssets'], + the: ['trendingHotAssets', 'portfolioPerformance'], + unavailable: ['cbEthWrappingUnavailable'], + wrapping: ['cbEthWrappingUnavailable'], + error: ['cbEthWrappingUnavailable', 'cardDeclined', 'verifyInfo', 'idError', 'outage', 'refresh'], + '2.0': ['ethStaking', 'instoEthStaking'], + mining: ['mining'], + MEV: ['mining'], + cart: ['mining'], + '➕': ['addMultipleCrypto', 'commerceInvoices'], Announcement: ['announcementAdvancedTrading'], advanced: [ 'announcementAdvancedTrading', 'accessToAdvancedCharts', + 'switchAdvancedToSimpleTrading', 'focusLimitOrders', 'advancedTradingUi', 'advancedTrading', - 'switchAdvancedToSimpleTrading', ], - trading: [ - 'announcementAdvancedTrading', - 'futures', - 'readyToTrade', - 'guideBullCase', - 'advancedTradingUi', - 'advancedTrading', - 'defiDecentralizedTradingExchange', + return: ['assetForward', 'yieldCenter'], + status: ['cardDeclined', 'addCard', 'cardBlocked'], + declined: ['cardDeclined'], + warning: ['cardDeclined', 'contactsListWarning', 'verifyInfo', 'idError', 'outage', 'refresh'], + ship: ['cardShipped'], + shipped: ['cardShipped'], + confirm: [ + 'confirmAddress', + 'confirmEmail', + 'confirmIDCard', + 'confirmSocialSecurity', + 'waitlistSignup', + 'documentSuccess', + 'coinbaseCardPocket', + 'coinbaseCardLock', ], - Lock: ['walletSecurity'], - key: ['walletSecurity', 'addPasswordProtection'], - authentication: ['walletSecurity', 'hardwareWallets'], - secure: [ - 'walletSecurity', - 'insuranceProtection', - 'secureGlobalTransactions', - 'stressTestedColdStorage', + validate: ['confirmAddress', 'confirmEmail', 'confirmIDCard', 'confirmSocialSecurity'], + address: ['confirmAddress'], + envelope: ['confirmAddress', 'confirmEmail', 'verifyEmail', 'openEmail'], + mail: ['confirmAddress'], + kyc: ['confirmAddress', 'confirmEmail', 'confirmIDCard', 'confirmSocialSecurity'], + onboarding: [ + 'confirmAddress', + 'confirmEmail', + 'confirmIDCard', + 'confirmSocialSecurity', + 'addPhoneNumber', + 'verifyEmail', 'securityShield', - 'secureStorage', - 'addPasswordProtection', - 'secureAndTrusted', - ], - cbone: ['coinbaseOneTokenRewards', 'coinbaseOneRewards'], - incentives: ['coinbaseOneTokenRewards'], - surprise: ['coinbaseOneTokenRewards'], - grow: ['invest', 'earn', 'coinbaseOneEarn', 'guideNftDefi'], - save: [ - 'invest', - 'fileYourCryptoTaxesCheckOther', - 'fileYourCryptoTaxesOther', - 'noFees', - 'holdingCrypto', ], - xtz: ['coinbaseOneStaking'], - staking: ['coinbaseOneStaking', 'staking', 'ethStakingRewards', 'defiHow', 'ethStaking'], - increase: [ - 'coinbaseOneStaking', - 'nuxEarnYield', - 'borrowLimitsAddressed', - 'ethStaking', - 'assetForward', - 'yieldCenter', - ], - 'empty frame art nft ⚠️': ['frameEmpty'], - liquid: ['staking'], - finance: ['staking', 'defiNfts', 'borrowWallet'], - Documents: ['documentSuccess'], - reviewed: ['documentSuccess', 'documentCertified'], - success: ['documentSuccess', 'readyToTrade', 'congratulationsOnEarningCrypto'], - complete: ['documentSuccess', 'completeAQuiz'], - bridge: ['bridging'], - blockchain: ['bridging', 'blockchain'], - 'one to another': ['bridging'], - '🌁': ['bridging'], - '🌉': ['bridging'], - excitement: ['boostedCard', 'coinbaseCardSparkle', 'directDepositExcitement'], - debit: ['boostedCard', 'coinbaseCardSparkle', 'cardAnnouncement'], - boost: ['boostedCard'], - boosted: ['boostedCard'], - taxes: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther', 'taxDocuments'], - file: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther', 'collectingNfts'], + email: ['confirmEmail', 'verifyEmail', 'openEmail'], + indentification: ['confirmIDCard'], + license: ['confirmIDCard'], + number: ['confirmSocialSecurity', 'guideBullCase', 'addPhoneNumber', 'phoneNumber'], + ssn: ['confirmSocialSecurity'], + defi: ['defiEarnAnnouncement', 'walletApp', 'defiNfts', 'guideNftDefi', 'defiHow', 'defiEarn'], + buy: ['defiEarnAnnouncement', 'guideStartInvesting'], + hold: ['defiEarnAnnouncement', 'holdCrypto'], + direct: ['directDepositExcitement'], + deposit: ['directDepositExcitement'], + paycheck: ['directDepositExcitement'], pay: [ - 'fileYourCryptoTaxesCheckOther', + 'directDepositExcitement', 'fileYourCryptoTaxesOther', + 'fileYourCryptoTaxesCheckOther', 'automaticPayments', - 'directDepositExcitement', ], - government: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther'], - irs: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther'], - tax: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther', 'taxDocuments'], - center: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther', 'yieldCenterUSDC'], - forms: ['fileYourCryptoTaxesCheckOther', 'fileYourCryptoTaxesOther'], - mark: ['fileYourCryptoTaxesCheckOther', 'stressTestedColdStorage'], - Referrals: ['referralsPeople'], - Friends: ['referralsPeople'], - BTC: ['referralsPeople', 'freeBtc', 'bigBtc'], - Reward: ['referralsPeople'], - Crypto: ['referralsPeople', 'bigBtc', 'primeDeFi', 'primeStaking', 'primeEarn'], - Coins: ['referralsPeople', 'bigBtc', 'primeDeFi', 'primeStaking', 'primeEarn'], - Sign: ['referralsPeople'], - Share: ['referralsPeople'], - Link: ['referralsPeople'], - Refer: ['referralsPeople'], - Friend: ['referralsPeople'], - certified: ['documentCertified'], - correct: ['documentCertified'], - ribbon: ['documentCertified'], - approved: ['documentCertified'], - stamped: ['documentCertified'], - papers: ['documentCertified'], - you: ['assetRefresh', 'waitlistSignup', 'interestForYou'], - barchart: ['accessToAdvancedCharts', 'earnInterest'], - rat: ['accessToAdvancedCharts', 'advancedTrading'], - ledger: ['cryptoAssets'], - transactions: ['cryptoAssets', 'noFees', 'secureGlobalTransactions', 'globalTransactions'], - trade: ['tradeImmediately', 'ethStakingRewards', 'defiNfts', 'guideNftDefi'], - immediately: ['tradeImmediately'], - swap: ['tradeImmediately', 'defiDecentralizedTradingExchange'], - now: ['tradeImmediately'], - today: ['tradeImmediately', 'startToday'], - umbrella: ['insuranceProtection'], - insurance: ['insuranceProtection'], - protection: ['insuranceProtection', 'addPasswordProtection'], - accounting: ['commerceAccounting'], - '⬇': ['commerceAccounting'], - Coinbase: ['coinbaseLock', 'coinbaseOneConcierge'], - 'no access': ['coinbaseLock'], - latch: ['coinbaseLock'], - '🔒': ['coinbaseLock'], - '🔐': ['coinbaseLock'], - '🔑': ['coinbaseLock'], - '🗝': ['coinbaseLock'], - nfts: ['collectingNfts', 'walletApp'], - non: ['collectingNfts', 'defiNfts'], - fungible: ['collectingNfts', 'defiNfts'], - fees: ['noFees', 'coinbaseFees'], - price: ['noFees', 'priceAlerts'], - sale: ['noFees'], - reduced: ['noFees'], - costs: ['noFees'], - world: ['secureGlobalTransactions', 'globalTransactions'], - 'peer to peer': ['secureGlobalTransactions'], - free: ['freeBtc'], - bitcoin: ['freeBtc'], - paid: ['freeBtc'], - join: ['freeBtc'], - refer: ['freeBtc'], - referral: ['freeBtc'], - Eth: ['gasFeesNetworkFees'], - Gas: ['gasFeesNetworkFees'], - Ethereum: ['gasFeesNetworkFees'], - Fees: ['gasFeesNetworkFees'], - Network: ['gasFeesNetworkFees'], - Payment: ['gasFeesNetworkFees'], - Pump: ['gasFeesNetworkFees'], - Token: ['gasFeesNetworkFees'], - Range: ['gasFeesNetworkFees'], - usdc: ['yieldCenterUSDC', 'coinbaseOneUSDC'], - proof: ['yieldCenterUSDC'], - wrap: ['addEth', 'wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo', 'coinbaseOneStakeOrWrap'], - rush: ['wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo', 'coinbaseOneStakeOrWrap'], - movement: ['wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo', 'coinbaseOneStakeOrWrap'], - forward: [ - 'wrapEth', - 'ethStakeOrWrap', - 'ethStakeOrWrapTwo', - 'coinbaseOneStakeOrWrap', + roll: ['directDepositExcitement'], + automatic: ['directDepositExcitement', 'automaticPayments'], + auto: ['directDepositExcitement'], + dca: ['directDepositExcitement', 'nuxRecurringBuys'], + celebrate: ['directDepositExcitement'], + celebration: ['directDepositExcitement'], + trade: [ + 'ethStakingRewards', + 'defiNfts', + 'guideNftDefi', + 'tradeImmediately', + 'instoEthStakingRewards', + ], + stars: ['ethStakingRewards', 'bigBtc', 'instoEthStakingRewards'], + eth2: ['ethStakingRewards', 'eth2SendSell', 'swapEth', 'instoEthStakingRewards'], + 'stacks of coins': ['ethStakingRewards', 'instoEthStakingRewards'], + taxes: ['fileYourCryptoTaxesOther', 'taxDocuments', 'fileYourCryptoTaxesCheckOther'], + government: ['fileYourCryptoTaxesOther', 'fileYourCryptoTaxesCheckOther'], + irs: ['fileYourCryptoTaxesOther', 'fileYourCryptoTaxesCheckOther'], + tax: ['fileYourCryptoTaxesOther', 'taxDocuments', 'fileYourCryptoTaxesCheckOther'], + center: ['fileYourCryptoTaxesOther', 'fileYourCryptoTaxesCheckOther', 'yieldCenterUSDC'], + forms: ['fileYourCryptoTaxesOther', 'fileYourCryptoTaxesCheckOther'], + beginner: [ + 'guideBullCase', + 'guideCryptoBeginner', + 'guideFiveThings', + 'guideStartInvesting', + 'guideNftDefi', + ], + guide: [ + 'guideBullCase', + 'guideCryptoBeginner', + 'guideFiveThings', 'guideStartInvesting', + 'guideNftDefi', ], - exciting: ['wrapEth', 'ethStakeOrWrap', 'ethStakeOrWrapTwo', 'coinbaseOneStakeOrWrap'], + bull: ['guideBullCase'], + case: ['guideBullCase', 'bullishCase'], + go: ['guideBullCase'], + here: ['guideCryptoBeginner', 'guideFiveThings', 'noPortfolio'], + doc: ['guideCryptoBeginner'], + paper: ['guideCryptoBeginner', 'taxDocuments', 'onTheList'], + do: ['guideFiveThings'], + these: ['guideFiveThings'], + things: ['guideFiveThings'], + investing: ['guideStartInvesting'], + Gift: ['miniGift'], + BRD: ['miniGift'], + box: ['miniGift', 'giftBoxCrypto'], + nft: ['miniGift', 'nft', 'walletApp', 'defiNfts', 'guideNftDefi'], cash: ['moneyRewards'], funds: ['moneyRewards', 'coinbaseOneSavingFunds'], + cat: ['nft'], + crown: ['nft'], + collectible: ['nft'], + waitlist: ['nuxChecklist', 'waitlistSignup'], + clip: ['nuxChecklist'], + board: ['nuxChecklist'], + clipboard: ['nuxChecklist', 'onTheList', 'verifyInfo', 'refresh'], + wait: ['nuxChecklist', 'waitlistSignup'], + recurring: ['nuxRecurringBuys', 'automaticPayments'], + buys: ['nuxRecurringBuys'], + recur: ['nuxRecurringBuys'], + weekly: ['nuxRecurringBuys'], + purchase: ['nuxRecurringBuys'], + repeat: ['nuxRecurringBuys'], + phone: [ + 'phoneNotifications', + 'priceAlerts', + 'refreshMobileApp', + 'appTrackingTransparency', + 'walletNotifications', + 'addPhoneNumber', + 'phoneNumber', + ], notification: ['phoneNotifications', 'priceAlerts', 'notificationsAlt'], alert: ['phoneNotifications', 'notificationsAlt'], red: ['phoneNotifications', 'performance'], - hex: ['blockchain'], - block: ['blockchain'], - chain: ['blockchain', 'sidechain', 'layeredNetworks'], - hexagon: ['sidechain'], - connections: ['sidechain'], - yellow: ['sidechain', 'shareOnSocialMedia', 'layerThree', 'outage'], - waitlist: ['waitlistSignup', 'nuxChecklist'], - signup: ['waitlistSignup'], - sign: ['waitlistSignup', 'defiRisk', 'addPasswordProtection'], - wait: ['waitlistSignup', 'nuxChecklist'], - made: ['waitlistSignup', 'basedInUsa'], - it: ['waitlistSignup'], - Light: ['earnToLearn'], - Bulb: ['earnToLearn'], - Earn: ['earnToLearn', 'primeStaking', 'primeEarn', 'assetForward', 'yieldCenter'], - Learn: ['earnToLearn'], - Coin: ['earnToLearn', 'bigBtc', 'coinFifty', 'primeDeFi', 'primeEarn'], - Make: ['earnToLearn'], + watchlist: ['priceAlerts'], + 'price alert': ['priceAlerts'], + '⭐️': ['priceAlerts'], + '📱': ['priceAlerts', 'appTrackingTransparency', 'walletNotifications'], + Prime: ['primeEarn', 'primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Rewards: ['primeEarn'], + Assets: ['primeEarn', 'primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Currency: ['primeEarn', 'bigBtc'], + Cash: ['primeEarn'], + recommend: ['recommendInvestments'], + recommended: ['recommendInvestments'], + recommendation: ['recommendInvestments'], + investments: ['recommendInvestments'], + choose: ['recommendInvestments'], + moment: ['rewardExpiring'], + record: ['rewardExpiring'], + minute: ['rewardExpiring'], + hour: ['rewardExpiring'], + day: ['rewardExpiring'], + '24 hours': ['rewardExpiring'], + expiring: ['rewardExpiring'], + end: ['rewardExpiring'], + countdown: ['rewardExpiring'], + '🕦': ['rewardExpiring'], + '🕐': ['rewardExpiring'], + '🕚': ['rewardExpiring'], + '🕥': ['rewardExpiring'], + '🕧': ['rewardExpiring'], + '🕙': ['rewardExpiring'], + '🕣': ['rewardExpiring'], + '🕠': ['rewardExpiring'], + '🕝': ['rewardExpiring'], + '🕢': ['rewardExpiring'], + '🕟': ['rewardExpiring'], + '🕜': ['rewardExpiring'], + '🕤': ['rewardExpiring'], + '🕡': ['rewardExpiring'], + '🕞': ['rewardExpiring'], + '🕘': ['rewardExpiring'], + '🕒': ['rewardExpiring'], + '🕗': ['rewardExpiring'], + '🕔': ['rewardExpiring'], + '🕑': ['rewardExpiring'], + '🕖': ['rewardExpiring'], + '🕓': ['rewardExpiring'], + '🕛': ['rewardExpiring'], + '⏰': ['rewardExpiring'], + '⏱': ['rewardExpiring'], + '🕰': ['rewardExpiring'], + '🔄': ['rewardExpiring', 'switchReward'], + '⏳': ['rewardExpiring'], + '⌛️': ['rewardExpiring'], + hodl: ['sparkleToken', 'freeBtc', 'holdCrypto'], + app: ['walletApp', 'refreshMobileApp'], + sid: ['walletApp'], + kevin: ['walletApp'], + landowners: ['walletApp'], + download: ['walletApp'], + announcement: ['cardAnnouncement'], + 'empty nft tag zero': ['nftTag'], + you: ['interestForYou', 'assetRefresh', 'waitlistSignup'], futures: ['futures'], - future: ['futures', 'earn', 'coinbaseOneEarn'], circle: ['futures', 'coinFifty'], - recurring: ['automaticPayments', 'nuxRecurringBuys'], - automatic: ['automaticPayments', 'directDepositExcitement'], + present: ['giftBoxCrypto'], + encrypted: ['encryptedEverything'], + computers: ['encryptedEverything'], + computation: ['encryptedEverything'], + confirmation: ['encryptedEverything', 'waitlistSignup', 'idError'], + plus: [ + 'addCard', + 'addPasswordProtection', + 'commerceInvoices', + 'coinbaseCardPocket', + 'coinbaseCardLock', + ], + green: ['addCard', 'performance', 'walletNotifications'], + password: ['addPasswordProtection'], + sign: ['addPasswordProtection', 'waitlistSignup', 'defiRisk'], + padlock: ['addPasswordProtection', 'securityShield'], + limits: ['borrowLimitsAddressed'], + bill: ['borrowLimitsAddressed'], + dollar: ['borrowLimitsAddressed'], + fiat: ['borrowLimitsAddressed'], + excitment: ['borrowLimitsAddressed'], + new: ['bullishCase', 'defiNfts'], + bullish: ['bullishCase'], + reload: ['cardAutoReload'], + sparkles: ['cardAutoReload', 'primeStaking', 'bigBtc', 'instoPrimeStaking'], + 'debit card': ['cardAutoReload'], + chip: ['cardAutoReload'], + blocked: ['cardBlocked', 'coinbaseLock'], + denied: ['cardBlocked'], + rejected: ['cardBlocked'], + moon: ['darkModeIntroduction', 'cryptoAndMore', 'goldSilverFutures'], + dark: ['darkModeIntroduction'], + darkmode: ['darkModeIntroduction'], + night: ['darkModeIntroduction'], + sell: ['eth2SendSell', 'swapEth'], + transfer: ['eth2SendSell', 'swapEth'], + '➡️': ['eth2SendSell', 'swapEth'], + '✅': ['fileYourCryptoTaxesCheckOther', 'verifyEmail', 'documentSuccess'], + give: ['gifting'], + friends: ['gifting'], + family: ['gifting'], + associates: ['gifting'], + performance: ['portfolioPerformance', 'performance'], + refresh: ['refreshMobileApp'], + '🗓': ['saveTheDate'], + '📅': ['saveTheDate'], + date: ['saveTheDate'], + faster: ['sendCryptoFaster'], + speed: ['sendCryptoFaster', 'lightningNetworkSend'], + bolt: ['sendCryptoFaster', 'lightningNetworkSend'], + lightning: ['sendCryptoFaster'], + '⚡️': ['sendCryptoFaster'], + exchange: ['switchReward'], + switch: ['switchReward', 'switchAdvancedToSimpleTrading', 'advancedTrading', 'tradeImmediately'], + '🪙': ['switchReward'], + signup: ['waitlistSignup'], + it: ['waitlistSignup'], + Staking: ['primeStaking', 'instoPrimeStaking'], + Stake: ['primeStaking', 'instoPrimeStaking'], + Interest: ['primeStaking', 'instoPrimeStaking'], + Circles: ['primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Universe: ['primeStaking', 'primeDeFi', 'instoPrimeStaking'], + Decentralized: ['primeDeFi'], + Finance: ['primeDeFi'], + Explore: ['primeDeFi'], + Stars: ['primeDeFi'], + coinbaseone: ['coinbaseOneDiscountedAmount'], + discounted: ['coinbaseOneDiscountedAmount'], + amount: ['coinbaseOneDiscountedAmount', 'estimatedAmount'], + bell: ['notificationsAlt'], + '🔔': ['notificationsAlt'], + '🔕': ['notificationsAlt'], + how: ['defiHow'], loan: ['automaticPayments'], - calendar: ['automaticPayments', 'startToday', 'nuxRecurringBuys', 'saveTheDate'], once: ['automaticPayments'], month: ['automaticPayments'], - checklist: ['didDecentralizedIdentity', 'guideFiveThings', 'nuxChecklist'], - did: ['didDecentralizedIdentity'], - identity: ['didDecentralizedIdentity'], - balloon: ['readyToTrade'], - welcome: ['readyToTrade'], - created: ['readyToTrade'], + barchart: ['accessToAdvancedCharts', 'earnInterest'], + rat: ['accessToAdvancedCharts', 'advancedTrading'], + ghost: ['cryptoApps'], + unicorn: ['cryptoApps'], + charts: ['cryptoApps'], + ui: ['switchAdvancedToSimpleTrading'], + change: ['switchAdvancedToSimpleTrading'], + tracking: ['appTrackingTransparency'], + transparency: ['appTrackingTransparency'], + '✔️': ['appTrackingTransparency'], + 'success state': [ + 'appTrackingTransparency', + 'bigBtc', + 'verifyEmail', + 'readyToTrade', + 'onTheList', + 'documentSuccess', + 'documentCertified', + ], + '📊': ['earnInterest', 'advancedTrading'], + '📉': ['earnInterest', 'advancedTrading'], deFi: ['defiRisk'], risk: ['defiRisk'], banner: ['defiRisk'], percent: ['defiRisk'], trust: ['defiRisk'], - watchlist: ['priceAlerts'], - 'price alert': ['priceAlerts'], - '⭐️': ['priceAlerts'], - '📱': ['priceAlerts', 'appTrackingTransparency', 'walletNotifications'], - cold: ['stressTestedColdStorage', 'hardwareWallets'], - trusted: ['stressTestedColdStorage', 'secureAndTrusted'], - Pie: ['taxesDetails'], - Doc: ['taxesDetails'], - Plus: ['taxesDetails'], - Minus: ['taxesDetails'], - Check: ['taxesDetails'], - Mark: ['taxesDetails'], - Done: ['taxesDetails'], - Taxes: ['taxesDetails'], - Details: ['taxesDetails'], - tracking: ['appTrackingTransparency'], - transparency: ['appTrackingTransparency'], - '✔️': ['appTrackingTransparency'], - stars: ['ethStakingRewards', 'bigBtc'], - eth2: ['ethStakingRewards', 'eth2SendSell', 'swapEth'], - 'stacks of coins': ['ethStakingRewards'], - Layered: ['layeredNetworks'], - Networks: ['layeredNetworks'], - layer: ['layeredNetworks'], - side: ['layeredNetworks'], Hold: ['holdingCrypto'], HODL: ['holdingCrypto'], down: ['holdingCrypto'], - started: ['getStartedInMinutes'], - stopwatch: ['getStartedInMinutes'], - please: ['getStartedInMinutes'], - Currency: ['bigBtc', 'primeEarn'], - how: ['defiHow'], + notifications: ['walletNotifications'], focus: ['focusLimitOrders'], limit: ['focusLimitOrders'], limitorders: ['focusLimitOrders'], advancedtrading: ['focusLimitOrders'], - '📊': ['earnInterest', 'advancedTrading'], - '📉': ['earnInterest', 'advancedTrading'], - learn: ['nuxEarnCrypto', 'startToday'], - notifications: ['walletNotifications'], - green: ['walletNotifications', 'performance', 'addCard'], - USA: ['basedInUsa'], - America: ['basedInUsa'], - fuck: ['basedInUsa'], - yeah: ['basedInUsa'], - location: ['basedInUsa'], - marker: ['basedInUsa'], - pin: ['basedInUsa'], - 'United States': ['basedInUsa'], - videos: ['startToday'], - week: ['startToday'], - padlock: ['securityShield', 'addPasswordProtection'], - Wallet: ['hardwareWallets', 'primeEarn'], - Hardware: ['hardwareWallets'], - Ledger: ['hardwareWallets'], - USB: ['hardwareWallets'], - quiz: ['completeAQuiz'], - X: ['completeAQuiz'], - wrong: ['completeAQuiz'], - pencil: ['completeAQuiz'], - mining: ['mining'], - MEV: ['mining'], - cart: ['mining'], - do: ['guideFiveThings'], - these: ['guideFiveThings'], - things: ['guideFiveThings'], - ship: ['cardShipped'], - shipped: ['cardShipped'], - social: ['confirmSocialSecurity', 'shareOnSocialMedia'], - ssn: ['confirmSocialSecurity'], - sell: ['eth2SendSell', 'swapEth'], - transfer: ['eth2SendSell', 'swapEth'], - '➡️': ['eth2SendSell', 'swapEth'], - buys: ['nuxRecurringBuys'], - dca: ['nuxRecurringBuys', 'directDepositExcitement'], - recur: ['nuxRecurringBuys'], - weekly: ['nuxRecurringBuys'], - purchase: ['nuxRecurringBuys'], - repeat: ['nuxRecurringBuys'], - Gift: ['miniGift'], - BRD: ['miniGift'], - app: ['walletApp', 'refreshMobileApp'], - sid: ['walletApp'], - kevin: ['walletApp'], - landowners: ['walletApp'], - download: ['walletApp'], - declined: ['cardDeclined'], - direct: ['directDepositExcitement'], - deposit: ['directDepositExcitement'], - paycheck: ['directDepositExcitement'], - roll: ['directDepositExcitement'], - auto: ['directDepositExcitement'], - celebrate: ['directDepositExcitement'], - celebration: ['directDepositExcitement'], - refresh: ['refreshMobileApp'], - share: ['shareOnSocialMedia'], - media: ['shareOnSocialMedia'], - circles: ['shareOnSocialMedia', 'coinFifty'], - bull: ['guideBullCase'], - case: ['guideBullCase', 'bullishCase'], - go: ['guideBullCase'], contacts: ['contactsListWarning'], contact: ['contactsListWarning'], '⚠': ['contactsListWarning'], - announcement: ['cardAnnouncement'], - limits: ['borrowLimitsAddressed'], - bill: ['borrowLimitsAddressed'], - dollar: ['borrowLimitsAddressed'], - fiat: ['borrowLimitsAddressed'], - excitment: ['borrowLimitsAddressed'], - keep: ['secureStorage'], - layers: ['layerThree'], - 'layer three': ['layerThree'], - three: ['layerThree'], - isometric: ['layerThree'], - base: ['layerThree'], - give: ['gifting'], - friends: ['gifting'], - family: ['gifting'], - associates: ['gifting'], - clip: ['nuxChecklist'], - board: ['nuxChecklist'], - '🗓': ['saveTheDate'], - '📅': ['saveTheDate'], - date: ['saveTheDate'], - simple: ['quickAndSimple', 'switchAdvancedToSimpleTrading'], - efficient: ['quickAndSimple'], - new: ['bullishCase', 'defiNfts'], - bullish: ['bullishCase'], - 'empty nft tag zero': ['nftTag'], - '2.0': ['ethStaking'], - coinfifty: ['coinFifty'], - fifty: ['coinFifty'], + details: ['addPhoneNumber', 'onTheList', 'coinbaseCardPocket', 'coinbaseCardLock'], + verify: ['verifyEmail', 'verifyInfo', 'refresh'], UI: ['advancedTradingUi'], candlestick: ['advancedTradingUi'], order: ['advancedTradingUi'], book: ['advancedTradingUi'], depth: ['advancedTradingUi'], - Concierge: ['coinbaseOneConcierge'], - person: ['coinbaseOneConcierge'], - attendant: ['coinbaseOneConcierge'], - logo: ['coinbaseOneLogo'], - logomark: ['coinbaseOneLogo'], - brand: ['coinbaseOneLogo'], - bad: ['idError'], - Prime: ['primeDeFi', 'primeStaking', 'primeEarn'], - DeFi: ['primeDeFi', 'defiDecentralizedTradingExchange'], - Decentralized: ['primeDeFi'], - Finance: ['primeDeFi'], - Explore: ['primeDeFi'], - Assets: ['primeDeFi', 'primeStaking', 'primeEarn'], - Universe: ['primeDeFi', 'primeStaking'], - Circles: ['primeDeFi', 'primeStaking'], - Stars: ['primeDeFi'], - Staking: ['primeStaking'], - Stake: ['primeStaking'], - Interest: ['primeStaking'], - triangle: ['outage'], - warn: ['outage'], - chest: ['walletQuestsChest'], - investing: ['guideStartInvesting'], - i18n: ['globalTransactions'], + commerce: ['commerceInvoices', 'commerceAccounting'], + invoices: ['commerceInvoices'], + '📝': ['commerceInvoices', 'commerceAccounting'], + '📄': ['commerceInvoices', 'commerceAccounting'], + '📃': ['commerceInvoices', 'commerceAccounting'], + '📑': ['commerceInvoices', 'commerceAccounting'], + '💲': ['commerceInvoices', 'coinbaseOneSavingFunds'], + balloon: ['readyToTrade'], + welcome: ['readyToTrade'], + created: ['readyToTrade'], + open: ['openEmail'], + letter: ['openEmail'], + '📧 📥 📤 ✉ 📩 📨': ['openEmail'], + free: ['freeBtc', 'inrTrade'], + bitcoin: ['freeBtc', 'inrTrade'], + paid: ['freeBtc'], + join: ['freeBtc'], + refer: ['freeBtc'], + referral: ['freeBtc'], + confirmed: ['onTheList', 'documentCertified'], + on: ['onTheList'], + waiting: ['onTheList'], + notify: ['onTheList'], + Documents: ['documentSuccess'], + reviewed: ['documentSuccess', 'documentCertified'], + accounting: ['commerceAccounting'], + '⬇': ['commerceAccounting'], + info: ['verifyInfo', 'refresh'], + information: ['verifyInfo', 'refresh'], + issue: ['verifyInfo', 'refresh'], + concern: ['verifyInfo', 'refresh'], + '⚠️': ['verifyInfo', 'idError', 'refresh'], + estimated: ['estimatedAmount'], + prices: ['estimatedAmount'], + calculation: ['estimatedAmount'], + plastic: ['coinbaseCardPocket', 'coinbaseCardLock'], + payment: ['coinbaseCardPocket', 'coinbaseCardLock'], + method: ['coinbaseCardPocket', 'coinbaseCardLock'], + 'empty state': ['cryptoAndMore', 'tradeImmediately', 'goldSilverFutures'], + asterisk: ['phoneNumber'], + certified: ['documentCertified'], + correct: ['documentCertified'], + ribbon: ['documentCertified'], + approved: ['documentCertified'], + stamped: ['documentCertified'], + papers: ['documentCertified'], + basket: ['holdCrypto'], + bowl: ['holdCrypto'], + immediately: ['tradeImmediately'], + now: ['tradeImmediately'], piggy: ['coinbaseOneSavingFunds'], pig: ['coinbaseOneSavingFunds'], - safe: ['coinbaseOneSavingFunds', 'secureAndTrusted'], saving: ['coinbaseOneSavingFunds'], '💵': ['coinbaseOneSavingFunds'], '💸': ['coinbaseOneSavingFunds'], @@ -1583,46 +1666,136 @@ const descriptionMap: Record = { '💶': ['coinbaseOneSavingFunds'], '💷': ['coinbaseOneSavingFunds'], '🐖': ['coinbaseOneSavingFunds'], + empty: ['noPortfolio'], + state: ['noPortfolio'], + none: ['noPortfolio'], + not: ['noPortfolio'], + One: [ + 'coinbaseOneUSDC', + 'coinbaseOneBoostedCard', + 'coinbaseOneConcierge', + 'coinbaseOneBoostedCardCB1', + ], + usdc: ['coinbaseOneUSDC', 'yieldCenterUSDC'], USDCoin: ['coinbaseOneUSDC'], USD: ['coinbaseOneUSDC'], - cross: ['crossBorderPayments'], - border: ['crossBorderPayments'], - backed: ['backedByUsDollar'], - dollars: ['backedByUsDollar'], - US: ['backedByUsDollar'], - Rewards: ['primeEarn'], - Cash: ['primeEarn'], - wallets: ['multipleAccountsWalletsForOneUser'], - multiple: ['multipleAccountsWalletsForOneUser', 'addMultipleCrypto'], - 'single account': ['multipleAccountsWalletsForOneUser'], - lots: ['multipleAccountsWalletsForOneUser'], - of: ['multipleAccountsWalletsForOneUser'], - ui: ['switchAdvancedToSimpleTrading'], - change: ['switchAdvancedToSimpleTrading'], - password: ['addPasswordProtection'], - bell: ['notificationsAlt'], - '🔔': ['notificationsAlt'], - '🔕': ['notificationsAlt'], - win: ['congratulationsOnEarningCrypto'], - Trust: ['secureAndTrusted'], - shield: ['secureAndTrusted'], - faster: ['sendCryptoFaster'], - lightning: ['sendCryptoFaster'], - '⚡️': ['sendCryptoFaster'], - Opt: ['optInPushNotificationsEmail'], - In: ['optInPushNotificationsEmail'], - Push: ['optInPushNotificationsEmail'], - Notifications: ['optInPushNotificationsEmail'], - Email: ['optInPushNotificationsEmail'], - Bubble: ['optInPushNotificationsEmail'], - Window: ['optInPushNotificationsEmail'], - Notify: ['optInPushNotificationsEmail'], - Account: ['optInPushNotificationsEmail'], - Security: ['optInPushNotificationsEmail'], - Prices: ['optInPushNotificationsEmail'], - return: ['assetForward', 'yieldCenter'], - unavailable: ['cbEthWrappingUnavailable'], - wrapping: ['cbEthWrappingUnavailable'], + proof: ['yieldCenterUSDC'], + Lighting: ['lightningNetworkSend'], + Lightingnetwork: ['lightningNetworkSend'], + lightingbolt: ['lightningNetworkSend'], + '⚡': ['lightningNetworkSend'], + 'wallet quests': ['walletQuestsTrophy', 'walletQuestsChest'], + '🏆': ['walletQuestsTrophy', 'walletQuestsChest'], + '⭐': ['walletQuestsTrophy', 'walletQuestsChest'], + chest: ['walletQuestsChest'], + bad: ['idError'], + triangle: ['outage'], + warn: ['outage'], + CoinbaseOne: ['coinbaseOneBoostedCard', 'coinbaseOneBoostedCardCB1'], + CoinbaseOneCard: ['coinbaseOneBoostedCard', 'coinbaseOneBoostedCardCB1'], + Coinbase: ['coinbaseLock', 'coinbaseOneConcierge'], + 'no access': ['coinbaseLock'], + latch: ['coinbaseLock'], + '🔒': ['coinbaseLock'], + '🔐': ['coinbaseLock'], + '🔑': ['coinbaseLock'], + '🗝': ['coinbaseLock'], + coinfifty: ['coinFifty'], + fifty: ['coinFifty'], + Concierge: ['coinbaseOneConcierge'], + person: ['coinbaseOneConcierge'], + attendant: ['coinbaseOneConcierge'], + layers: ['layerThree'], + 'layer three': ['layerThree'], + three: ['layerThree'], + isometric: ['layerThree'], + base: ['layerThree'], + coinbaseOneZero: ['coinbaseOneZeroPortal', 'coinbaseOneZero'], + Zero: ['coinbaseOneZeroPortal', 'coinbaseOneZero'], + insto: [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + prime: [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + negroni: [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + orange: [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + institutional: [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + 'institutional investor': [ + 'instoEthStaking', + 'instoStaking', + 'instoPrimeStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoDappWallet', + 'instoWaiting', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoUbiKey', + 'instoAuthenticatorProgress', + ], + pictogram: ['inrTrade'], + 'crypto learning': ['inrTrade'], + btc: ['inrTrade'], + satoshi: ['inrTrade'], + giveaway: ['inrTrade'], + competition: ['inrTrade'], }; export default descriptionMap; diff --git a/packages/illustrations/src/__generated__/spotSquare/data/names.ts b/packages/illustrations/src/__generated__/spotSquare/data/names.ts index 1b57feaf11..fb47cb69ca 100644 --- a/packages/illustrations/src/__generated__/spotSquare/data/names.ts +++ b/packages/illustrations/src/__generated__/spotSquare/data/names.ts @@ -158,6 +158,7 @@ const names: SpotSquareName[] = [ 'giftBoxCrypto', 'gifting', 'globalTransactions', + 'goldSilverFutures', 'guideBullCase', 'guideCryptoBeginner', 'guideFiveThings', @@ -167,6 +168,19 @@ const names: SpotSquareName[] = [ 'holdCrypto', 'holdingCrypto', 'idError', + 'inrTrade', + 'instantUnstaking', + 'instoAuthenticatorProgress', + 'instoDappWallet', + 'instoEthStaking', + 'instoEthStakingRewards', + 'instoPixDeposits', + 'instoPrimeStaking', + 'instoSecurityKey', + 'instoSideChainSide', + 'instoStaking', + 'instoUbiKey', + 'instoWaiting', 'insuranceProtection', 'interestForYou', 'invest', @@ -202,6 +216,8 @@ const names: SpotSquareName[] = [ 'performance', 'phoneNotifications', 'phoneNumber', + 'pieChartWithArrow', + 'pieChartWithArrowBlue', 'pixBankDeposits', 'pixDeposits', 'portfolioPerformance', diff --git a/packages/illustrations/src/__generated__/spotSquare/data/svgJsMap.ts b/packages/illustrations/src/__generated__/spotSquare/data/svgJsMap.ts index 4fa01c46b2..4218daca25 100644 --- a/packages/illustrations/src/__generated__/spotSquare/data/svgJsMap.ts +++ b/packages/illustrations/src/__generated__/spotSquare/data/svgJsMap.ts @@ -427,8 +427,8 @@ const svgJsMap = { dark: () => require('../svgJs/dark/cryptoEconomy-4.js').content, }, cryptoEconomyArrows: { - light: () => require('../svgJs/light/cryptoEconomyArrows-1.js').content, - dark: () => require('../svgJs/dark/cryptoEconomyArrows-1.js').content, + light: () => require('../svgJs/light/cryptoEconomyArrows-2.js').content, + dark: () => require('../svgJs/dark/cryptoEconomyArrows-2.js').content, }, cryptoForBeginners: { light: () => require('../svgJs/light/cryptoForBeginners-5.js').content, @@ -598,6 +598,10 @@ const svgJsMap = { light: () => require('../svgJs/light/globalTransactions-6.js').content, dark: () => require('../svgJs/dark/globalTransactions-6.js').content, }, + goldSilverFutures: { + light: () => require('../svgJs/light/goldSilverFutures-0.js').content, + dark: () => require('../svgJs/dark/goldSilverFutures-0.js').content, + }, guideBullCase: { light: () => require('../svgJs/light/guideBullCase-4.js').content, dark: () => require('../svgJs/dark/guideBullCase-4.js').content, @@ -634,6 +638,58 @@ const svgJsMap = { light: () => require('../svgJs/light/idError-2.js').content, dark: () => require('../svgJs/dark/idError-2.js').content, }, + inrTrade: { + light: () => require('../svgJs/light/inrTrade-0.js').content, + dark: () => require('../svgJs/dark/inrTrade-0.js').content, + }, + instantUnstaking: { + light: () => require('../svgJs/light/instantUnstaking-1.js').content, + dark: () => require('../svgJs/dark/instantUnstaking-1.js').content, + }, + instoAuthenticatorProgress: { + light: () => require('../svgJs/light/instoAuthenticatorProgress-2.js').content, + dark: () => require('../svgJs/dark/instoAuthenticatorProgress-2.js').content, + }, + instoDappWallet: { + light: () => require('../svgJs/light/instoDappWallet-1.js').content, + dark: () => require('../svgJs/dark/instoDappWallet-1.js').content, + }, + instoEthStaking: { + light: () => require('../svgJs/light/instoEthStaking-0.js').content, + dark: () => require('../svgJs/dark/instoEthStaking-0.js').content, + }, + instoEthStakingRewards: { + light: () => require('../svgJs/light/instoEthStakingRewards-0.js').content, + dark: () => require('../svgJs/dark/instoEthStakingRewards-0.js').content, + }, + instoPixDeposits: { + light: () => require('../svgJs/light/instoPixDeposits-0.js').content, + dark: () => require('../svgJs/dark/instoPixDeposits-0.js').content, + }, + instoPrimeStaking: { + light: () => require('../svgJs/light/instoPrimeStaking-0.js').content, + dark: () => require('../svgJs/dark/instoPrimeStaking-0.js').content, + }, + instoSecurityKey: { + light: () => require('../svgJs/light/instoSecurityKey-1.js').content, + dark: () => require('../svgJs/dark/instoSecurityKey-1.js').content, + }, + instoSideChainSide: { + light: () => require('../svgJs/light/instoSideChainSide-0.js').content, + dark: () => require('../svgJs/dark/instoSideChainSide-0.js').content, + }, + instoStaking: { + light: () => require('../svgJs/light/instoStaking-0.js').content, + dark: () => require('../svgJs/dark/instoStaking-0.js').content, + }, + instoUbiKey: { + light: () => require('../svgJs/light/instoUbiKey-1.js').content, + dark: () => require('../svgJs/dark/instoUbiKey-1.js').content, + }, + instoWaiting: { + light: () => require('../svgJs/light/instoWaiting-2.js').content, + dark: () => require('../svgJs/dark/instoWaiting-2.js').content, + }, insuranceProtection: { light: () => require('../svgJs/light/insuranceProtection-4.js').content, dark: () => require('../svgJs/dark/insuranceProtection-4.js').content, @@ -774,6 +830,14 @@ const svgJsMap = { light: () => require('../svgJs/light/phoneNumber-2.js').content, dark: () => require('../svgJs/dark/phoneNumber-2.js').content, }, + pieChartWithArrow: { + light: () => require('../svgJs/light/pieChartWithArrow-0.js').content, + dark: () => require('../svgJs/dark/pieChartWithArrow-0.js').content, + }, + pieChartWithArrowBlue: { + light: () => require('../svgJs/light/pieChartWithArrowBlue-0.js').content, + dark: () => require('../svgJs/dark/pieChartWithArrowBlue-0.js').content, + }, pixBankDeposits: { light: () => require('../svgJs/light/pixBankDeposits-5.js').content, dark: () => require('../svgJs/dark/pixBankDeposits-5.js').content, diff --git a/packages/illustrations/src/__generated__/spotSquare/data/versionMap.ts b/packages/illustrations/src/__generated__/spotSquare/data/versionMap.ts index c401613a2e..b44e77f371 100644 --- a/packages/illustrations/src/__generated__/spotSquare/data/versionMap.ts +++ b/packages/illustrations/src/__generated__/spotSquare/data/versionMap.ts @@ -261,8 +261,24 @@ const versionMap: Record = { baseRewardTrophyStars: 0, coinbaseUnlockOffers: 0, baseCreatorCoin: 1, - cryptoEconomyArrows: 1, + cryptoEconomyArrows: 2, baseQuickBuy: 0, + goldSilverFutures: 0, + pieChartWithArrow: 0, + pieChartWithArrowBlue: 0, + instantUnstaking: 1, + instoPrimeStaking: 0, + instoEthStakingRewards: 0, + instoStaking: 0, + instoEthStaking: 0, + instoAuthenticatorProgress: 2, + instoUbiKey: 1, + instoSideChainSide: 0, + instoSecurityKey: 1, + instoWaiting: 2, + instoDappWallet: 1, + instoPixDeposits: 0, + inrTrade: 0, }; export default versionMap; diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/cryptoEconomyArrows-1.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/cryptoEconomyArrows-2.png similarity index 100% rename from packages/illustrations/src/__generated__/spotSquare/png/dark/cryptoEconomyArrows-1.png rename to packages/illustrations/src/__generated__/spotSquare/png/dark/cryptoEconomyArrows-2.png diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/goldSilverFutures-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/goldSilverFutures-0.png new file mode 100644 index 0000000000..a387fb886e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/goldSilverFutures-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/inrTrade-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/inrTrade-0.png new file mode 100644 index 0000000000..75ba1ac576 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/inrTrade-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instantUnstaking-1.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instantUnstaking-1.png new file mode 100644 index 0000000000..ca3b9a6128 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instantUnstaking-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoAuthenticatorProgress-2.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoAuthenticatorProgress-2.png new file mode 100644 index 0000000000..689a157764 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoAuthenticatorProgress-2.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoDappWallet-1.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoDappWallet-1.png new file mode 100644 index 0000000000..82a56c88ed Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoDappWallet-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStaking-0.png new file mode 100644 index 0000000000..8cc7ff81e8 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStakingRewards-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStakingRewards-0.png new file mode 100644 index 0000000000..47a855d0e7 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoEthStakingRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPixDeposits-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPixDeposits-0.png new file mode 100644 index 0000000000..f17ebc3f30 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPixDeposits-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPrimeStaking-0.png new file mode 100644 index 0000000000..d271049b80 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSecurityKey-1.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSecurityKey-1.png new file mode 100644 index 0000000000..947c08e61b Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSecurityKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSideChainSide-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSideChainSide-0.png new file mode 100644 index 0000000000..27efd7ede2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoSideChainSide-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoStaking-0.png new file mode 100644 index 0000000000..af1fa5e58f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoUbiKey-1.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoUbiKey-1.png new file mode 100644 index 0000000000..2296944238 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoUbiKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/instoWaiting-2.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoWaiting-2.png new file mode 100644 index 0000000000..f6db1184a5 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/instoWaiting-2.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrow-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrow-0.png new file mode 100644 index 0000000000..6f6e8f733f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrow-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrowBlue-0.png b/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrowBlue-0.png new file mode 100644 index 0000000000..da2270ac55 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/dark/pieChartWithArrowBlue-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-1.png b/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-1.png deleted file mode 100644 index edcb499c45..0000000000 Binary files a/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-1.png and /dev/null differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-2.png b/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-2.png new file mode 100644 index 0000000000..f0bc09d9fb Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/cryptoEconomyArrows-2.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/goldSilverFutures-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/goldSilverFutures-0.png new file mode 100644 index 0000000000..13eb95d9e4 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/goldSilverFutures-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/inrTrade-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/inrTrade-0.png new file mode 100644 index 0000000000..0787da80f2 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/inrTrade-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instantUnstaking-1.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instantUnstaking-1.png new file mode 100644 index 0000000000..782aacc3f1 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instantUnstaking-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoAuthenticatorProgress-2.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoAuthenticatorProgress-2.png new file mode 100644 index 0000000000..c4f036e205 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoAuthenticatorProgress-2.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoDappWallet-1.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoDappWallet-1.png new file mode 100644 index 0000000000..4a17493e6e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoDappWallet-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStaking-0.png new file mode 100644 index 0000000000..d3b8a43f37 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStakingRewards-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStakingRewards-0.png new file mode 100644 index 0000000000..f28392d55e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoEthStakingRewards-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoPixDeposits-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoPixDeposits-0.png new file mode 100644 index 0000000000..8f5b391f7f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoPixDeposits-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoPrimeStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoPrimeStaking-0.png new file mode 100644 index 0000000000..3f7e59f4c3 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoPrimeStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoSecurityKey-1.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoSecurityKey-1.png new file mode 100644 index 0000000000..5e743cad1e Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoSecurityKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoSideChainSide-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoSideChainSide-0.png new file mode 100644 index 0000000000..5b85e60a4a Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoSideChainSide-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoStaking-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoStaking-0.png new file mode 100644 index 0000000000..beecb8f5de Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoStaking-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoUbiKey-1.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoUbiKey-1.png new file mode 100644 index 0000000000..0ae940ee29 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoUbiKey-1.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/instoWaiting-2.png b/packages/illustrations/src/__generated__/spotSquare/png/light/instoWaiting-2.png new file mode 100644 index 0000000000..a3a5afe857 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/instoWaiting-2.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrow-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrow-0.png new file mode 100644 index 0000000000..76495bbb4f Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrow-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrowBlue-0.png b/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrowBlue-0.png new file mode 100644 index 0000000000..b27e523619 Binary files /dev/null and b/packages/illustrations/src/__generated__/spotSquare/png/light/pieChartWithArrowBlue-0.png differ diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/cryptoEconomyArrows-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/cryptoEconomyArrows-2.svg similarity index 100% rename from packages/illustrations/src/__generated__/spotSquare/svg/dark/cryptoEconomyArrows-1.svg rename to packages/illustrations/src/__generated__/spotSquare/svg/dark/cryptoEconomyArrows-2.svg diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/goldSilverFutures-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/goldSilverFutures-0.svg new file mode 100644 index 0000000000..7d693d51b7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/goldSilverFutures-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/inrTrade-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/inrTrade-0.svg new file mode 100644 index 0000000000..b5ee2a6202 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instantUnstaking-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instantUnstaking-1.svg new file mode 100644 index 0000000000..16e6480282 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instantUnstaking-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoAuthenticatorProgress-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoAuthenticatorProgress-2.svg new file mode 100644 index 0000000000..2b26f36e24 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoAuthenticatorProgress-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoDappWallet-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoDappWallet-1.svg new file mode 100644 index 0000000000..0efffac494 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoDappWallet-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStaking-0.svg new file mode 100644 index 0000000000..a5826ed04d --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..163d384cac --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPixDeposits-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPixDeposits-0.svg new file mode 100644 index 0000000000..e9a6328638 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPixDeposits-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..6080c94a42 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSecurityKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSecurityKey-1.svg new file mode 100644 index 0000000000..de87d04cc8 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSecurityKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSideChainSide-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSideChainSide-0.svg new file mode 100644 index 0000000000..bc81897832 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoSideChainSide-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoStaking-0.svg new file mode 100644 index 0000000000..058d0f3220 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoUbiKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoUbiKey-1.svg new file mode 100644 index 0000000000..8ef9c1f4d9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoUbiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoWaiting-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoWaiting-2.svg new file mode 100644 index 0000000000..714e3cd180 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/instoWaiting-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..47139527e5 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..8b346ffb99 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/dark/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-1.svg deleted file mode 100644 index 55c618f082..0000000000 --- a/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-2.svg new file mode 100644 index 0000000000..ec1578bd48 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/cryptoEconomyArrows-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/goldSilverFutures-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/goldSilverFutures-0.svg new file mode 100644 index 0000000000..47b6987825 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/goldSilverFutures-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/inrTrade-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/inrTrade-0.svg new file mode 100644 index 0000000000..341bd42bdd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instantUnstaking-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instantUnstaking-1.svg new file mode 100644 index 0000000000..1bbfe9aa33 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instantUnstaking-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoAuthenticatorProgress-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoAuthenticatorProgress-2.svg new file mode 100644 index 0000000000..9d52136fb5 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoAuthenticatorProgress-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoDappWallet-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoDappWallet-1.svg new file mode 100644 index 0000000000..ed360f50ce --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoDappWallet-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStaking-0.svg new file mode 100644 index 0000000000..1824b4e276 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..87be624e50 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPixDeposits-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPixDeposits-0.svg new file mode 100644 index 0000000000..dad73b8310 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPixDeposits-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..b5b0a265b3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSecurityKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSecurityKey-1.svg new file mode 100644 index 0000000000..172454a280 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSecurityKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSideChainSide-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSideChainSide-0.svg new file mode 100644 index 0000000000..f7e25b21b0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoSideChainSide-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoStaking-0.svg new file mode 100644 index 0000000000..2fddb156bd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoUbiKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoUbiKey-1.svg new file mode 100644 index 0000000000..af59608a7a --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoUbiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/instoWaiting-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoWaiting-2.svg new file mode 100644 index 0000000000..d50c43a4c6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/instoWaiting-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..138fa17e14 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..09c4251079 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/light/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-1.svg deleted file mode 100644 index e7e3b9e7d7..0000000000 --- a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-2.svg new file mode 100644 index 0000000000..5732f2eede --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/cryptoEconomyArrows-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/goldSilverFutures-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/goldSilverFutures-0.svg new file mode 100644 index 0000000000..fc48f6f26f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/goldSilverFutures-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/inrTrade-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/inrTrade-0.svg new file mode 100644 index 0000000000..b7d90b0107 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/inrTrade-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instantUnstaking-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instantUnstaking-1.svg new file mode 100644 index 0000000000..50a788ed5b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instantUnstaking-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoAuthenticatorProgress-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoAuthenticatorProgress-2.svg new file mode 100644 index 0000000000..132e91e560 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoAuthenticatorProgress-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoDappWallet-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoDappWallet-1.svg new file mode 100644 index 0000000000..c7e8b879bc --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoDappWallet-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStaking-0.svg new file mode 100644 index 0000000000..2454ec9aee --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStakingRewards-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStakingRewards-0.svg new file mode 100644 index 0000000000..1c505dfbe2 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoEthStakingRewards-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPixDeposits-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPixDeposits-0.svg new file mode 100644 index 0000000000..392f13fd93 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPixDeposits-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPrimeStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPrimeStaking-0.svg new file mode 100644 index 0000000000..1f13bcafca --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoPrimeStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSecurityKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSecurityKey-1.svg new file mode 100644 index 0000000000..c46acff915 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSecurityKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSideChainSide-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSideChainSide-0.svg new file mode 100644 index 0000000000..f5a1d4cad0 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoSideChainSide-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoStaking-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoStaking-0.svg new file mode 100644 index 0000000000..1c1e022186 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoStaking-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoUbiKey-1.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoUbiKey-1.svg new file mode 100644 index 0000000000..64e2fc40d6 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoUbiKey-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoWaiting-2.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoWaiting-2.svg new file mode 100644 index 0000000000..028724c4c9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/instoWaiting-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrow-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrow-0.svg new file mode 100644 index 0000000000..4c8f96d8d3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrow-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrowBlue-0.svg b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrowBlue-0.svg new file mode 100644 index 0000000000..342159cfec --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svg/themeable/pieChartWithArrowBlue-0.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/cryptoEconomyArrows-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/cryptoEconomyArrows-2.js similarity index 100% rename from packages/illustrations/src/__generated__/spotSquare/svgJs/dark/cryptoEconomyArrows-1.js rename to packages/illustrations/src/__generated__/spotSquare/svgJs/dark/cryptoEconomyArrows-2.js diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/goldSilverFutures-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/goldSilverFutures-0.js new file mode 100644 index 0000000000..d845587bb3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/goldSilverFutures-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/inrTrade-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/inrTrade-0.js new file mode 100644 index 0000000000..eed4d50c65 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/inrTrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instantUnstaking-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instantUnstaking-1.js new file mode 100644 index 0000000000..790e5afe4b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instantUnstaking-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoAuthenticatorProgress-2.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoAuthenticatorProgress-2.js new file mode 100644 index 0000000000..6476b9c1e9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoAuthenticatorProgress-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoDappWallet-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoDappWallet-1.js new file mode 100644 index 0000000000..1f23142db8 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoDappWallet-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStaking-0.js new file mode 100644 index 0000000000..3a9e6ed394 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStakingRewards-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStakingRewards-0.js new file mode 100644 index 0000000000..bfb32966c9 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoEthStakingRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPixDeposits-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPixDeposits-0.js new file mode 100644 index 0000000000..f92364a6fe --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPixDeposits-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPrimeStaking-0.js new file mode 100644 index 0000000000..1b8c78ac35 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSecurityKey-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSecurityKey-1.js new file mode 100644 index 0000000000..c85a36b641 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSecurityKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSideChainSide-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSideChainSide-0.js new file mode 100644 index 0000000000..24e40cc618 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoSideChainSide-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoStaking-0.js new file mode 100644 index 0000000000..67341dd992 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoUbiKey-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoUbiKey-1.js new file mode 100644 index 0000000000..744b1e8ab3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoUbiKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoWaiting-2.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoWaiting-2.js new file mode 100644 index 0000000000..6f170a79ae --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/instoWaiting-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrow-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrow-0.js new file mode 100644 index 0000000000..00dece95b8 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrow-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrowBlue-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrowBlue-0.js new file mode 100644 index 0000000000..d986c0f5fb --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/dark/pieChartWithArrowBlue-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-1.js deleted file mode 100644 index 6294bfcb3b..0000000000 --- a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-1.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - content: ``, -}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-2.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-2.js new file mode 100644 index 0000000000..31074510dd --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/cryptoEconomyArrows-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/goldSilverFutures-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/goldSilverFutures-0.js new file mode 100644 index 0000000000..6b6c20992c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/goldSilverFutures-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/inrTrade-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/inrTrade-0.js new file mode 100644 index 0000000000..3a03b57f83 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/inrTrade-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instantUnstaking-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instantUnstaking-1.js new file mode 100644 index 0000000000..a1dae4ca4b --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instantUnstaking-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoAuthenticatorProgress-2.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoAuthenticatorProgress-2.js new file mode 100644 index 0000000000..2738ab5a17 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoAuthenticatorProgress-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoDappWallet-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoDappWallet-1.js new file mode 100644 index 0000000000..1917d77800 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoDappWallet-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStaking-0.js new file mode 100644 index 0000000000..3a323eeecf --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStakingRewards-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStakingRewards-0.js new file mode 100644 index 0000000000..a516ce3a57 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoEthStakingRewards-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPixDeposits-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPixDeposits-0.js new file mode 100644 index 0000000000..a7fcfd9fa3 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPixDeposits-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPrimeStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPrimeStaking-0.js new file mode 100644 index 0000000000..002b0bfc11 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoPrimeStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSecurityKey-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSecurityKey-1.js new file mode 100644 index 0000000000..104aa7e20c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSecurityKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSideChainSide-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSideChainSide-0.js new file mode 100644 index 0000000000..2db38455c7 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoSideChainSide-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoStaking-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoStaking-0.js new file mode 100644 index 0000000000..0559b75e3f --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoStaking-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoUbiKey-1.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoUbiKey-1.js new file mode 100644 index 0000000000..5f28e37658 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoUbiKey-1.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoWaiting-2.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoWaiting-2.js new file mode 100644 index 0000000000..0b46fc6205 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/instoWaiting-2.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrow-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrow-0.js new file mode 100644 index 0000000000..ca1e19525c --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrow-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrowBlue-0.js b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrowBlue-0.js new file mode 100644 index 0000000000..00b25d4036 --- /dev/null +++ b/packages/illustrations/src/__generated__/spotSquare/svgJs/light/pieChartWithArrowBlue-0.js @@ -0,0 +1,3 @@ +module.exports = { + content: ``, +}; diff --git a/packages/illustrations/src/__generated__/spotSquare/types/SpotSquareName.ts b/packages/illustrations/src/__generated__/spotSquare/types/SpotSquareName.ts index c5d56b6c7e..0581f7e644 100644 --- a/packages/illustrations/src/__generated__/spotSquare/types/SpotSquareName.ts +++ b/packages/illustrations/src/__generated__/spotSquare/types/SpotSquareName.ts @@ -152,6 +152,7 @@ export type SpotSquareName = | 'giftBoxCrypto' | 'gifting' | 'globalTransactions' + | 'goldSilverFutures' | 'guideBullCase' | 'guideCryptoBeginner' | 'guideFiveThings' @@ -161,6 +162,19 @@ export type SpotSquareName = | 'holdCrypto' | 'holdingCrypto' | 'idError' + | 'inrTrade' + | 'instantUnstaking' + | 'instoAuthenticatorProgress' + | 'instoDappWallet' + | 'instoEthStaking' + | 'instoEthStakingRewards' + | 'instoPixDeposits' + | 'instoPrimeStaking' + | 'instoSecurityKey' + | 'instoSideChainSide' + | 'instoStaking' + | 'instoUbiKey' + | 'instoWaiting' | 'insuranceProtection' | 'interestForYou' | 'invest' @@ -196,6 +210,8 @@ export type SpotSquareName = | 'performance' | 'phoneNotifications' | 'phoneNumber' + | 'pieChartWithArrow' + | 'pieChartWithArrowBlue' | 'pixBankDeposits' | 'pixDeposits' | 'portfolioPerformance' diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 27ee7700ac..ca01edbd51 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,264 @@ All notable changes to this project will be documented in this file. +## 8.66.0 ((4/16/2026, 01:57 PM PST)) + +This is an artificial version bump with no new change. + +## 8.65.0 ((4/16/2026, 10:06 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.5 ((4/16/2026, 06:50 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.4 ((4/10/2026, 01:20 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.3 ((4/8/2026, 05:54 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.2 ((4/8/2026, 11:26 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.1 ((4/7/2026, 12:46 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.0 ((4/2/2026, 07:51 AM PST)) + +This is an artificial version bump with no new change. + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.62.1 ((4/1/2026, 12:25 PM PST)) + +This is an artificial version bump with no new change. + +## 8.62.0 ((3/30/2026, 06:52 PM PST)) + +This is an artificial version bump with no new change. + +## 8.61.0 ((3/30/2026, 02:40 PM PST)) + +This is an artificial version bump with no new change. + +## 8.60.0 ((3/29/2026, 10:49 AM PST)) + +This is an artificial version bump with no new change. + +## 8.59.0 ((3/27/2026, 05:43 AM PST)) + +This is an artificial version bump with no new change. + +## 8.58.0 ((3/25/2026, 11:42 AM PST)) + +This is an artificial version bump with no new change. + +## 8.57.1 ((3/24/2026, 01:14 PM PST)) + +This is an artificial version bump with no new change. + +## 8.57.0 ((3/24/2026, 12:46 PM PST)) + +This is an artificial version bump with no new change. + +## 8.56.1 ((3/24/2026, 08:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.56.0 ((3/23/2026, 06:31 AM PST)) + +This is an artificial version bump with no new change. + +## 8.55.1 ((3/22/2026, 01:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.55.0 ((3/19/2026, 01:41 PM PST)) + +This is an artificial version bump with no new change. + +## 8.54.0 ((3/18/2026, 02:27 PM PST)) + +This is an artificial version bump with no new change. + +## 8.53.1 ((3/17/2026, 10:58 AM PST)) + +This is an artificial version bump with no new change. + +## 8.53.0 ((3/16/2026, 01:45 PM PST)) + +This is an artificial version bump with no new change. + +## 8.52.2 ((3/11/2026, 10:02 AM PST)) + +This is an artificial version bump with no new change. + +## 8.52.1 ((3/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + +## 8.52.0 ((3/10/2026, 08:50 AM PST)) + +This is an artificial version bump with no new change. + +## 8.51.0 ((3/9/2026, 06:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.50.0 ((3/6/2026, 09:36 AM PST)) + +This is an artificial version bump with no new change. + +## 8.49.2 ((3/6/2026, 09:04 AM PST)) + +This is an artificial version bump with no new change. + +## 8.49.1 ((3/5/2026, 03:13 PM PST)) + +This is an artificial version bump with no new change. + +## 8.49.0 ((2/26/2026, 04:03 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.3 ((2/25/2026, 08:36 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.2 ((2/25/2026, 04:21 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 ((2/24/2026, 10:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.4 ((2/23/2026, 03:04 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.1 ((2/19/2026, 01:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.0 ((2/19/2026, 08:05 AM PST)) + +This is an artificial version bump with no new change. + +## 8.46.1 ((2/12/2026, 01:01 PM PST)) + +This is an artificial version bump with no new change. + +## 8.46.0 ((2/12/2026, 11:34 AM PST)) + +This is an artificial version bump with no new change. + +## 8.45.0 ((2/12/2026, 07:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.44.2 ((2/10/2026, 08:38 AM PST)) + +This is an artificial version bump with no new change. + +## 8.44.1 ((2/10/2026, 12:05 PM PST)) + +This is an artificial version bump with no new change. + +## 8.44.0 ((2/9/2026, 07:07 PM PST)) + +This is an artificial version bump with no new change. + +## 8.43.2 ((2/9/2026, 09:05 AM PST)) + +This is an artificial version bump with no new change. + +## 8.43.1 ((2/6/2026, 02:15 PM PST)) + +This is an artificial version bump with no new change. + +## 8.43.0 ((2/6/2026, 9:00 AM PST)) + +This is an artificial version bump with no new change. + +## 8.42.0 ((2/4/2026, 01:51 PM PST)) + +This is an artificial version bump with no new change. + +## 8.41.0 ((2/4/2026, 09:22 AM PST)) + +This is an artificial version bump with no new change. + +## 8.40.2 ((2/2/2026, 11:25 AM PST)) + +This is an artificial version bump with no new change. + +## 8.40.1 ((1/30/2026, 04:58 PM PST)) + +This is an artificial version bump with no new change. + +## 8.40.0 ((1/28/2026, 11:12 AM PST)) + +This is an artificial version bump with no new change. + +## 8.39.1 ((1/28/2026, 06:48 AM PST)) + +This is an artificial version bump with no new change. + +## 8.39.0 ((1/27/2026, 11:17 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.7 ((1/26/2026, 10:28 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.6 (1/23/2026 PST) + +#### 🐞 Fixes + +- Chore: align version with web package. + +## 8.38.5 ((1/23/2026, 06:35 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.4 ((1/22/2026, 01:55 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.3 ((1/22/2026, 01:42 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.2 ((1/22/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.1 ((1/15/2026, 10:22 AM PST)) + +This is an artificial version bump with no new change. + ## 8.38.0 ((1/14/2026, 01:30 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index f94e8f9bc0..80a031e2c6 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.38.0", + "version": "8.66.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile-visreg/.gitignore b/packages/mobile-visreg/.gitignore new file mode 100644 index 0000000000..2757ae025d --- /dev/null +++ b/packages/mobile-visreg/.gitignore @@ -0,0 +1,2 @@ +flows/capture-all.yaml +maestro-test-output/ \ No newline at end of file diff --git a/packages/mobile-visreg/README.md b/packages/mobile-visreg/README.md new file mode 100644 index 0000000000..1c26475990 --- /dev/null +++ b/packages/mobile-visreg/README.md @@ -0,0 +1,196 @@ +# @coinbase/mobile-visreg + +Shared visual regression (visreg) testing package for CDS mobile apps. Orchestrates [Maestro](https://maestro.mobile.dev/) flows to screenshot component routes via deep-linking and uses [BrowserStack App Percy](https://percy.io) to upload and compare them visually across builds. + +## Responsibilities + +This package is responsible for: + +- **Defining which component routes are visreg-enabled** via `config/enabled-routes.mjs` (an explicit opt-in list — new routes are not included automatically) +- **Generating Maestro flow YAML** that sequences all enabled routes into a single capture run (`src/generate-flows.mjs`) +- **Orchestrating screenshot capture** by driving the target app through deep-links, waiting for animations to settle, and calling Maestro's `takeScreenshot` for each route (`src/run.mjs`) +- **Uploading screenshots to Percy** for visual comparison across branches/builds (`src/upload.mjs`) +- **Installing Maestro CLI** on developer machines (`src/setup.mjs`) + +## How it works + +1. Maestro launches the app on a simulator and navigates to each component route via deep-link (`:///Debug`) +2. After animations settle, `takeScreenshot` saves a PNG named `_` to the output directory +3. The `upload` target sends the full screenshot directory to BrowserStack App Percy +4. Percy diffs the new screenshots against the baseline (typically `master`) and surfaces any visual regressions in its dashboard + +## Package structure + +``` +packages/mobile-visreg/ + config/ + enabled-routes.mjs # Explicit opt-in list of routes to visreg + src/ + config.mjs # Re-exports enabled routes + default settings + generate-flows.mjs # Generates flows/capture-all.yaml from the route list + run.mjs # Orchestrator CLI — generates flows, invokes Maestro + setup.mjs # Maestro CLI installer + upload.mjs # Percy upload CLI + flows/ + capture-route.yaml # Single-route Maestro flow (used for --route iteration) + capture-route-steps.yaml # Sub-flow used by capture-all.yaml for each route + capture-all.yaml # Auto-generated — do not edit (git-ignored) + visreg-screenshots/ # Local screenshot output directory (git-ignored) +``` + +## Nx targets + +All targets are run from the repo root via `yarn nx run mobile-visreg:`. + +| Target | Command | Description | +| --------- | ----------------------------------- | ------------------------------------------------------ | +| `setup` | `yarn nx run mobile-visreg:setup` | Install Maestro CLI (one-time) | +| `ios` | `yarn nx run mobile-visreg:ios` | Capture screenshots from the CDS mobile app on iOS | +| `android` | `yarn nx run mobile-visreg:android` | Capture screenshots from the CDS mobile app on Android | +| `upload` | `yarn nx run mobile-visreg:upload` | Upload screenshots to BrowserStack App Percy | + +## Prerequisites + +- **macOS with Xcode** — required for the iOS simulator +- **Android Studio** — required for the Android emulator +- **Maestro CLI** — installed via `yarn nx run mobile-visreg:setup` +- **BrowserStack App Percy account** — a project token (`PERCY_TOKEN`) is needed to upload + +If `maestro` is not found on PATH after installation, add it to your shell: + +```bash +export PATH="$PATH:$HOME/.maestro/bin" +``` + +Add that line to your shell profile (`~/.zshrc` or `~/.bashrc`) to make it permanent. + +## Local dev workflow + +### 1. Install dependencies (one-time) + +```bash +yarn install +``` + +### 2. Install Maestro (one-time) + +```bash +yarn nx run mobile-visreg:setup +``` + +### 3. Build and install the target app + +> **Important**: Use the **release** build, not debug. Debug builds use the Expo Dev Client shell which intercepts deep links before React Navigation can handle them, preventing navigation to component routes. + +```bash +yarn nx run mobile-app:build:ios-release +yarn nx run mobile-app:launch:ios-release +``` + +### 4. Capture screenshots + +```bash +# iOS +yarn nx run mobile-visreg:ios + +# Android +yarn nx run mobile-visreg:android +``` + +Screenshots are saved to `packages/mobile-visreg/visreg-screenshots/`. + +### 5. Upload to Percy + +```bash +export PERCY_TOKEN=app_xxxxxxxxxxxxxxxx +yarn nx run mobile-visreg:upload +``` + +## Adding new component routes + +Routes must be explicitly opted in to visreg. To add a new route: + +1. Open `config/enabled-routes.mjs` +2. Add the route name (must match the debug route name registered in the app) to the `enabledRoutes` array +3. Verify the deep-link works: `xcrun simctl openurl booted cds:///Debug` +4. Run `yarn nx run mobile-visreg:ios` and confirm a screenshot is captured for the new route + +## Single-route iteration + +For fast iteration on a single component, run only that route without regenerating the full flow: + +```bash +# Via the Maestro CLI directly +cd packages/mobile-visreg +maestro test flows/capture-route.yaml \ + --env APP_ID=com.ui-systems.ios-release-hermes \ + --env SCHEME=cds \ + --env ROUTE_NAME=Button \ + --env PLATFORM_SUFFIX=_ios + +# Via run.mjs +node src/run.mjs \ + --appId com.ui-systems.ios-release-hermes \ + --scheme cds \ + --route Button \ + --output ./visreg-screenshots +``` + +## BrowserStack App Percy setup + +### 1. Sign in + +Go to [percy.io](https://percy.io) and sign in with your BrowserStack credentials. + +### 2. Create a new project + +- Click **"Create new project"** +- Select platform: **"Mobile App"** +- Name: e.g. `CDS Mobile Visreg` +- Baseline management: **Git** (recommended) +- Optionally link to the GitHub repository + +### 3. Copy the `PERCY_TOKEN` + +After project creation, Percy shows a write-only token starting with `app_`. Copy it. + +### 4. Set the token locally + +```bash +export PERCY_TOKEN=app_xxxxxxxxxxxxxxxx +``` + +### 5. Upload screenshots + +```bash +yarn nx run mobile-visreg:upload +``` + +### 6. Review builds + +Visit the project dashboard at percy.io. The first upload establishes the baseline. Subsequent uploads are compared against the baseline, with visual diffs highlighted for review. + +### Baseline management + +- Builds on the default branch (`master`) auto-approve and become the new baseline +- Builds on feature branches compare against the latest `master` baseline +- Set `PERCY_BRANCH` to control which branch the build is associated with +- Set `PERCY_TARGET_BRANCH` to control the comparison baseline (defaults to `master`) + +### Useful environment variables + +| Variable | Purpose | +| ---------------------- | -------------------------------------------------------- | +| `PERCY_TOKEN` | Required. Project write-only API token | +| `PERCY_BRANCH` | Branch name for this build (default: current git branch) | +| `PERCY_TARGET_BRANCH` | Baseline branch to compare against (default: `master`) | +| `PERCY_COMMIT` | Git commit SHA to associate with the build | +| `PERCY_PARALLEL_TOTAL` | Number of parallel shards (for parallel uploads) | + +## Verification checklist + +1. Build the iOS release app and install it on a simulator +2. Verify deep-linking: `xcrun simctl openurl booted cds:///DebugButton` +3. Run `yarn nx run mobile-visreg:ios` — confirm screenshots appear in `visreg-screenshots/` +4. Verify screenshots show the correct component (not the component list or a blank screen) +5. Set `PERCY_TOKEN` and run `yarn nx run mobile-visreg:upload` — verify the build appears in the Percy dashboard diff --git a/packages/mobile-visreg/config/enabled-routes.mjs b/packages/mobile-visreg/config/enabled-routes.mjs new file mode 100644 index 0000000000..01d9795924 --- /dev/null +++ b/packages/mobile-visreg/config/enabled-routes.mjs @@ -0,0 +1,61 @@ +// Routes whose stories open an overlay (modal, alert, tray, drawer, etc.) +// via an "Open" button. These use a separate sub-flow that taps Open before +// the screenshot and Cancel after. +export const overlayRoutes = new Set([ + 'AlertBasic', + 'DrawerLeft', + 'DrawerTop', + 'StickyFooter', + 'TrayBasic', + 'ModalBasic', +]); + +export const enabledRoutes = [ + 'Accordion', + 'AlertBasic', + 'AlphaSelect', + 'AlphaSelectChip', + 'AlphaTabbedChips', + 'AreaChart', + 'Avatar', + 'AvatarButton', + 'Axis', + 'Banner', + 'BarChart', + 'Box', + 'BrowserBar', + 'Button', + 'ButtonGroup', + 'Card', + 'Carousel', + 'CartesianChart', + 'Checkbox', + 'CheckboxCell', + 'Chip', + 'Coachmark', + 'Combobox', + 'ControlGroup', + 'Divider', + 'Dot', + 'DrawerLeft', + 'DrawerTop', + 'Group', + 'InputChip', + 'InputStack', + 'Legend', + 'Link', + 'ListCell', + 'ModalBasic', + 'Pressable', + 'RadioCell', + 'SelectChip', + 'SlideButton', + 'StepperHorizontal', + 'StepperVertical', + 'StickyFooter', + 'TrayBasic', + 'Switch', + 'Tabs', + 'Tag', + 'Text', +]; diff --git a/packages/mobile-visreg/flows/capture-overlay-route-steps.yaml b/packages/mobile-visreg/flows/capture-overlay-route-steps.yaml new file mode 100644 index 0000000000..f482cf25a4 --- /dev/null +++ b/packages/mobile-visreg/flows/capture-overlay-route-steps.yaml @@ -0,0 +1,17 @@ +appId: ${APP_ID} +--- +# Sub-flow for overlay routes (alerts, modals, trays, drawers, etc.) +# Required env vars: ROUTE_NAME, PLATFORM_SUFFIX, SCHEME (inherited from capture-all.yaml env) + +- openLink: ${SCHEME}:///Debug${ROUTE_NAME} +- assertVisible: + id: mobile-playground-screen +- waitForAnimationToEnd +# Open overlay, capture, then close before next route +- tapOn: 'Open' +- waitForAnimationToEnd +- takeScreenshot: ${ROUTE_NAME}${PLATFORM_SUFFIX} +- tapOn: + text: 'Cancel' + optional: true +- waitForAnimationToEnd diff --git a/packages/mobile-visreg/flows/capture-route-steps.yaml b/packages/mobile-visreg/flows/capture-route-steps.yaml new file mode 100644 index 0000000000..b063f52188 --- /dev/null +++ b/packages/mobile-visreg/flows/capture-route-steps.yaml @@ -0,0 +1,11 @@ +appId: ${APP_ID} +--- +# Sub-flow: navigate to a single route via deep link, capture a screenshot. +# Called from capture-all.yaml for each route via runFlow. +# Required env vars: ROUTE_NAME, PLATFORM_SUFFIX, SCHEME (inherited from capture-all.yaml env) + +- openLink: ${SCHEME}:///Debug${ROUTE_NAME} +- assertVisible: + id: mobile-playground-screen +- waitForAnimationToEnd +- takeScreenshot: ${ROUTE_NAME}${PLATFORM_SUFFIX} diff --git a/packages/mobile-visreg/flows/capture-route.yaml b/packages/mobile-visreg/flows/capture-route.yaml new file mode 100644 index 0000000000..2d1434d1ad --- /dev/null +++ b/packages/mobile-visreg/flows/capture-route.yaml @@ -0,0 +1,14 @@ +appId: ${APP_ID} +--- +- launchApp: + appId: ${APP_ID} +- assertVisible: + text: 'CDS' +- runFlow: + file: ./dismiss-deep-link-dialog.yaml + label: 'Dismiss deep link dialog' +- openLink: ${SCHEME}:///Debug${ROUTE_NAME} +- assertVisible: + id: mobile-playground-screen +- waitForAnimationToEnd +- takeScreenshot: ${ROUTE_NAME}${PLATFORM_SUFFIX} diff --git a/packages/mobile-visreg/flows/dismiss-deep-link-dialog.yaml b/packages/mobile-visreg/flows/dismiss-deep-link-dialog.yaml new file mode 100644 index 0000000000..c7f871460b --- /dev/null +++ b/packages/mobile-visreg/flows/dismiss-deep-link-dialog.yaml @@ -0,0 +1,9 @@ +appId: ${APP_ID} +--- +# Dismiss the iOS "Open in CDS?" system dialog on the first deep link of a session. +# Uses a dummy path so the app stays on the home screen — the dialog is triggered +# by the scheme, not the path. Subsequent deep links don't show the dialog. +- openLink: ${SCHEME}:///dismiss +- tapOn: + text: 'Open' + optional: true diff --git a/packages/mobile-visreg/package.json b/packages/mobile-visreg/package.json new file mode 100644 index 0000000000..8e7805390d --- /dev/null +++ b/packages/mobile-visreg/package.json @@ -0,0 +1,15 @@ +{ + "name": "@coinbase/mobile-visreg", + "version": "1.0.0", + "private": true, + "description": "Reusable Maestro + Percy visual regression testing for CDS mobile apps", + "repository": { + "type": "git", + "url": "git@github.com:coinbase/cds.git", + "directory": "packages/mobile-visreg" + }, + "type": "module", + "dependencies": { + "@percy/cli": "^1.31.1" + } +} diff --git a/packages/mobile-visreg/project.json b/packages/mobile-visreg/project.json new file mode 100644 index 0000000000..5deab1d16b --- /dev/null +++ b/packages/mobile-visreg/project.json @@ -0,0 +1,38 @@ +{ + "name": "mobile-visreg", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/mobile-visreg/src", + "projectType": "library", + "targets": { + "setup": { + "command": "node ./src/setup.mjs", + "options": { + "cwd": "packages/mobile-visreg" + } + }, + "ios": { + "command": "node ./src/run.mjs --appId com.ui-systems.ios-release-hermes --scheme cds --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", + "options": { + "cwd": "packages/mobile-visreg" + } + }, + "upload": { + "command": "node ./src/upload.mjs", + "options": { + "cwd": "packages/mobile-visreg" + } + }, + "should-run": { + "command": "node ./scripts/shouldRunVisreg.mjs", + "options": { + "cwd": "packages/mobile-visreg" + } + } + } +} diff --git a/packages/mobile-visreg/scripts/shouldRunVisreg.mjs b/packages/mobile-visreg/scripts/shouldRunVisreg.mjs new file mode 100644 index 0000000000..897511a080 --- /dev/null +++ b/packages/mobile-visreg/scripts/shouldRunVisreg.mjs @@ -0,0 +1,6 @@ +import { shouldRunVisreg } from '../../../scripts/ci/shouldRunVisreg.mjs'; + +const RELEVANT_ROOTS = ['packages/common', 'packages/mobile', 'packages/mobile-visualization']; + +if (!shouldRunVisreg(RELEVANT_ROOTS)) process.exit(1); +process.exit(0); diff --git a/packages/mobile-visreg/src/config.mjs b/packages/mobile-visreg/src/config.mjs new file mode 100644 index 0000000000..db45449561 --- /dev/null +++ b/packages/mobile-visreg/src/config.mjs @@ -0,0 +1,19 @@ +import { enabledRoutes, overlayRoutes } from '../config/enabled-routes.mjs'; + +/** + * Returns the explicit whitelist of routes to run visreg against. + * Routes must be opted in via enabled-routes.mjs — new routes are not included automatically. + */ +export function getVisregRoutes() { + return [...enabledRoutes]; +} + +export function isOverlayRoute(route) { + return overlayRoutes.has(route); +} + +export const defaults = { + settleTimeMs: 2000, + screenshotDir: 'screenshots', + platform: 'ios', +}; diff --git a/packages/mobile-visreg/src/generate-flows.mjs b/packages/mobile-visreg/src/generate-flows.mjs new file mode 100644 index 0000000000..8cc6b5cba8 --- /dev/null +++ b/packages/mobile-visreg/src/generate-flows.mjs @@ -0,0 +1,49 @@ +import { writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { getVisregRoutes, isOverlayRoute } from './config.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const outputPath = resolve(__dirname, '../flows/capture-all.yaml'); + +const sorted = getVisregRoutes().sort(); + +const routeSteps = sorted + .map((route, index) => { + const file = isOverlayRoute(route) + ? './capture-overlay-route-steps.yaml' + : './capture-route-steps.yaml'; + + // Dismiss the iOS "Open in CDS?" dialog on the first deep link only. + // The simulator remembers the approval for the rest of the session. + const dismissDialog = + index === 0 + ? ` +- runFlow: + file: ./dismiss-deep-link-dialog.yaml + label: "Dismiss deep link dialog"` + : ''; + + return `${dismissDialog} +- runFlow: + file: ${file} + label: "Route: ${route}" + env: + ROUTE_NAME: ${route}`; + }) + .join('\n'); + +const yaml = `# AUTO-GENERATED — do not edit +# Run: node src/generate-flows.mjs +appId: \${APP_ID} +--- +- launchApp: + appId: \${APP_ID} +- assertVisible: + text: CDS +- waitForAnimationToEnd +${routeSteps} +`; + +writeFileSync(outputPath, yaml, 'utf8'); +console.log(`Generated flows/capture-all.yaml with ${sorted.length} routes`); diff --git a/packages/mobile-visreg/src/run.mjs b/packages/mobile-visreg/src/run.mjs new file mode 100644 index 0000000000..732d6ed02c --- /dev/null +++ b/packages/mobile-visreg/src/run.mjs @@ -0,0 +1,69 @@ +import { execSync } from 'child_process'; +import { mkdirSync, readdirSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(__dirname, '..'); + +function parseArgs() { + const args = process.argv.slice(2); + const result = {}; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + result[args[i].slice(2)] = args[i + 1]; + i++; + } + } + return result; +} + +const parsed = parseArgs(); +const { appId, scheme, output = './visreg-screenshots', route, platform = 'ios' } = parsed; +const platformSuffix = parsed['platform-suffix'] ?? ''; + +if (!appId) { + console.error('Error: --appId is required'); + process.exit(1); +} +if (!scheme) { + console.error('Error: --scheme is required'); + process.exit(1); +} + +const outputDir = resolve(output); +mkdirSync(outputDir, { recursive: true }); + +let flowPath; +if (route) { + flowPath = resolve(packageRoot, 'flows/capture-route.yaml'); + console.log(`Running single-route capture: ${route}`); +} else { + console.log('Generating capture-all.yaml...'); + execSync(`node src/generate-flows.mjs ${platform}`, { cwd: packageRoot, stdio: 'inherit' }); + flowPath = resolve(packageRoot, 'flows/capture-all.yaml'); + console.log('Running full visreg capture...'); +} + +const envFlags = [`APP_ID=${appId}`, `SCHEME=${scheme}`, `PLATFORM_SUFFIX=${platformSuffix}`]; +if (route) { + envFlags.push(`ROUTE_NAME=${route}`); +} + +const testOutputDir = resolve(packageRoot, 'maestro-test-output'); +mkdirSync(testOutputDir, { recursive: true }); +const cmd = [ + 'maestro', + 'test', + '--test-output-dir', + testOutputDir, + flowPath, + ...envFlags.map((e) => `--env ${e}`), +].join(' '); + +console.log(`\nRunning: ${cmd}\n`); +execSync(cmd, { stdio: 'inherit', cwd: outputDir }); + +const screenshotDir = resolve(testOutputDir, 'screenshots'); +const screenshots = readdirSync(screenshotDir).filter((f) => f.endsWith('.png')); +console.log(`\nCapture complete: ${screenshots.length} screenshots in ${screenshotDir}`); diff --git a/packages/mobile-visreg/src/setup.mjs b/packages/mobile-visreg/src/setup.mjs new file mode 100644 index 0000000000..ecb836b712 --- /dev/null +++ b/packages/mobile-visreg/src/setup.mjs @@ -0,0 +1,25 @@ +import { execSync } from 'child_process'; + +function isMaestroInstalled() { + try { + execSync('which maestro', { stdio: 'pipe' }); + return true; + } catch { + return false; + } +} + +const maestroBin = `${process.env.HOME}/.maestro/bin/maestro`; + +if (isMaestroInstalled()) { + const version = execSync('maestro --version', { encoding: 'utf8' }).trim(); + console.log(`Maestro is already installed: ${version}`); +} else { + console.log('Installing Maestro CLI...'); + execSync('curl -Ls "https://get.maestro.mobile.dev" | bash', { stdio: 'inherit' }); + + const version = execSync(`${maestroBin} --version`, { encoding: 'utf8' }).trim(); + console.log(`\nMaestro installed successfully: ${version}`); + console.log('\nTo use maestro in your shell, open a new terminal or run:'); + console.log(' export PATH="$PATH:$HOME/.maestro/bin"'); +} diff --git a/packages/mobile-visreg/src/upload.mjs b/packages/mobile-visreg/src/upload.mjs new file mode 100644 index 0000000000..c17cf66f45 --- /dev/null +++ b/packages/mobile-visreg/src/upload.mjs @@ -0,0 +1,29 @@ +import { execSync } from 'child_process'; +import { resolve } from 'path'; + +function parseArgs() { + const args = process.argv.slice(2); + const result = {}; + for (let i = 0; i < args.length; i++) { + if (args[i].startsWith('--')) { + result[args[i].slice(2)] = args[i + 1]; + i++; + } + } + return result; +} + +const { dir = './maestro-test-output/screenshots' } = parseArgs(); + +if (!process.env.PERCY_TOKEN) { + console.error('Error: PERCY_TOKEN environment variable is not set'); + console.error('Set it with: export PERCY_TOKEN=app_xxxxxxxxxxxxxxxx'); + process.exit(1); +} + +const screenshotDir = resolve(dir); +console.log(`Uploading screenshots from ${screenshotDir} to Percy...`); + +execSync(`npx percy upload ${screenshotDir}`, { stdio: 'inherit' }); + +console.log('\nUpload complete. Visit percy.io to review the build.'); diff --git a/packages/mobile-visualization/CHANGELOG.md b/packages/mobile-visualization/CHANGELOG.md index dd5fe4e6c3..6555fa18a1 100644 --- a/packages/mobile-visualization/CHANGELOG.md +++ b/packages/mobile-visualization/CHANGELOG.md @@ -8,10 +8,148 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 3.7.0 (4/20/2026 PST) + +#### 🚀 Updates + +- Feat: add chart baseline support. [[#502](https://github.com/coinbase/cds/pull/502)] + +## 3.6.2 (4/20/2026 PST) + +#### 🐞 Fixes + +- Fix: bar chart enter animation clipping. [[#631](https://github.com/coinbase/cds/pull/631)] + +## 3.6.1 (4/16/2026 PST) + +#### 🐞 Fixes + +- Fix: stabilize chart path transitions. [[#618](https://github.com/coinbase/cds/pull/618)] + +## 3.6.0 (4/13/2026 PST) + +#### 🚀 Updates + +- Add PercentageBarChart component. [[#550](https://github.com/coinbase/cds/pull/550)] + +## 3.5.0 (4/13/2026 PST) + +#### 🚀 Updates + +- Feat: add enter opacity transition to bars. [[#612](https://github.com/coinbase/cds/pull/612)] + +## 3.4.0 (4/1/2026 PST) + +#### 🐞 Fixes + +- Remove usage of Array.prototype.at(). [[#575](https://github.com/coinbase/cds/pull/575)] + +## 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 + +- Fix bar enter and update animation. [[#540](https://github.com/coinbase/cds/pull/540)] + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + +## 3.4.0-beta.24 (3/12/2026 PST) + +#### 🚀 Updates + +- Improve chart accessibility. [[#492](https://github.com/coinbase/cds/pull/492)] + +## 3.4.0-beta.23 (3/10/2026 PST) + +#### 🚀 Updates + +- Add layout prop on CartesianChart. [[#483](https://github.com/coinbase/cds/pull/483)] + +## 3.4.0-beta.22 (3/4/2026 PST) + +#### 🚀 Updates + +- Improve PeriodSelector types. [[#464](https://github.com/coinbase/cds/pull/464)] +- Skip null path transitions. [[#464](https://github.com/coinbase/cds/pull/464)] +- Fix path transition on incompatible paths. [[#464](https://github.com/coinbase/cds/pull/464)] + +## 3.4.0-beta.21 (3/2/2026 PST) + +#### 🚀 Updates + +- Fix issues with animations that spread props. [[#463](https://github.com/coinbase/cds/pull/463)] + +## 3.4.0-beta.20 (2/27/2026 PST) + +#### 🚀 Updates + +- Add styles props to PeriodSelector. [[#438](https://github.com/coinbase/cds/pull/438/)] + +#### 📘 Misc + +- Update outdated doc links. [[#440](https://github.com/coinbase/cds/pull/440)] + +## 3.4.0-beta.19 (2/20/2026 PST) + +#### 🚀 Updates + +- Support custom enter transitions [[#400](https://github.com/coinbase/cds/pull/400/)] + +## 3.4.0-beta.18 (2/6/2026 PST) + +#### 🚀 Updates + +- Fix line chart enter animations not properly syncing with scrubber. [[#374](https://github.com/coinbase/cds/pull/374)] + +## 3.4.0-beta.17 (2/4/2026 PST) + +#### 🚀 Updates + +- Add support preferred side for scrubber beacon label group. [[#366](https://github.com/coinbase/cds/pull/366)] + +## 3.4.0-beta.16 (1/28/2026 PST) + +#### 🐞 Fixes + +- Fix every context rendering a second time in CDS Chart for performance. [[#339](https://github.com/avocado-cb/cds/pull/339)] [DEX2-874] + +## 3.4.0-beta.15 (1/27/2026 PST) + +#### 🐞 Fixes + +- Fix padding on PeriodSelector. [[#330](https://github.com/coinbase/cds/pull/330)] + +## 3.4.0-beta.14 (1/22/2026 PST) + +#### 🚀 Updates + +- Add chart Legend component. [[#302](https://github.com/coinbase/cds/pull/302)] +- Add support for hideBeaconLabels in Scrubber. [[#302](https://github.com/coinbase/cds/pull/302)] +- Add support for custom bar components. [[#302](https://github.com/coinbase/cds/pull/302)] + +## 3.4.0-beta.13 (1/20/2026 PST) + +#### 🚀 Updates + +- Feat: support styling default scrubber beacon. [[#315](https://github.com/coinbase/cds/pull/315)] +- Fix: idlePulse works on mobile even when Chart animation is off, matching web. [[#315](https://github.com/coinbase/cds/pull/315)] #### 📘 Misc +- Internal: code connect file lint fixes. [[#311](https://github.com/coinbase/cds/pull/311)] - Internal: update figma code connect config and some mapping files. [[#304](https://github.com/coinbase/cds/pull/304)] ## 3.4.0-beta.12 (1/8/2026 PST) diff --git a/packages/mobile-visualization/package.json b/packages/mobile-visualization/package.json index 046df8cffa..e2cb34e043 100644 --- a/packages/mobile-visualization/package.json +++ b/packages/mobile-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile-visualization", - "version": "3.4.0-beta.12", + "version": "3.7.0", "description": "Coinbase Design System - Mobile Visualization Native", "repository": { "type": "git", diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx index 1cce171975..767f356086 100644 --- a/packages/mobile-visualization/src/chart/CartesianChart.tsx +++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx @@ -6,34 +6,62 @@ 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 { + ScrubberAccessibilityView, + type ScrubberAccessibilityViewProps, +} from './scrubber/ScrubberAccessibilityView'; import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { convertToSerializableScale, type SerializableScale } from './utils/scale'; import { useChartContextBridge } from './ChartContextBridge'; import { CartesianChartProvider } from './ChartProvider'; +import { Legend } from './legend'; import { - type AxisConfig, - type AxisConfigProps, + type CartesianAxisConfig, + type CartesianAxisConfigProps, type CartesianChartContextValue, + type CartesianChartLayout, type ChartInset, type ChartScaleFunction, defaultAxisId, - defaultChartInset, + defaultHorizontalLayoutChartInset, + defaultVerticalLayoutChartInset, getAxisConfig, - getAxisDomain, getAxisRange, - getAxisScale, + getCartesianAxisDomain, + getCartesianAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type LegendPosition, type Series, useTotalAxisPadding, } from './utils'; +type ChartCanvasProps = Pick< + CartesianChartProps, + 'accessible' | 'accessibilityLabel' | 'accessibilityLiveRegion' +> & { + children: React.ReactNode; + style?: StyleProp; +}; + const ChartCanvas = memo( - ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => { + ({ + children, + style, + accessible = true, + accessibilityLabel, + accessibilityLiveRegion = 'polite', + }: ChartCanvasProps) => { const ContextBridge = useChartContextBridge(); + const isAccessible = accessible && accessibilityLabel !== null; return ( - + {children} ); @@ -47,23 +75,51 @@ export type CartesianChartBaseProps = Omit & * Each series contains its own data array. */ series?: Array; + /** + * Chart layout - describes the direction bars/areas grow. + * - 'vertical' (default): Bars grow vertically. X is category axis, Y is value axis. + * - 'horizontal': Bars grow horizontally. Y is category axis, X is value axis. + * @default 'vertical' + */ + layout?: CartesianChartLayout; /** * Whether to animate the chart. * @default true */ animate?: boolean; /** - * Configuration for x-axis. + * Configuration for x-axis(es). Can be a single config or array of configs. + * + * @note Multiple x-axis configs are only supported when `layout="horizontal"`. */ - xAxis?: Partial>; + xAxis?: Partial | Partial[]; /** * Configuration for y-axis(es). Can be a single config or array of configs. + * + * @note Multiple y-axis configs are only supported when `layout="vertical"`. */ - yAxis?: Partial | Partial[]; + yAxis?: Partial | Partial[]; /** * Inset around the entire chart (outside the axes). */ inset?: number | Partial; + /** + * Whether to show the legend or a custom legend element. + * - `true` renders the default Legend component + * - A React element renders that element as the legend + * - `false` or omitted hides the legend + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; + /** + * Accessibility label for the legend group. + * @default 'Legend' + */ + legendAccessibilityLabel?: string; }; export type CartesianChartProps = CartesianChartBaseProps & @@ -81,6 +137,15 @@ export type CartesianChartProps = CartesianChartBaseProps & * If not provided, the only available fonts will be those defined by the system. */ fontProvider?: SkTypefaceFontProvider; + /** + * Function that returns the accessibility label for each scrubber point. + * Receives `dataIndex` for each scrubber point label. + */ + getScrubberAccessibilityLabel?: ScrubberAccessibilityViewProps['accessibilityLabel']; + /** + * Number of data points to move between screen-reader samples. + */ + scrubberAccessibilityLabelStep?: number; /** * Custom styles for the root element. */ @@ -106,12 +171,18 @@ export const CartesianChart = memo( { series, children, + layout = 'vertical', animate = true, enableScrubbing, + getScrubberAccessibilityLabel, + scrubberAccessibilityLabelStep, xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, onScrubberPositionChange, + legend, + legendPosition = 'bottom', + legendAccessibilityLabel, width = '100%', height = '100%', style, @@ -123,6 +194,9 @@ export const CartesianChart = memo( // to group children, which interferes with gesture-handler // https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/gesture-detector/#:~:text=%7B%0A%20%20return%20%3C-,View,-collapsable%3D%7B collapsable = false, + accessible = true, + accessibilityLabel, + accessibilityLiveRegion = 'polite', ...props }, ref, @@ -132,12 +206,34 @@ export const CartesianChart = memo( const chartWidth = containerLayout.width; const chartHeight = containerLayout.height; - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => + getChartInset( + inset, + layout === 'horizontal' + ? defaultHorizontalLayoutChartInset + : defaultVerticalLayoutChartInset, + ), + [inset, layout], + ); - // there can only be one x axis but the helper function always returns an array - const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); + const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp), [xAxisConfigProp]); const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); + // Horizontal layout supports multiple value axes on x, but only a single category axis on y. + // Vertical layout keeps a single x-axis to preserve existing behavior. + if (layout === 'horizontal' && yAxisConfig.length > 1) { + throw new Error( + 'When layout="horizontal", only one y-axis is supported. See https://cds.coinbase.com/components/charts/CartesianChart.', + ); + } + + if (layout !== 'horizontal' && xAxisConfig.length > 1) { + throw new Error( + 'Multiple x-axes are only supported when layout="horizontal". See https://cds.coinbase.com/components/charts/CartesianChart.', + ); + } + const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); const totalInset = useMemo( @@ -164,54 +260,78 @@ export const CartesianChart = memo( }; }, [chartHeight, chartWidth, totalInset]); - const { xAxis, xScale } = useMemo(() => { + const { xAxes, xScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) - return { xAxis: undefined, xScale: undefined }; - - const domain = getAxisDomain(xAxisConfig, series ?? [], 'x'); - const range = getAxisRange(xAxisConfig, chartRect, 'x'); - - const axisConfig: AxisConfig = { - scaleType: xAxisConfig.scaleType, - domain, - range, - data: xAxisConfig.data, - categoryPadding: xAxisConfig.categoryPadding, - domainLimit: xAxisConfig.domainLimit, - }; + return { xAxes: axes, xScales: scales }; - // Create the scale - const scale = getAxisScale({ - config: axisConfig, - type: 'x', - range: axisConfig.range, - dataDomain: axisConfig.domain, - }); + xAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; - if (!scale) return { xAxis: undefined, xScale: undefined }; + // Get relevant series data. + const relevantSeries = + xAxisConfig.length > 1 + ? (series?.filter((s) => (s.xAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); - // Update axis config with actual scale domain (after .nice() or other adjustments) - const scaleDomain = scale.domain(); - const actualDomain = - Array.isArray(scaleDomain) && scaleDomain.length === 2 - ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } - : axisConfig.domain; + // Calculate domain and range. + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'x', layout); + const range = getAxisRange(axisParam, chartRect, 'x'); - const finalAxisConfig = { - ...axisConfig, - domain: actualDomain, - }; + const axisConfig: CartesianAxisConfig = { + scaleType: axisParam.scaleType, + domain: dataDomain, + range, + data: axisParam.data, + categoryPadding: axisParam.categoryPadding, + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'nice' : 'strict'), + baseline: axisParam.baseline, + }; - return { xAxis: finalAxisConfig, xScale: scale }; - }, [xAxisConfig, series, chartRect]); + // Create the scale. + const scale = getCartesianAxisScale({ + config: axisConfig, + type: 'x', + range: axisConfig.range, + dataDomain: axisConfig.domain, + layout, + }); - const xSerializableScale = useMemo(() => { - if (!xScale) return; - return convertToSerializableScale(xScale); - }, [xScale]); + if (scale) { + scales.set(axisId, scale); + + // Update axis config with actual scale domain (after .nice() or other adjustments). + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; + + axes.set(axisId, { + ...axisConfig, + domain: actualDomain, + }); + } + }); + + return { xAxes: axes, xScales: scales }; + }, [xAxisConfig, series, chartRect, layout]); + + // We need a set of serialized scales usable in UI thread. + const xSerializableScales = useMemo(() => { + const serializableScales = new Map(); + xScales.forEach((scale, id) => { + const serializableScale = convertToSerializableScale(scale); + if (serializableScale) { + serializableScales.set(id, serializableScale); + } + }); + return serializableScales; + }, [xScales]); const { yAxes, yScales } = useMemo(() => { - const axes = new Map(); + const axes = new Map(); const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { yAxes: axes, yScales: scales }; @@ -219,35 +339,39 @@ export const CartesianChart = memo( yAxisConfig.forEach((axisParam) => { const axisId = axisParam.id ?? defaultAxisId; - // Get relevant series data + // Get relevant series data. const relevantSeries = - series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []; + yAxisConfig.length > 1 + ? (series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); - // Calculate domain and range - const dataDomain = getAxisDomain(axisParam, relevantSeries, 'y'); + // Calculate domain and range. + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y', layout); const range = getAxisRange(axisParam, chartRect, 'y'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: axisParam.scaleType, domain: dataDomain, range, data: axisParam.data, categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? 'nice', + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'strict' : 'nice'), + baseline: axisParam.baseline, }; - // Create the scale - const scale = getAxisScale({ + // Create the scale. + const scale = getCartesianAxisScale({ config: axisConfig, type: 'y', range: axisConfig.range, dataDomain: axisConfig.domain, + layout, }); if (scale) { scales.set(axisId, scale); - // Update axis config with actual scale domain (after .nice() or other adjustments) + // Update axis config with actual scale domain (after .nice() or other adjustments). const scaleDomain = scale.domain(); const actualDomain = Array.isArray(scaleDomain) && scaleDomain.length === 2 @@ -262,7 +386,7 @@ export const CartesianChart = memo( }); return { yAxes: axes, yScales: scales }; - }, [yAxisConfig, series, chartRect]); + }, [yAxisConfig, series, chartRect, layout]); // We need a set of serialized scales usable in UI thread const ySerializableScales = useMemo(() => { @@ -276,11 +400,14 @@ export const CartesianChart = memo( return serializableScales; }, [yScales]); - const getXAxis = useCallback(() => xAxis, [xAxis]); + const getXAxis = useCallback((id?: string) => xAxes.get(id ?? defaultAxisId), [xAxes]); const getYAxis = useCallback((id?: string) => yAxes.get(id ?? defaultAxisId), [yAxes]); - const getXScale = useCallback(() => xScale, [xScale]); + const getXScale = useCallback((id?: string) => xScales.get(id ?? defaultAxisId), [xScales]); const getYScale = useCallback((id?: string) => yScales.get(id ?? defaultAxisId), [yScales]); - const getXSerializableScale = useCallback(() => xSerializableScale, [xSerializableScale]); + const getXSerializableScale = useCallback( + (id?: string) => xSerializableScales.get(id ?? defaultAxisId), + [xSerializableScales], + ); const getYSerializableScale = useCallback( (id?: string) => ySerializableScales.get(id ?? defaultAxisId), [ySerializableScales], @@ -292,8 +419,8 @@ export const CartesianChart = memo( const stackedDataMap = useMemo(() => { if (!series) return new Map>(); - return calculateStackedSeriesData(series); - }, [series]); + return calculateStackedSeriesData(series, layout, xAxisConfig, yAxisConfig); + }, [series, layout, xAxisConfig, yAxisConfig]); const getStackedSeriesData = useCallback( (seriesId?: string) => { @@ -303,19 +430,29 @@ export const CartesianChart = memo( [stackedDataMap], ); + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxisConfig = useMemo(() => { + return categoryAxisIsX + ? (xAxisConfig[0] ?? yAxisConfig[0]) + : (yAxisConfig[0] ?? xAxisConfig[0]); + }, [categoryAxisIsX, xAxisConfig, yAxisConfig]); + const dataLength = useMemo(() => { - // If xAxis has categorical data, use that length - if (xAxisConfig.data && xAxisConfig.data.length > 0) { - return xAxisConfig.data.length; + // If category axis has categorical data, use that length. + if (categoryAxisConfig.data && categoryAxisConfig.data.length > 0) { + return categoryAxisConfig.data.length; } - // Otherwise, find the longest series + // Otherwise, find the longest series. if (!series || series.length === 0) return 0; return series.reduce((max, s) => { const seriesData = getStackedSeriesData(s.id); return Math.max(max, seriesData?.length ?? 0); }, 0); - }, [xAxisConfig.data, series, getStackedSeriesData]); + }, [categoryAxisConfig, series, getStackedSeriesData]); const getAxisBounds = useCallback( (axisId: string): Rect | undefined => { @@ -382,6 +519,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + layout, series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -403,6 +541,7 @@ export const CartesianChart = memo( getAxisBounds, }), [ + layout, series, getSeries, getStackedSeriesData, @@ -429,6 +568,32 @@ export const CartesianChart = memo( return [style, styles?.root]; }, [style, styles?.root]); + const legendElement = useMemo(() => { + if (!legend) return; + + if (legend === true) { + const isHorizontal = legendPosition === 'top' || legendPosition === 'bottom'; + const flexDirection = isHorizontal ? 'row' : 'column'; + + return ( + + ); + } + + return legend; + }, [legend, legendAccessibilityLabel, legendPosition]); + + const rootBoxProps: BoxProps = useMemo( + () => ({ + ref, + height, + style: rootStyles, + width, + ...props, + }), + [ref, height, rootStyles, width, props], + ); + return ( - - {children} - + {legend ? ( + + {(legendPosition === 'top' || legendPosition === 'left') && legendElement} + + + {children} + + + + {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} + + ) : ( + + + {children} + + + + )} ); diff --git a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx index a5910e492e..89db490bf8 100644 --- a/packages/mobile-visualization/src/chart/ChartContextBridge.tsx +++ b/packages/mobile-visualization/src/chart/ChartContextBridge.tsx @@ -6,6 +6,21 @@ import * as React from 'react'; import type ReactReconciler from 'react-reconciler'; +import { ThemeContext } from '@coinbase/cds-mobile/system/ThemeProvider'; + +import { ScrubberContext } from './utils/context'; +import { CartesianChartContext } from './ChartProvider'; + +/** + * Whitelist of contexts that should be bridged to the Skia canvas. + * 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, +]; /** * Represents a react-internal tree node. @@ -122,7 +137,7 @@ export type ContextMap = Map, any> & { }; /** - * Returns a map of all contexts and their values. + * Returns a map of whitelisted contexts and their values. */ function useContextMap(): ContextMap { const treeNode = useTreeNode(); @@ -137,7 +152,12 @@ function useContextMap(): ContextMap { const enableRenderableContext = (node.type as any)._context === undefined && (node.type as any).Provider === node.type; const context = enableRenderableContext ? node.type : (node.type as any)._context; - if (context && context !== TreeNodeContext && !contextMap.has(context)) { + if ( + context && + context !== TreeNodeContext && + BRIDGED_CONTEXTS.includes(context) && + !contextMap.has(context) + ) { // eslint-disable-next-line react-hooks/rules-of-hooks contextMap.set(context, React.useContext(wrapContext(context))); } diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile-visualization/src/chart/ChartProvider.tsx index 4d255c8858..7491d0989a 100644 --- a/packages/mobile-visualization/src/chart/ChartProvider.tsx +++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx @@ -2,13 +2,15 @@ import { createContext, useContext } from 'react'; import type { CartesianChartContextValue } from './utils'; -const CartesianChartContext = createContext(undefined); +export const CartesianChartContext = createContext( + undefined, +); export const useCartesianChartContext = (): CartesianChartContextValue => { const context = useContext(CartesianChartContext); if (!context) { throw new Error( - 'useCartesianChartContext must be used within a CartesianChart component. See http://cds.coinbase.com/components/graphs/CartesianChart.', + 'useCartesianChartContext must be used within a CartesianChart component. See https://cds.coinbase.com/components/charts/CartesianChart.', ); } return context; diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile-visualization/src/chart/Path.tsx index d00da62557..37dacc4d13 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo } from 'react'; +import { memo, useEffect, useMemo, useRef } from 'react'; import { useDerivedValue, useSharedValue, withTiming } from 'react-native-reanimated'; import type { Rect } from '@coinbase/cds-common/types'; import { @@ -10,8 +10,14 @@ import { usePathInterpolation, } from '@shopify/react-native-skia'; -import type { Transition } from './utils/transition'; -import { usePathTransition } from './utils/transition'; +import { defaultPathEnterTransition } from './utils/path'; +import { + buildTransition, + defaultTransition, + getTransition, + type Transition, + usePathTransition, +} from './utils/transition'; import { useCartesianChartContext } from './ChartProvider'; import { unwrapAnimatedValue } from './utils'; @@ -70,6 +76,48 @@ export type PathProps = PathBaseProps & | 'style' | 'transform' > & { + /** + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + * + * @default transitions = {{ + * enter: { type: 'timing', duration: 500 }, + * enterOpacity: undefined, + * update: { type: 'spring', stiffness: 900, damping: 120 } + * }} + * + * @example + * // Custom enter and update transitions + * transitions={{ enter: { type: 'timing', duration: 300 }, update: { type: 'spring', damping: 20 } }} + * + * @example + * // Disable enter animation + * transitions={{ enter: null }} + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for the initial enter opacity animation. + * When provided, path opacity animates from 0 to 1. + * Set to `null` to disable. + */ + enterOpacity?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + */ + transition?: Transition; /** * The SVG path data string. */ @@ -90,21 +138,11 @@ export type PathProps = PathBaseProps & * Will be overridden by clipPath if set. */ clipRect?: Rect; - /** - * Animation transition - * - * @example - * // Duration based - * transition={{ type: 'timing', duration: 300 }} - * - * @example - * // Spring based - * transition={{ type: 'spring', damping: 20, stiffness: 300 }} - */ - transition?: Transition; }; -const AnimatedPath = memo>( +const AnimatedPath = memo< + Omit +>( ({ d = '', initialPath, @@ -116,17 +154,15 @@ const AnimatedPath = memo { const isDAnimated = typeof d !== 'string'; - // When d is animated, usePathTransition handles static path transitions. - // For animated d values, we skip usePathTransition and use useDerivedValue directly. const animatedPath = usePathTransition({ currentPath: isDAnimated ? '' : d, initialPath, - transition, + transitions, }); const isFilled = fill !== undefined && fill !== 'none'; @@ -187,6 +223,7 @@ export const Path = memo((props) => { strokeCap, strokeJoin, children, + transitions, transition, ...pathProps } = props; @@ -195,50 +232,105 @@ export const Path = memo((props) => { const rect = clipRect ?? context.drawingArea; const animate = animateProp ?? context.animate; + const isReady = !!context.getXScale(); + + const enterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultPathEnterTransition), + [animate, transitions?.enter], + ); + + const updateTransition = useMemo( + () => + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + [animate, transitions?.update, transition], + ); + + const enterOpacityTransition = useMemo(() => { + if (!animate) return null; + return transitions?.enterOpacity; + }, [animate, transitions?.enterOpacity]); + const animateEnterOpacity = Boolean(enterOpacityTransition); + const enterOpacity = useSharedValue(animateEnterOpacity ? 0 : 1); + const hasAnimatedEnterOpacity = useRef(false); + + useEffect(() => { + if (hasAnimatedEnterOpacity.current) { + return; + } + + if (!animateEnterOpacity) { + hasAnimatedEnterOpacity.current = true; + enterOpacity.value = 1; + return; + } + + if (!isReady) { + return; + } + + if (enterOpacityTransition === undefined || enterOpacityTransition === null) { + enterOpacity.value = 1; + hasAnimatedEnterOpacity.current = true; + return; + } + + hasAnimatedEnterOpacity.current = true; + enterOpacity.value = buildTransition(1, enterOpacityTransition); + }, [animateEnterOpacity, isReady, enterOpacityTransition, enterOpacity]); + + const animateClip = animate && enterTransition !== null; + // The clip offset provides extra padding to prevent path from being cut off // Area charts typically use offset=0 for exact clipping, while lines use offset=2 for breathing room const totalOffset = clipOffset * 2; // Applied on both sides // Animation progress for clip path reveal - const clipProgress = useSharedValue(animate ? 0 : 1); + const clipProgress = useSharedValue(animateClip ? 0 : 1); - // Trigger clip path animation when component mounts and animate is true useEffect(() => { - if (animate) { - clipProgress.value = withTiming(1, { duration: pathEnterTransitionDuration }); + if (animateClip && isReady) { + clipProgress.value = buildTransition(1, enterTransition); } - }, [animate, clipProgress]); + }, [animateClip, isReady, clipProgress, enterTransition]); // Create initial and target clip paths for animation const { initialClipPath, targetClipPath } = useMemo(() => { if (!rect) return { initialClipPath: null, targetClipPath: null }; - // Initial clip path (width = 0) + const categoryAxisIsX = context.layout !== 'horizontal'; + const fullWidth = rect.width + totalOffset; + const fullHeight = rect.height + totalOffset; + + // Initial clip path starts collapsed on the category axis. const initial = Skia.Path.Make(); initial.addRect({ x: rect.x - clipOffset, y: rect.y - clipOffset, - width: 0, - height: rect.height + totalOffset, + width: categoryAxisIsX ? 0 : fullWidth, + height: categoryAxisIsX ? fullHeight : 0, }); - // Target clip path (full width) + // Target clip path is fully expanded. const target = Skia.Path.Make(); target.addRect({ x: rect.x - clipOffset, y: rect.y - clipOffset, - width: rect.width + totalOffset, - height: rect.height + totalOffset, + width: fullWidth, + height: fullHeight, }); return { initialClipPath: initial, targetClipPath: target }; - }, [rect, clipOffset, totalOffset]); + }, [rect, clipOffset, totalOffset, context.layout]); // Use usePathInterpolation for animated clip path const animatedClipPath = usePathInterpolation( clipProgress, [0, 1], - animate && initialClipPath && targetClipPath + animateClip && initialClipPath && targetClipPath ? [initialClipPath, targetClipPath] : targetClipPath ? [targetClipPath, targetClipPath] @@ -256,13 +348,13 @@ export const Path = memo((props) => { } // If not animating or paths are null, return target clip path - if (!animate || !targetClipPath) { + if (!animateClip || !targetClipPath) { return targetClipPath; } // Return undefined here since we'll use animatedClipPath directly return undefined; - }, [clipPathProp, animate, targetClipPath]); + }, [clipPathProp, animateClip, targetClipPath]); // Convert SVG path string to SkPath for static rendering const staticPath = useDerivedValue(() => { @@ -307,7 +399,11 @@ export const Path = memo((props) => { strokeJoin={strokeJoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} - transition={transition} + transitions={{ + enter: enterTransition, + enterOpacity: enterOpacityTransition, + update: updateTransition, + }} > {children} @@ -315,12 +411,16 @@ export const Path = memo((props) => { // Determine which clip path to use const finalClipPath = - animate && resolvedClipPath === undefined ? animatedClipPath : resolvedClipPath; + animateClip && resolvedClipPath === undefined ? animatedClipPath : resolvedClipPath; // If finalClipPath is null, render without clipping if (finalClipPath === null) { - return content; + return {content}; } - return {content}; + return ( + + {content} + + ); }); diff --git a/packages/mobile-visualization/src/chart/PeriodSelector.tsx b/packages/mobile-visualization/src/chart/PeriodSelector.tsx index df7ccad907..6dfe5369c2 100644 --- a/packages/mobile-visualization/src/chart/PeriodSelector.tsx +++ b/packages/mobile-visualization/src/chart/PeriodSelector.tsx @@ -20,7 +20,7 @@ export const PeriodSelectorActiveIndicator = ({ borderRadius = 1000, }: TabsActiveIndicatorProps) => { const theme = useTheme(); - const { width, height, x } = activeTabRect; + const { width, height, x, y } = activeTabRect; // Get the target background color const backgroundColorKey = background as keyof typeof theme.color; @@ -31,7 +31,7 @@ export const PeriodSelectorActiveIndicator = ({ const previousColor = React.useRef(targetColor); // Combined animated value for position, size, and color - const newAnimatedValues = { x, width, backgroundColor: targetColor }; + const newAnimatedValues = { x, y, width, backgroundColor: targetColor }; const animatedValues = useSharedValue(newAnimatedValues); const isFirstRenderWithWidth = @@ -47,7 +47,7 @@ export const PeriodSelectorActiveIndicator = ({ const animatedStyles = useAnimatedStyle( () => ({ - transform: [{ translateX: animatedValues.value.x }], + transform: [{ translateX: animatedValues.value.x }, { translateY: animatedValues.value.y }], width: animatedValues.value.width, backgroundColor: animatedValues.value.backgroundColor, }), diff --git a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx index 31474366f3..abad603bd2 100644 --- a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx @@ -176,7 +176,7 @@ const EarningsHistory = () => { }, }); - const LegendItem = memo(({ opacity = 1, label }: { opacity?: number; label: string }) => { + const LegendEntry = memo(({ opacity = 1, label }: { opacity?: number; label: string }) => { return ( @@ -212,8 +212,8 @@ const EarningsHistory = () => { - - + + ); @@ -251,6 +251,10 @@ const PriceWithVolumeChart = memo( }); }, []); + const formatVolume = useCallback((volume: number) => { + return `${(volume / 1000).toFixed(2)}K`; + }, []); + const scrubberLabel = useCallback( (dataIndex: number) => { return formatDate(btcDates[dataIndex]); @@ -258,9 +262,26 @@ const PriceWithVolumeChart = memo( [formatDate], ); + const chartAccessibilityLabel = useMemo(() => { + const lastIndex = btcPrices.length - 1; + return `Bitcoin chart. Current date ${formatDate(btcDates[lastIndex])}. Current price ${formatPriceInThousands( + btcPrices[lastIndex], + )}. Current volume ${formatVolume(btcVolumes[lastIndex])}.`; + }, [formatDate, formatPriceInThousands, formatVolume]); + + const getScrubberAccessibilityLabel = useCallback( + (dataIndex: number) => + `Bitcoin on ${formatDate(btcDates[dataIndex])}. Price ${formatPriceInThousands( + btcPrices[dataIndex], + )}. Volume ${formatVolume(btcVolumes[dataIndex])}.`, + [formatDate, formatPriceInThousands, formatVolume], + ); + return ( { - // we will clip the data to the current hour - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - const currentHourX = xScale?.(currentHour); - - const clipPath = useMemo(() => { - if (!xScale || currentHourX === undefined) return null; - - // Create a rectangle from top-left of drawing area to currentHourX on the right - // Apply clipOffset to left, top, and bottom edges only (NOT to currentHourX) - const pathString = `M ${drawingArea.x - clipOffset} ${drawingArea.y - clipOffset} L ${currentHourX} ${drawingArea.y - clipOffset} L ${currentHourX} ${drawingArea.y + drawingArea.height + clipOffset} L ${drawingArea.x - clipOffset} ${drawingArea.y + drawingArea.height + clipOffset} Z`; - return Skia.Path.MakeFromSVGString(pathString); - }, [xScale, currentHourX, drawingArea, clipOffset]); - - if (!clipPath) return null; - - return ( - - {children} - - ); - }, -); - -const FutureData = memo( - ({ - children, - currentHour, - clipOffset = 0, - }: { - children: React.ReactNode; - currentHour: number; - clipOffset?: number; - }) => { - // we will clip the data from the current hour to the right edge - const { drawingArea, getXScale } = useCartesianChartContext(); - const xScale = getXScale(); - - const currentHourX = xScale?.(currentHour); - - const clipPath = useMemo(() => { - if (!xScale || currentHourX === undefined) return null; - - // Create a rectangle from currentHourX to right edge of drawing area - // Apply clipOffset to top, bottom, and right, but NOT left (currentHourX) - const pathString = `M ${currentHourX} ${drawingArea.y - clipOffset} L ${drawingArea.x + drawingArea.width + clipOffset} ${drawingArea.y - clipOffset} L ${drawingArea.x + drawingArea.width + clipOffset} ${drawingArea.y + drawingArea.height + clipOffset} L ${currentHourX} ${drawingArea.y + drawingArea.height + clipOffset} Z`; - return Skia.Path.MakeFromSVGString(pathString); - }, [xScale, currentHourX, drawingArea, clipOffset]); - - if (!clipPath) return null; - - return {children}; - }, -); - const ScatterplotWithCustomLabels = memo(() => { const theme = useTheme(); const dataPoints = useMemo( diff --git a/packages/mobile-visualization/src/chart/__stories__/Chart.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/Chart.stories.tsx deleted file mode 100644 index 4dde0e99bd..0000000000 --- a/packages/mobile-visualization/src/chart/__stories__/Chart.stories.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; - -import { CartesianChart, DottedArea, Line, LineChart, SolidLine } from '../'; - -const defaultChartHeight = 250; - -const BasicLineChart = () => { - const chartData = [65, 78, 45, 88, 92, 73, 69]; - - return ( - `$${value}`, - showGrid: true, - }} - /> - ); -}; - -const LineStyles = () => { - const topChartData = [15, 28, 32, 44, 46, 36, 40, 45, 48, 38]; - const upperMiddleChartData = [12, 23, 21, 29, 34, 28, 31, 38, 42, 35]; - const lowerMiddleChartData = [8, 15, 14, 25, 20, 18, 22, 28, 24, 30]; - const bottomChartData = [4, 8, 11, 15, 16, 14, 16, 10, 12, 14]; - - return ( - - - - } - curve="natural" - seriesId="lowerMiddle" - /> - - - ); -}; - -const ChartStories = () => { - return ( - - - - - - - - - ); -}; - -export default ChartStories; diff --git a/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx new file mode 100644 index 0000000000..09cc7603c1 --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/ChartAccessibility.stories.tsx @@ -0,0 +1,670 @@ +import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import type { View } from 'react-native'; +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 { XAxis, YAxis } from '../axis'; +import { BarChart } from '../bar/BarChart'; +import { BarPlot } from '../bar/BarPlot'; +import { CartesianChart } from '../CartesianChart'; +import { Legend } from '../legend'; +import { Line, ReferenceLine, SolidLine, type SolidLineProps } from '../line'; +import { LineChart } from '../line/LineChart'; +import { PeriodSelector, PeriodSelectorActiveIndicator } from '../PeriodSelector'; +import { Scrubber } from '../scrubber'; + +const ThinSolidLine = memo((props: SolidLineProps) => ); + +const BasicLineChart = memo(function BasicLineChart() { + const theme = useTheme(); + const data = useMemo(() => [2, 4, 3, 6, 5, 8, 7], []); + const categories = useMemo(() => ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], []); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${categories[index]}: ${data[index]}`, + [categories, data], + ); + + return ( + + + + ); +}); + +const DataFormatLineChart = memo(function DataFormatLineChart() { + const theme = useTheme(); + const yData = useMemo(() => [2, 5.5, 2, 8.5, 1.5, 5], []); + const xData = useMemo(() => [1, 2, 3, 5, 8, 10], []); + + const chartAccessibilityLabel = `Chart with uneven X values ${xData.join(', ')}. ${yData.length} data points.`; + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`, + [xData, yData], + ); + + return ( + + + + ); +}); + +const AccessibilityBarChart = memo(function AccessibilityBarChart() { + const theme = useTheme(); + const categories = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); + const values = useMemo(() => [40, 65, 55, 80, 72, 90], []); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${categories[index]}: ${values[index]}`, + [categories, values], + ); + + return ( + + ); +}); + +const AccessibilityHorizontalBarChart = memo(function AccessibilityHorizontalBarChart() { + const theme = useTheme(); + const dataset = useMemo( + () => [ + { month: 'Jan', rainfall: 21 }, + { month: 'Feb', rainfall: 28 }, + { month: 'Mar', rainfall: 41 }, + { month: 'Apr', rainfall: 73 }, + { month: 'May', rainfall: 99 }, + { month: 'June', rainfall: 144 }, + { month: 'July', rainfall: 319 }, + { month: 'Aug', rainfall: 249 }, + { month: 'Sept', rainfall: 131 }, + { month: 'Oct', rainfall: 55 }, + { month: 'Nov', rainfall: 48 }, + { month: 'Dec', rainfall: 25 }, + ], + [], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${dataset[index].month}: ${dataset[index].rainfall}mm rainfall`, + [dataset], + ); + + return ( + d.rainfall), + color: theme.color.accentBoldBlue, + }, + ]} + xAxis={{ + label: 'rainfall (mm)', + GridLineComponent: (props) => , + showGrid: true, + showLine: true, + showTickMarks: true, + }} + yAxis={{ + position: 'left', + data: dataset.map((d) => d.month), + showLine: true, + showTickMarks: true, + bandTickMarkPlacement: 'edges', + }} + > + ); +}); + +const ServiceAvailability = memo(function ServiceAvailability() { + const theme = useTheme(); + const availabilityEvents = useMemo( + () => [ + { date: new Date('2022-01-01'), availability: 79 }, + { date: new Date('2022-01-03'), availability: 81 }, + { date: new Date('2022-01-04'), availability: 82 }, + { date: new Date('2022-01-06'), availability: 91 }, + { date: new Date('2022-01-07'), availability: 92 }, + { date: new Date('2022-01-10'), availability: 86 }, + ], + [], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `Point ${index + 1}: ${availabilityEvents[index].availability}% availability on ${availabilityEvents[index].date.toLocaleDateString()}`, + [availabilityEvents], + ); + + return ( + event.availability), + gradient: { + stops: ({ min, max }) => [ + { offset: min, color: theme.color.fgNegative }, + { offset: 85, color: theme.color.fgNegative }, + { offset: 85, color: theme.color.fgWarning }, + { offset: 90, color: theme.color.fgWarning }, + { offset: 90, color: theme.color.fgPositive }, + { offset: max, color: theme.color.fgPositive }, + ], + }, + }, + ]} + xAxis={{ + data: availabilityEvents.map((event) => event.date.getTime()), + }} + yAxis={{ + domain: ({ min, max }) => ({ min: Math.max(min - 2, 0), max: Math.min(max + 2, 100) }), + }} + > + new Date(value).toLocaleDateString()} + /> + `${value}%`} + /> + ({ + ...props, + fill: theme.color.bg, + stroke: props.fill, + })} + seriesId="availability" + /> + + + ); +}); + +const BasicPricesWithManyPoints = memo(function BasicPricesWithManyPoints() { + const theme = useTheme(); + const data = useMemo( + () => [ + 10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58, 10, 22, 29, 45, 98, 45, 22, 52, 21, 4, + 68, 20, 21, 58, + ], + [], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${data[index]}`, + [data], + ); + + return ( + + + + ); +}); + +const PositiveAndNegativeCashFlow = memo(function PositiveAndNegativeCashFlow() { + const theme = useTheme(); + const categories = useMemo(() => Array.from({ length: 31 }, (_, i) => `3/${i + 1}`), []); + const gains = useMemo( + () => [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, + 16, 0, 0, 0, + ], + [], + ); + const losses = useMemo( + () => [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, + 0, 0, 0, 0, -12, -10, + ], + [], + ); + const series = useMemo( + () => [ + { id: 'gains', data: gains, color: theme.color.fgPositive, stackId: 'bars' }, + { id: 'losses', data: losses, color: theme.color.fgNegative, stackId: 'bars' }, + ], + [gains, losses, theme.color.fgNegative, theme.color.fgPositive], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const net = gains[index] + losses[index]; + const netStr = net >= 0 ? `+$${net}M` : `-$${Math.abs(net)}M`; + return `${categories[index]}: ${netStr}`; + }, + [categories, gains, losses], + ); + + return ( + + + `$${value}M`} + /> + + + + ); +}); + +const LegendPosition = memo(function LegendPosition() { + const theme = useTheme(); + const categories = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); + const revenueData = useMemo(() => [455, 520, 380, 455, 285, 235], []); + const profitMarginData = useMemo(() => [23, 20, 16, 38, 12, 9], []); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${categories[index]}: Revenue $${revenueData[index]}k, Profit Margin ${profitMarginData[index]}%`, + [categories, profitMarginData, revenueData], + ); + + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: revenueData, + yAxisId: 'revenue', + color: `rgb(${theme.spectrum.yellow40})`, + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: profitMarginData, + yAxisId: 'profitMargin', + color: theme.color.fgPositive, + legendShape: 'squircle', + }, + ]} + xAxis={{ + data: categories, + scaleType: 'band', + range: ({ min, max }) => ({ min, max: max - 128 }), + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + ); +}); + +const AssetPriceWithDottedArea = memo(function AssetPriceWithDottedArea() { + const theme = useTheme(); + const fontMgr = useMemo(() => Skia.TypefaceFontProvider.Make(), []); + + const tabs = useMemo( + () => [ + { id: 'hour', label: '1H' }, + { id: 'day', label: '1D' }, + { id: 'week', label: '1W' }, + { id: 'month', label: '1M' }, + { id: 'year', label: '1Y' }, + { id: 'all', label: 'All' }, + ], + [], + ); + const [timePeriod, setTimePeriod] = useState(tabs[0]); + + const sparklineTimePeriodData = useMemo( + () => sparklineInteractiveData[timePeriod.id as keyof typeof sparklineInteractiveData], + [timePeriod], + ); + const sparklineTimePeriodDataValues = useMemo( + () => sparklineTimePeriodData.map((d) => d.value), + [sparklineTimePeriodData], + ); + const sparklineTimePeriodDataTimestamps = useMemo( + () => sparklineTimePeriodData.map((d) => d.date), + [sparklineTimePeriodData], + ); + + const currentPrice = sparklineTimePeriodDataValues[sparklineTimePeriodDataValues.length - 1]; + + const priceFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }), + [], + ); + const formatPrice = useCallback( + (price: number) => priceFormatter.format(price), + [priceFormatter], + ); + const formatDate = useCallback((date: Date) => { + const dayOfWeek = date.toLocaleDateString('en-US', { weekday: 'short' }); + const monthDay = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + const time = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return `${dayOfWeek}, ${monthDay}, ${time}`; + }, []); + + const chartAccessibilityLabel = useMemo( + () => + `Bitcoin price chart for ${timePeriod.label} period. Current price: ${formatPrice(currentPrice)}.`, + [currentPrice, formatPrice, timePeriod.label], + ); + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = formatPrice(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `${price} ${date}`; + }, + [formatDate, formatPrice, sparklineTimePeriodDataTimestamps, sparklineTimePeriodDataValues], + ); + + const BTCTab: TabComponent = memo( + forwardRef(({ label, ...props }: SegmentedTabProps, ref: React.ForwardedRef) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === props.id; + return ( + + {label} + + } + {...props} + /> + ); + }), + ); + const BTCActiveIndicator = memo(({ style, ...props }: TabsActiveIndicatorProps) => ( + + )); + + const onPeriodChange = useCallback( + (period: TabValue | null) => setTimePeriod(period || tabs[0]), + [tabs], + ); + + return ( + + {formatPrice(currentPrice)}} + end={ + + + + } + title={Bitcoin} + /> + + { + const date = formatDate(sparklineTimePeriodDataTimestamps[d]); + const price = formatPrice(sparklineTimePeriodDataValues[d]); + const regularStyle: SkTextStyle = { + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { weight: FontWeight.Normal }, + color: Skia.Color(theme.color.fgMuted), + }; + const boldStyle: SkTextStyle = { + ...regularStyle, + fontStyle: { weight: FontWeight.Bold }, + }; + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Left }, fontMgr); + builder.pushStyle(boldStyle); + builder.addText(price); + builder.pushStyle(regularStyle); + builder.addText(` ${date}`); + const para = builder.build(); + para.layout(512); + return para; + }} + /> + + + + ); +}); + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { title: 'Basic Line Chart', component: }, + { title: 'Data Format (Uneven X)', component: }, + { title: 'Bar Chart', component: }, + { + title: 'Horizontal Bar Chart', + component: , + }, + { title: 'Service Availability', component: }, + { + title: 'Basic Prices (28 pts, step 1)', + component: , + }, + { title: 'Positive/Negative Cash Flow', component: }, + { title: 'Legend Position', component: }, + { title: 'Bitcoin Price (Dotted Area)', component: }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + + + Swipe to navigate chart segments. + + {currentExample.component} + + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx new file mode 100644 index 0000000000..fc3d35206f --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx @@ -0,0 +1,605 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTheme } from '@coinbase/cds-mobile'; +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 { AreaChart } from '../area/AreaChart'; +import type { BarProps } from '../bar/Bar'; +import { BarChart } from '../bar/BarChart'; +import { CartesianChart } from '../CartesianChart'; +import { Line, type LineProps } from '../line/Line'; +import type { PathProps } from '../Path'; +import type { PointBaseProps, PointProps } from '../point'; +import { Scrubber, type ScrubberRef } from '../scrubber'; + +const dataCount = 15; +const updateInterval = 2500; +const rapidUpdateInterval = 800; + +function generateNextValue(previousValue: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, previousValue + step)); +} + +function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; +} + +// Transition presets +const enterOnly: PathProps['transitions'] = { + update: null, +}; +const updateOnly: PathProps['transitions'] = { + enter: null, +}; +const bothDisabled: PathProps['transitions'] = { enter: null, update: null }; +const customEnterUpdate: PathProps['transitions'] = { + enter: { type: 'timing', duration: 1500 }, + update: { type: 'spring', stiffness: 400, damping: 30 }, +}; +const customEnterUpdateBeacon: PathProps['transitions'] = { + enter: { type: 'timing', duration: 500, delay: 1000 }, + update: { type: 'spring', stiffness: 400, damping: 30 }, +}; +const slowSpringBoth: PathProps['transitions'] = { + enter: { type: 'spring', stiffness: 100, damping: 10 }, + update: { type: 'spring', stiffness: 100, damping: 10 }, +}; +const staggeredBoth: BarProps['transitions'] = { + enter: { type: 'timing', duration: 750, staggerDelay: 250 }, + update: { type: 'spring', stiffness: 300, damping: 20, staggerDelay: 150 }, +}; +const slowTimingBoth: PathProps['transitions'] = { + enter: { type: 'timing', duration: 2000 }, + update: { type: 'timing', duration: 2000 }, +}; + +// --- Reusable Chart Components --- + +const TransitionLineChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; + scrubberTransitions?: PathProps['transitions']; + animate?: boolean; + idlePulse?: boolean; + scrubberRef?: React.RefObject; + enableScrubbing?: boolean; + points?: LineProps['points']; +}>( + ({ + data, + transitions, + scrubberTransitions, + animate: animateProp, + idlePulse, + scrubberRef, + enableScrubbing = true, + points, + }) => ( + + + {enableScrubbing && ( + } + hideOverlay + idlePulse={idlePulse} + transitions={scrubberTransitions ?? transitions} + /> + )} + + ), +); + +const TransitionAreaChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; + idlePulse?: boolean; + scrubberRef?: React.RefObject; +}>(({ data, transitions, idlePulse, scrubberRef }) => ( + + } + hideOverlay + idlePulse={idlePulse} + transitions={transitions} + /> + +)); + +const MultiLineChart = memo<{ + data1: number[]; + data2: number[]; + transitions: PathProps['transitions']; +}>(({ data1, data2, transitions }) => ( + + + + + +)); + +// --- Self-contained Example Wrappers --- + +function LineExample({ + transitions, + scrubberTransitions, + pointTransitions, + animate, + idlePulse, + resettable = true, + imperative = false, + points, +}: { + transitions: PathProps['transitions']; + scrubberTransitions?: PathProps['transitions']; + pointTransitions?: PointProps['transitions']; + animate?: boolean; + idlePulse?: boolean; + resettable?: boolean; + imperative?: boolean; + points?: boolean; +}) { + const scrubberRef = useRef(null); + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + if (imperative) scrubberRef.current?.pulse(); + }, updateInterval); + return () => clearInterval(intervalId); + }, [imperative]); + + const pointFunction: LineProps['points'] = (props: PointBaseProps) => ({ + ...props, + transitions: pointTransitions, + }); + + const pointProps: LineProps['points'] = points ? pointFunction : false; + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function AreaExample({ + transitions, + idlePulse, + resettable = true, + imperative = false, +}: { + transitions: PathProps['transitions']; + idlePulse?: boolean; + resettable?: boolean; + imperative?: boolean; +}) { + const scrubberRef = useRef(null); + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + if (imperative) scrubberRef.current?.pulse(); + }, updateInterval); + return () => clearInterval(intervalId); + }, [imperative]); + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function SessionBaselineAreaTransitionsExample() { + const theme = useTheme(); + const [resetKey, setResetKey] = useState(0); + const [data, setData] = useState(generateInitialData()); + const handleReset = useCallback(() => { + setData(generateInitialData()); + setResetKey((k) => k + 1); + }, []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((d) => [...d.slice(1), generateNextValue(d[d.length - 1])]); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + const baseline = data[0]; + + return ( + + + + + + + + + ); +} + +// --- Bar Chart Components --- + +const barCategories = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function generateBarData() { + return barCategories.map(() => Math.round(Math.random() * 80 + 10)); +} + +const barChartProps = { + showXAxis: true, + enableScrubbing: true, + height: 200, + xAxis: { data: barCategories }, + yAxis: { domain: { min: 0, max: 100 } }, +} as const; + +const TransitionBarChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; +}>(({ data, transitions }) => ( + + + +)); + +function BarExample({ + transitions, + resettable = true, +}: { + transitions: PathProps['transitions']; + resettable?: boolean; +}) { + const [data, setData] = useState(generateBarData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData(generateBarData()); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function RapidLineExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, rapidUpdateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +function RapidBarExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data, setData] = useState(generateBarData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData(generateBarData()); + }, rapidUpdateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +function MultiLineExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data1, setData1] = useState(generateInitialData); + const [data2, setData2] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData1((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + setData2((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +// --- Main Navigator --- + +type ExampleItem = { + category: string; + title: string; + component: React.ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { + category: 'Line', + title: 'Enter Only', + component: , + }, + { + category: 'Line', + title: 'Update Only', + component: ( + + ), + }, + { + category: 'Line', + title: 'Both Disabled', + component: , + }, + { + category: 'Line', + title: 'Custom 2', + component: ( + + ), + }, + { + category: 'Line', + title: 'Imperative Pulse', + component: , + }, + { + category: 'Multi-Line', + title: 'Update Only', + component: , + }, + { + category: 'Area', + title: 'Both Disabled', + component: , + }, + { + category: 'Area', + title: 'Imperative Pulse', + component: , + }, + { + category: 'Area', + title: 'Session baseline', + component: , + }, + { + category: 'Bar', + title: 'Enter Only', + component: , + }, + { + category: 'Bar', + title: 'Update Only', + component: , + }, + { + category: 'Bar', + title: 'Both Disabled', + component: , + }, + { + category: 'Bar', + title: 'Slow Spring Both', + component: , + }, + { + category: 'Bar', + title: 'Staggered Both', + component: , + }, + { + category: 'Line', + title: 'Rapid Interrupts', + component: , + }, + { + category: 'Bar', + title: 'Rapid Interrupts', + component: , + }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev + 1) % examples.length); + }, [examples.length]); + + return ( + + + + + + + {currentExample.category} + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + {currentExample.component} + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx index e03153668b..2cfb07603c 100644 --- a/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx +++ b/packages/mobile-visualization/src/chart/__stories__/PeriodSelector.stories.tsx @@ -1,14 +1,23 @@ -import { forwardRef, memo, useMemo, useState } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, View } from 'react-native'; +import { + interpolateColor, + runOnJS, + useAnimatedReaction, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; 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 { @@ -52,6 +61,25 @@ const MinWidthPeriodSelectorExample = () => { ); }; +const PaddedPeriodSelectorExample = () => { + const tabs = [ + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + setActiveTab(tab)} + padding={3} + tabs={tabs} + width="fit-content" + /> + ); +}; + const LivePeriodSelectorExample = () => { const tabs = useMemo( () => [ @@ -264,6 +292,60 @@ const ColoredExcludingLivePeriodSelectorExample = () => { ); }; +type ColoredIconProps = { + tabId: string; + name: IconProps['name']; +}; + +const ColoredIcon = memo(({ tabId, name }: ColoredIconProps) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === tabId; + const theme = useTheme(); + + const progress = useSharedValue(isActive ? 1 : 0); + const [color, setColor] = useState(isActive ? theme.color.fgPrimary : theme.color.fg); + + useEffect(() => { + progress.value = withSpring(isActive ? 1 : 0, tabsSpringConfig); + }, [isActive, progress]); + + useAnimatedReaction( + () => interpolateColor(progress.value, [0, 1], [theme.color.fg, theme.color.fgPrimary]), + (newColor) => { + runOnJS(setColor)(newColor); + }, + ); + + return ; +}); + +function IconsPeriodSelectorExample() { + const theme = useTheme(); + + const tabs = [ + { id: 'buy', label: }, + { id: 'sell', label: }, + { id: 'convert', label: }, + ]; + const [activeTab, updateActiveTab] = useState(tabs[0]); + const handleChange = useCallback((activeTab: TabValue | null) => updateActiveTab(activeTab), []); + return ( + + ); +} + export default function All() { return ( @@ -285,6 +367,12 @@ export default function All() { + + + + + + ); } diff --git a/packages/mobile-visualization/src/chart/area/Area.tsx b/packages/mobile-visualization/src/chart/area/Area.tsx index a7d59ae53e..ec1a888936 100644 --- a/packages/mobile-visualization/src/chart/area/Area.tsx +++ b/packages/mobile-visualization/src/chart/area/Area.tsx @@ -1,7 +1,8 @@ import React, { memo, useMemo } from 'react'; import { useCartesianChartContext } from '../ChartProvider'; -import { type ChartPathCurveType, getAreaPath, type Transition } from '../utils'; +import type { PathBaseProps, PathProps } from '../Path'; +import { type ChartPathCurveType, getAreaPath } from '../utils'; import type { GradientDefinition } from '../utils/gradient'; import { DottedArea } from './DottedArea'; @@ -36,16 +37,19 @@ export type AreaBaseProps = { * The color of the area. * @default color of the series or 'var(--color-fgPrimary)' */ - fill?: string; + fill?: PathBaseProps['fill']; /** * Opacity of the area * @note when combined with gradient, both will be applied * @default 1 */ - fillOpacity?: number; + fillOpacity?: PathBaseProps['fillOpacity']; /** * Baseline value for the gradient. * When set, overrides the default baseline. + * + * @deprecated this prop has no functionality. Use 'baseline' on axis config instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v5 */ baseline?: number; /** @@ -57,27 +61,29 @@ export type AreaBaseProps = { * Whether to animate the area. * Overrides the animate value from the chart context. */ - animate?: boolean; + animate?: PathBaseProps['animate']; }; -export type AreaProps = AreaBaseProps & { - /** - * Transition configuration for path animations. - */ - transition?: Transition; -}; +export type AreaProps = AreaBaseProps & Pick; export type AreaComponentProps = Pick< AreaProps, - 'fill' | 'fillOpacity' | 'baseline' | 'gradient' | 'animate' | 'transition' + 'fill' | 'fillOpacity' | 'baseline' | 'gradient' | 'animate' | 'transitions' | 'transition' > & { /** * Path of the area */ d: string; + /** + * ID of the x-axis to use. + * If not provided, defaults to the default x-axis. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * ID of the y-axis to use. * If not provided, defaults to the default y-axis. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; }; @@ -92,13 +98,14 @@ export const Area = memo( AreaComponent: AreaComponentProp, fill: fillProp, fillOpacity = 1, - baseline, connectNulls, gradient: gradientProp, + transitions, transition, animate, }) => { - const { getSeries, getSeriesData, getXScale, getYScale, getXAxis } = useCartesianChartContext(); + const { layout, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = + useCartesianChartContext(); const matchedSeries = useMemo(() => getSeries(seriesId), [seriesId, getSeries]); const gradient = useMemo( @@ -109,17 +116,27 @@ export const Area = memo( const sourceData = useMemo(() => getSeriesData(seriesId), [seriesId, getSeriesData]); - const xAxis = getXAxis(); - const xScale = getXScale(); + const xAxis = getXAxis(matchedSeries?.xAxisId); + const xScale = getXScale(matchedSeries?.xAxisId); const yScale = getYScale(matchedSeries?.yAxisId); + const yAxis = getYAxis(matchedSeries?.yAxisId); + + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxis = useMemo(() => { + return categoryAxisIsX ? xAxis : yAxis; + }, [categoryAxisIsX, xAxis, yAxis]); const area = useMemo(() => { if (!sourceData || sourceData.length === 0 || !xScale || !yScale) return ''; - // Get numeric x-axis data if available - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) + const indexData = + categoryAxis?.data && + Array.isArray(categoryAxis.data) && + typeof categoryAxis.data[0] === 'number' + ? (categoryAxis.data as number[]) : undefined; return getAreaPath({ @@ -127,10 +144,12 @@ export const Area = memo( xScale, yScale, curve, - xData, + xData: categoryAxisIsX ? indexData : undefined, + yData: !categoryAxisIsX ? indexData : undefined, connectNulls, + layout, }); - }, [sourceData, xScale, yScale, curve, xAxis?.data, connectNulls]); + }, [sourceData, xScale, yScale, curve, categoryAxis, categoryAxisIsX, connectNulls, layout]); const AreaComponent = useMemo((): AreaComponent => { if (AreaComponentProp) { @@ -153,12 +172,13 @@ export const Area = memo( return ( ); diff --git a/packages/mobile-visualization/src/chart/area/AreaChart.tsx b/packages/mobile-visualization/src/chart/area/AreaChart.tsx index c791870708..d9b42a6716 100644 --- a/packages/mobile-visualization/src/chart/area/AreaChart.tsx +++ b/packages/mobile-visualization/src/chart/area/AreaChart.tsx @@ -9,11 +9,10 @@ import { } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; import { - type AxisConfigProps, - defaultChartInset, + type CartesianAxisConfigProps, defaultStackId, - getChartInset, type Series, + withBaselineDomain, } from '../utils'; import { Area, type AreaProps } from './Area'; @@ -22,10 +21,22 @@ export type AreaSeries = Series & Partial< Pick< AreaProps, - 'AreaComponent' | 'curve' | 'fillOpacity' | 'type' | 'fill' | 'connectNulls' | 'transition' + | 'AreaComponent' + | 'curve' + | 'fillOpacity' + | 'type' + | 'fill' + | 'connectNulls' + | 'transition' + | 'transitions' > > & - Partial> & { + Partial< + Pick< + LineProps, + 'LineComponent' | 'strokeWidth' | 'stroke' | 'opacity' | 'transition' | 'transitions' + > + > & { /** * The type of line to render for this series. * Overrides the chart-level lineType if provided. @@ -37,7 +48,13 @@ export type AreaSeries = Series & export type AreaChartBaseProps = Omit & Pick< AreaProps, - 'AreaComponent' | 'curve' | 'fillOpacity' | 'type' | 'connectNulls' | 'transition' + | 'AreaComponent' + | 'curve' + | 'fillOpacity' + | 'type' + | 'connectNulls' + | 'transition' + | 'transitions' > & Pick & { /** @@ -77,13 +94,13 @@ export type AreaChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type AreaChartProps = AreaChartBaseProps & @@ -101,6 +118,7 @@ export const AreaChart = memo( type, connectNulls, transition, + transitions, LineComponent, strokeWidth, showXAxis, @@ -115,8 +133,6 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( @@ -126,8 +142,10 @@ export const AreaChart = memo( label: s.label, color: s.color, gradient: s.gradient, + xAxisId: s.xAxisId, yAxisId: s.yAxisId, stackId: s.stackId, + legendShape: s.legendShape, }), ); }, [series]); @@ -147,6 +165,8 @@ export const AreaChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; const { @@ -156,50 +176,43 @@ export const AreaChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, - domain: xDomain, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, }; - const hasNegativeValues = useMemo(() => { - if (!series) return false; - return series.some((s) => - s.data?.some( - (value: number | null | [number, number]) => - (typeof value === 'number' && value < 0) || - (Array.isArray(value) && value.some((v) => typeof v === 'number' && v < 0)), - ), - ); - }, [series]); - - // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, - domain: hasNegativeValues ? yDomain : { min: 0, ...yDomain }, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, }; return ( - {showXAxis && } + {showXAxis && } {showYAxis && } {series?.map( ({ @@ -207,6 +220,7 @@ export const AreaChart = memo( data, label, color, + xAxisId, yAxisId, opacity, LineComponent, @@ -221,6 +235,7 @@ export const AreaChart = memo( fillOpacity={fillOpacity} seriesId={id} transition={transition} + transitions={transitions} type={type} {...areaPropsFromSeries} /> @@ -233,12 +248,12 @@ export const AreaChart = memo( data, label, color, + xAxisId, yAxisId, fill, fillOpacity, stackId, type, // Area type (don't pass to Line) - lineType: seriesLineType, ...otherPropsFromSeries }) => { return ( @@ -250,7 +265,8 @@ export const AreaChart = memo( seriesId={id} strokeWidth={strokeWidth} transition={transition} - type={seriesLineType ?? lineType} + transitions={transitions} + type={lineType} {...otherPropsFromSeries} /> ); diff --git a/packages/mobile-visualization/src/chart/area/DottedArea.tsx b/packages/mobile-visualization/src/chart/area/DottedArea.tsx index 43c77d79c6..d16025d010 100644 --- a/packages/mobile-visualization/src/chart/area/DottedArea.tsx +++ b/packages/mobile-visualization/src/chart/area/DottedArea.tsx @@ -7,7 +7,7 @@ import { Gradient } from '../gradient'; import { Path, type PathProps } from '../Path'; import { createGradient, getBaseline } from '../utils'; import { getDottedAreaPath } from '../utils/path'; -import { usePathTransition } from '../utils/transition'; +import { defaultTransition, usePathTransition } from '../utils/transition'; import type { AreaComponentProps } from './Area'; @@ -62,23 +62,35 @@ export const DottedArea = memo( dotSize = 1, peakOpacity = 1, baselineOpacity = 0, - baseline, + xAxisId, yAxisId, gradient: gradientProp, animate: animateProp, + transitions, transition, ...pathProps }) => { const theme = useTheme(); - const { drawingArea, animate, getYAxis } = useCartesianChartContext(); + const { drawingArea, animate, layout, getXAxis, getYAxis } = useCartesianChartContext(); - const yAxisConfig = getYAxis(yAxisId); + const shouldAnimate = animateProp ?? animate; + + const valueAxisConfig = layout !== 'horizontal' ? getYAxis(yAxisId) : getXAxis(xAxisId); + const gradientAxis = layout !== 'horizontal' ? 'y' : 'x'; const fill = useMemo( () => fillProp ?? theme.color.fgPrimary, [fillProp, theme.color.fgPrimary], ); + const updateTransition = useMemo(() => { + return transitions?.update !== undefined + ? transitions.update + : transition !== undefined + ? transition + : defaultTransition; + }, [transitions?.update, transition]); + const dottedPath = useMemo(() => { if (!drawingArea) return ''; @@ -94,34 +106,46 @@ export const DottedArea = memo( ); }, [drawingArea, patternSize, dotSize]); - const animatedClipPath = usePathTransition({ + const clipPath = usePathTransition({ currentPath: d, - transition, + transitions: { update: shouldAnimate ? updateTransition : null }, }); - const staticClipPath = useMemo(() => { - if (!d) return; - return Skia.Path.MakeFromSVGString(d) ?? undefined; - }, [d]); - const gradient = useMemo(() => { if (gradientProp) return gradientProp; - if (!yAxisConfig) return; + if (!valueAxisConfig) return; - const baselineValue = getBaseline(yAxisConfig.domain, baseline); - return createGradient(yAxisConfig.domain, baselineValue, fill, peakOpacity, baselineOpacity); - }, [gradientProp, yAxisConfig, fill, baseline, peakOpacity, baselineOpacity]); + const baselineValue = getBaseline(valueAxisConfig.domain, valueAxisConfig.baseline); + return createGradient( + valueAxisConfig.domain, + baselineValue, + fill, + peakOpacity, + baselineOpacity, + gradientAxis, + ); + }, [gradientProp, valueAxisConfig, fill, peakOpacity, baselineOpacity, gradientAxis]); + // Update transition is used for clip path, we skip update animation on Path itself return ( - + - {gradient && } + {gradient && ( + + )} ); diff --git a/packages/mobile-visualization/src/chart/area/GradientArea.tsx b/packages/mobile-visualization/src/chart/area/GradientArea.tsx index 1f59e0cd34..23e66de37f 100644 --- a/packages/mobile-visualization/src/chart/area/GradientArea.tsx +++ b/packages/mobile-visualization/src/chart/area/GradientArea.tsx @@ -49,16 +49,18 @@ export const GradientArea = memo( gradient: gradientProp, peakOpacity = 0.3, baselineOpacity = 0, - baseline, + xAxisId, yAxisId, animate, + transitions, transition, ...pathProps }) => { - const { getYAxis } = useCartesianChartContext(); + const { layout, getXAxis, getYAxis } = useCartesianChartContext(); const theme = useTheme(); - const yAxisConfig = getYAxis(yAxisId); + const valueAxisConfig = layout !== 'horizontal' ? getYAxis(yAxisId) : getXAxis(xAxisId); + const gradientAxis = layout !== 'horizontal' ? 'y' : 'x'; const fill = useMemo( () => fillProp ?? theme.color.fgPrimary, @@ -67,11 +69,18 @@ export const GradientArea = memo( const gradient = useMemo(() => { if (gradientProp) return gradientProp; - if (!yAxisConfig) return; + if (!valueAxisConfig) return; - const baselineValue = getBaseline(yAxisConfig.domain, baseline); - return createGradient(yAxisConfig.domain, baselineValue, fill, peakOpacity, baselineOpacity); - }, [gradientProp, yAxisConfig, fill, baseline, peakOpacity, baselineOpacity]); + const baselineValue = getBaseline(valueAxisConfig.domain, valueAxisConfig.baseline); + return createGradient( + valueAxisConfig.domain, + baselineValue, + fill, + peakOpacity, + baselineOpacity, + gradientAxis, + ); + }, [gradientProp, valueAxisConfig, fill, peakOpacity, baselineOpacity, gradientAxis]); return ( ( fill={fill} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} > - {gradient && } + {gradient && ( + + )} ); }, diff --git a/packages/mobile-visualization/src/chart/area/SolidArea.tsx b/packages/mobile-visualization/src/chart/area/SolidArea.tsx index 4f0d5c8a58..d44281bfa5 100644 --- a/packages/mobile-visualization/src/chart/area/SolidArea.tsx +++ b/packages/mobile-visualization/src/chart/area/SolidArea.tsx @@ -27,7 +27,18 @@ export type SolidAreaProps = Pick< * Otherwise, renders with solid fill. */ export const SolidArea = memo( - ({ d, fill, fillOpacity = 1, yAxisId, animate, transition, gradient, ...pathProps }) => { + ({ + d, + fill, + fillOpacity = 1, + xAxisId, + yAxisId, + animate, + transitions, + transition, + gradient, + ...pathProps + }) => { const theme = useTheme(); return ( @@ -37,9 +48,18 @@ export const SolidArea = memo( fill={fill ?? theme.color.fgPrimary} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} > - {gradient && } + {gradient && ( + + )} ); }, diff --git a/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx b/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx index 362aa185ad..55846d84c7 100644 --- a/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/area/__stories__/AreaChart.stories.tsx @@ -1,22 +1,37 @@ +import { memo, useCallback } from 'react'; +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 { VStack } from '@coinbase/cds-mobile/layout'; -import { DottedLine } from '../../line'; +import { + DefaultReferenceLineLabel, + DottedLine, + ReferenceLine, + type ReferenceLineLabelComponentProps, +} from '../../line'; import { Scrubber } from '../../scrubber/Scrubber'; import { AreaChart } from '..'; +const basicData = [24, 13, 98, 39, 48, 38, 43]; +const baselineThresholdData = [40, 28, 21, 5, 48, 5, 28, 2, 29, 48, 18, 30, 29, 8].map( + (value) => value + 50, +); + const BasicExample = () => { + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${basicData[index]}`, + [], + ); + return ( { ); }; +const currentRewardsData = [ + 100, 150, 200, 280, 380, 500, 650, 820, 1020, 1250, 1510, 1800, 2120, 2470, 2850, 3260, 3700, + 4170, +]; +const potentialRewardsData = [ + 150, 220, 300, 400, 520, 660, 820, 1000, 1200, 1420, 1660, 1920, 2200, 2500, 2820, 3160, 3520, + 3900, +]; + const StackedExample = () => { const theme = useTheme(); + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `Point ${index + 1}: current ${currentRewardsData[index]}, potential ${potentialRewardsData[index]}`, + [], + ); + return ( , }, ]} type="dotted" @@ -64,6 +90,170 @@ const StackedExample = () => { ); }; +const CustomBaselineExample = () => { + const theme = useTheme(); + const candles = [...btcCandles].reverse().slice(0, 180); + const prices = candles.map((candle) => parseFloat(candle.close)); + const dates = candles.map((candle) => new Date(parseInt(candle.start, 10) * 1000)); + + const startingPrice = prices[0]; + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatPriceInThousands = useCallback((price: number) => { + return `$${(price / 1000).toLocaleString('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + })}k`; + }, []); + + const formatDate = useCallback((date: Date) => { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, []); + + const formatLabel = useCallback( + (dataIndex: number) => `${formatPrice(prices[dataIndex])} ${formatDate(dates[dataIndex])}`, + [dates, formatDate, formatPrice, prices], + ); + + const PriceLabel = memo((props: ReferenceLineLabelComponentProps) => ( + + )); + + const chartAccessibilityLabel = `Bitcoin area chart with custom baseline. Current price: ${formatPrice( + prices[prices.length - 1], + )}. Swipe to navigate.`; + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${formatPrice(prices[index])} ${formatDate(dates[index])}`, + [dates, formatDate, formatPrice, prices], + ); + + return ( + + + } + dataY={startingPrice} + label={formatPrice(startingPrice)} + stroke={theme.color.fg} + /> + + ); +}; + +const AxisBaselineThresholdExample = () => { + const theme = useTheme(); + + return ( + + + `Point ${index + 1}: ${baselineThresholdData[index]}` + } + height={220} + inset={0} + series={[ + { + id: 'axis-baseline-threshold-vertical', + data: baselineThresholdData, + gradient: { + stops: [ + { offset: 30, color: theme.color.fgNegative }, + { offset: 30, color: theme.color.fgPositive }, + ], + }, + }, + ]} + type="dotted" + yAxis={{ + showGrid: true, + baseline: 30, + }} + > + + + + `Point ${index + 1}: ${baselineThresholdData[index]}` + } + height={220} + inset={0} + layout="horizontal" + series={[ + { + id: 'axis-baseline-threshold-horizontal', + data: baselineThresholdData, + gradient: { + stops: [ + { offset: 30, color: theme.color.fgNegative }, + { offset: 30, color: theme.color.fgPositive }, + ], + }, + }, + ]} + type="dotted" + xAxis={{ + showGrid: true, + baseline: 30, + }} + > + + + + ); +}; + const AreaChartStories = () => { return ( @@ -78,21 +268,57 @@ const AreaChartStories = () => { enableScrubbing showLines showYAxis - height={400} + accessibilityLabel="Area chart with negative values. 7 data points. Swipe to navigate." + getScrubberAccessibilityLabel={(index: number) => + `Point ${index + 1}: ${[24, 13, -98, 39, 48, 38, 43][index]}` + } + height={150} series={[ { id: 'pageViews', data: [24, 13, -98, 39, 48, 38, 43], }, ]} - type="solid" + type="gradient" + yAxis={{ + showGrid: true, + }} + > + + +
    + + + `Point ${index + 1}: ${[112, 97, 121, 103, 129, 118, 94][index]}` + } + height={220} + series={[ + { + id: 'netFlow', + data: [112, 97, 121, 103, 129, 118, 94], + }, + ]} yAxis={{ + baseline: 100, + domain: { min: 80, max: 140 }, showGrid: true, + tickLabelFormatter: (value) => `${value}`, }} > + + + + + + { ]} /> + + + `${['BTC', 'ETH', 'SOL', 'DOGE', 'ADA'][index]}: ${[68, 54, 43, 29, 18][index]}%` + } + height={280} + layout="horizontal" + series={[ + { + id: 'volume', + data: [68, 54, 43, 29, 18], + label: 'Volume', + }, + ]} + type="gradient" + xAxis={{ domain: { min: 0, max: 80 }, tickLabelFormatter: (value) => `${value}%` }} + yAxis={{ data: ['BTC', 'ETH', 'SOL', 'DOGE', 'ADA'], scaleType: 'band' }} + > + + + ); }; diff --git a/packages/mobile-visualization/src/chart/axis/Axis.tsx b/packages/mobile-visualization/src/chart/axis/Axis.tsx index 520c8a047d..2a0efc60d7 100644 --- a/packages/mobile-visualization/src/chart/axis/Axis.tsx +++ b/packages/mobile-visualization/src/chart/axis/Axis.tsx @@ -67,7 +67,9 @@ export type AxisBaseProps = { * This value is passed into d3 and may not be respected. * @note This property is overridden when `ticks` is provided. * @note this property overrides the `tickInterval` property. - * @default 5 (for y-axis) + * @default 5 for value axes by layout: + * - X axis when chart layout is horizontal + * - Y axis when chart layout is vertical */ requestedTickCount?: number; /** diff --git a/packages/mobile-visualization/src/chart/axis/XAxis.tsx b/packages/mobile-visualization/src/chart/axis/XAxis.tsx index 66601f4699..5b791a8e13 100644 --- a/packages/mobile-visualization/src/chart/axis/XAxis.tsx +++ b/packages/mobile-visualization/src/chart/axis/XAxis.tsx @@ -23,6 +23,12 @@ const AXIS_HEIGHT = 32; const LABEL_SIZE = 20; export type XAxisBaseProps = AxisBaseProps & { + /** + * The ID of the axis to render. + * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + axisId?: string; /** * The position of the axis relative to the chart's drawing area. * @default 'bottom' @@ -39,6 +45,7 @@ export type XAxisProps = AxisProps & XAxisBaseProps; export const XAxis = memo( ({ + axisId, position = 'bottom', showGrid, requestedTickCount, @@ -68,6 +75,7 @@ export const XAxis = memo( const { animate, drawingArea, + layout, getXScale, getXAxis, registerAxis, @@ -75,8 +83,8 @@ export const XAxis = memo( getAxisBounds, } = useCartesianChartContext(); - const xScale = getXScale(); - const xAxis = getXAxis(); + const xScale = getXScale(axisId); + const xAxis = getXAxis(axisId); const axisBounds = getAxisBounds(registrationId); useEffect(() => { @@ -86,21 +94,18 @@ export const XAxis = memo( }, [registrationId, registerAxis, unregisterAxis, position, height]); const formatTick = useCallback( - (value: any) => { + (value: number) => { // If we have string labels and no custom formatter, use the labels const axisData = xAxis?.data; const hasStringLabels = axisData && Array.isArray(axisData) && typeof axisData[0] === 'string'; - let finalValue = value; - - // For band scales with string data, value is an index - if (hasStringLabels && typeof value === 'number' && axisData[value] !== undefined) { - finalValue = axisData[value]; + if (hasStringLabels && !tickLabelFormatter && axisData[value] !== undefined) { + return axisData[value]; } - // Use the formatter (if provided) or the value itself - return tickLabelFormatter?.(finalValue) ?? finalValue; + // Otherwise passes raw index to formatter + return tickLabelFormatter?.(value) ?? value; }, [xAxis?.data, tickLabelFormatter], ); @@ -124,31 +129,31 @@ export const XAxis = memo( categories = domain.map(String); } - let possibleTickValues: number[] | undefined; - - // If we have discrete data, we can use the indices as possible tick values - if ( - axisData && - Array.isArray(axisData) && - (typeof axisData[0] === 'string' || - (typeof axisData[0] === 'number' && isCategoricalScale(xScale))) - ) { - possibleTickValues = Array.from({ length: axisData.length }, (_, i) => i); - } - return getAxisTicksData({ scaleFunction: xScale, ticks, - requestedTickCount, + requestedTickCount: requestedTickCount ?? (layout === 'horizontal' ? 5 : undefined), categories, - possibleTickValues, + possibleTickValues: + axisData && Array.isArray(axisData) && typeof axisData[0] === 'string' + ? Array.from({ length: axisData.length }, (_, i) => i) + : undefined, tickInterval: tickInterval, options: { minStep: tickMinStep, maxStep: tickMaxStep, }, }); - }, [ticks, xScale, requestedTickCount, tickInterval, tickMinStep, tickMaxStep, xAxis?.data]); + }, [ + ticks, + xScale, + requestedTickCount, + tickInterval, + tickMinStep, + tickMaxStep, + xAxis?.data, + layout, + ]); const isBandScale = useMemo(() => { if (!xScale) return false; diff --git a/packages/mobile-visualization/src/chart/axis/YAxis.tsx b/packages/mobile-visualization/src/chart/axis/YAxis.tsx index 9aa1c4174c..14d3e1635b 100644 --- a/packages/mobile-visualization/src/chart/axis/YAxis.tsx +++ b/packages/mobile-visualization/src/chart/axis/YAxis.tsx @@ -26,6 +26,7 @@ export type YAxisBaseProps = AxisBaseProps & { /** * The ID of the axis to render. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ axisId?: string; /** @@ -47,7 +48,7 @@ export const YAxis = memo( axisId, position = 'right', showGrid, - requestedTickCount = 5, + requestedTickCount, ticks, tickLabelFormatter, TickLabelComponent = DefaultAxisTickLabel, @@ -72,6 +73,7 @@ export const YAxis = memo( const { animate, drawingArea, + layout, getYScale, getYAxis, registerAxis, @@ -133,7 +135,10 @@ export const YAxis = memo( return getAxisTicksData({ scaleFunction: yScale as any, ticks, - requestedTickCount: tickInterval !== undefined ? undefined : (requestedTickCount ?? 5), + requestedTickCount: + tickInterval !== undefined + ? undefined + : (requestedTickCount ?? (layout === 'horizontal' ? undefined : 5)), categories, possibleTickValues: axisData && Array.isArray(axisData) && typeof axisData[0] === 'number' @@ -141,7 +146,7 @@ export const YAxis = memo( : undefined, tickInterval: tickInterval, }); - }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data]); + }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data, layout]); const isBandScale = useMemo(() => { if (!yScale) return false; diff --git a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx b/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx index be759fa95a..9477a00dd5 100644 --- a/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx +++ b/packages/mobile-visualization/src/chart/axis/__stories__/Axis.stories.tsx @@ -63,11 +63,20 @@ const Simple = () => { const pageNames = data.map((d) => d.name); const pageUniqueVisitors = data.map((d) => d.uv); + const chartAccessibilityLabel = `Page views and unique visitors across ${pageNames.length} pages. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${pageNames[index]}: ${pageViews[index]} views, ${pageUniqueVisitors[index]} unique visitors`, + [pageNames, pageViews, pageUniqueVisitors], + ); + return ( { const TimeOfDayAxesExample = () => { const theme = useTheme(); - const lineA = [5, 5, 10, 90, 85, 70, 30, 25, 25]; - const lineB = [90, 85, 70, 25, 23, 40, 45, 40, 50]; + const lineA = useMemo(() => [5, 5, 10, 90, 85, 70, 30, 25, 25], []); + const lineB = useMemo(() => [90, 85, 70, 25, 23, 40, 45, 40, 50], []); const timeData = useMemo( () => @@ -160,9 +169,17 @@ const TimeOfDayAxesExample = () => { return timeData.map((d, index) => index).filter((d) => d % 2 === 0); }, [timeData]); + const chartAccessibilityLabel = `Chart with ${lineA.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: lineA ${lineA[index]}, lineB ${lineB[index]}`, + [lineA, lineB], + ); + return ( { ); }; -const MultipleYAxesExample = () => ( - - - - - - - - -); +const multipleYAxesData = [1, 10, 30, 50, 70, 90, 100]; + +const MultipleYAxesExample = () => { + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `Point ${index + 1}: linear ${multipleYAxesData[index]}, log ${multipleYAxesData[index]}`, + [], + ); + + return ( + + + + + + + + + ); +}; const AxesOnAllSides = () => { const theme = useTheme(); @@ -322,13 +351,23 @@ const CustomTickMarkSizes = () => { }; const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { - const exponentialData = [ - 1, 2, 4, 8, 15, 30, 65, 140, 280, 580, 1200, 2400, 4800, 9500, 19000, 38000, 75000, 150000, - ]; + const exponentialData = useMemo( + () => [ + 1, 2, 4, 8, 15, 30, 65, 140, 280, 580, 1200, 2400, 4800, 9500, 19000, 38000, 75000, 150000, + ], + [], + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${exponentialData[index]}`, + [exponentialData], + ); return ( ( y, width, height, - originY, + origin: originProp, dataX, dataY, + seriesId, BarComponent = DefaultBar, fill, fillOpacity = 1, @@ -114,41 +124,41 @@ export const Bar = memo( borderRadius = 4, roundTop = true, roundBottom = true, + minSize, + transitions, transition, }) => { const theme = useTheme(); - - // Use theme color as default if no fill is provided - const effectiveFill = fill ?? theme.color.fgPrimary; - - const borderRadiusPixels = useMemo(() => borderRadius ?? 0, [borderRadius]); + const { layout } = useCartesianChartContext(); const barPath = useMemo(() => { - return getBarPath(x, y, width, height, borderRadiusPixels, roundTop, roundBottom); - }, [x, y, width, height, borderRadiusPixels, roundTop, roundBottom]); + return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom, layout); + }, [x, y, width, height, borderRadius, roundTop, roundBottom, layout]); - const effectiveOriginY = originY ?? y + height; - - if (!barPath) { - return null; - } + const origin = useMemo( + () => originProp ?? (layout === 'horizontal' ? x : y + height), + [originProp, layout, x, y, height], + ); + if (!barPath) return; - // Always use the BarComponent for rendering return ( & +export type BarChartBaseProps = Omit< + CartesianChartBaseProps, + | 'xAxis' + | 'yAxis' + | 'series' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' +> & Pick< BarPlotProps, | 'barPadding' @@ -31,12 +36,14 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. + * Each series can optionally define its own BarComponent. */ - series?: Array; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. @@ -59,23 +66,33 @@ export type BarChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type BarChartProps = BarChartBaseProps & - Omit; + Omit< + CartesianChartProps, + | 'xAxis' + | 'yAxis' + | 'series' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' + >; export const BarChart = memo( forwardRef( ( { - series, + series: seriesProp, stacked, showXAxis, showYAxis, @@ -94,22 +111,21 @@ export const BarChart = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, ...chartProps }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const series: Array | undefined = useMemo(() => { + if (!stacked || !seriesProp) return seriesProp; + return seriesProp.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); + }, [seriesProp, stacked]); - const transformedSeries = useMemo(() => { - if (!stacked || !series) return series; - return series.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); - }, [series, stacked]); - - // Unlike other charts with custom props per series, we do not need to pick out - // the props from each series that shouldn't be passed to CartesianChart - const seriesToRender = transformedSeries ?? series; - const seriesIds = seriesToRender?.map((s) => s.id); + const seriesIds = useMemo(() => series?.map((s) => s.id), [series]); + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const defaultXScaleType = isHorizontalLayout ? 'linear' : 'band'; + const defaultYScaleType = isHorizontalLayout ? 'band' : 'linear'; // Split axis props into config props for Chart and visual props for axis components const { @@ -119,6 +135,8 @@ export const BarChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; const { @@ -128,50 +146,70 @@ export const BarChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - const xAxisConfig: Partial = { - scaleType: xScaleType ?? 'band', - data: xData, - categoryPadding: xCategoryPadding, - domain: xDomain, - domainLimit: xDomainLimit, - range: xRange, - }; - - const hasNegativeValues = useMemo(() => { - if (!series) return false; - return series.some((s) => - s.data?.some( - (value: number | null | [number, number]) => - (typeof value === 'number' && value < 0) || - (Array.isArray(value) && value.some((v) => typeof v === 'number' && v < 0)), - ), - ); - }, [series]); + const xAxisConfig = useMemo>( + () => ({ + scaleType: xScaleType ?? defaultXScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + }), + [ + xScaleType, + defaultXScaleType, + xData, + xCategoryPadding, + isHorizontalLayout, + xDomain, + xDomainLimit, + xRange, + xBaseline, + valueAxisBaseline, + ], + ); - // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: hasNegativeValues ? yDomain : { min: 0, ...yDomain }, - domainLimit: yDomainLimit, - range: yRange, - }; + const yAxisConfig = useMemo>( + () => ({ + scaleType: yScaleType ?? defaultYScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + }), + [ + yScaleType, + defaultYScaleType, + yData, + yCategoryPadding, + isHorizontalLayout, + yDomain, + yDomainLimit, + yRange, + yBaseline, + valueAxisBaseline, + ], + ); return ( - {showXAxis && } + {showXAxis && } {showYAxis && } {children} diff --git a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx index 190d26cbd2..ae97522803 100644 --- a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx @@ -1,10 +1,13 @@ -import { memo, useId, useMemo } from 'react'; -import { Group, Skia } from '@shopify/react-native-skia'; +import { memo, useEffect, useMemo, useState } from 'react'; +import { useSharedValue } from 'react-native-reanimated'; +import type { Rect } from '@coinbase/cds-common/types'; +import { Group, Skia, usePathInterpolation } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; -import type { Series } from '../utils'; -import { defaultAxisId } from '../utils'; +import { getStackGroups } from '../utils'; +import { buildTransition, instantTransition } from '../utils/transition'; +import type { BarSeries } from './BarStack'; import type { BarStackGroupProps } from './BarStackGroup'; import { BarStackGroup } from './BarStackGroup'; @@ -29,7 +32,14 @@ export type BarPlotBaseProps = Pick< seriesIds?: string[]; }; -export type BarPlotProps = BarPlotBaseProps & Pick; +export type BarPlotProps = BarPlotBaseProps & + Pick; + +const makeClipPath = (area: Rect) => { + const path = Skia.Path.Make(); + path.addRect(area); + return path; +}; /** * BarPlot component that handles multiple series with proper stacking coordination. @@ -51,12 +61,12 @@ export const BarPlot = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, }) => { - const { series: allSeries, drawingArea } = useCartesianChartContext(); - const clipPathId = useId(); + const { animate, series: allSeries, drawingArea } = useCartesianChartContext(); - const targetSeries = useMemo(() => { + const targetSeries: BarSeries[] = useMemo(() => { // Then filter by seriesIds if provided if (seriesIds !== undefined) { return allSeries.filter((s: any) => seriesIds.includes(s.id)); @@ -65,58 +75,51 @@ export const BarPlot = memo( return allSeries; }, [allSeries, seriesIds]); - const stackGroups = useMemo(() => { - const groups = new Map< - string, - { - stackId: string; - series: Series[]; - yAxisId?: string; - } - >(); - - // Group series into stacks based on stackId + yAxisId combination - targetSeries.forEach((series) => { - const yAxisId = series.yAxisId ?? defaultAxisId; - const stackId = series.stackId || `individual-${series.id}`; - const stackKey = `${stackId}:${yAxisId}`; - - if (!groups.has(stackKey)) { - groups.set(stackKey, { - stackId: stackKey, - series: [], - yAxisId: series.yAxisId, - }); - } - - const group = groups.get(stackKey)!; - group.series.push(series); - }); - - return Array.from(groups.values()); - }, [targetSeries]); - - // Create clip path for the entire chart area (shared by all bars) - const clipPath = useMemo(() => { - if (!drawingArea) return null; - const clip = Skia.Path.Make(); - clip.addRect({ - x: drawingArea.x, - y: drawingArea.y, - width: drawingArea.width, - height: drawingArea.height, - }); - return clip; - }, [drawingArea]); - - if (!clipPath) { - return null; - } - - // Note: Clipping is now handled here at the BarPlot level (one clip path for all bars!) - // This is much more efficient than creating a clip path for each individual bar + const stackGroups = useMemo(() => getStackGroups(targetSeries), [targetSeries]); + + const clipUpdateTransition = useMemo( + () => (transitions?.update !== undefined ? transitions.update : instantTransition), + [transitions?.update], + ); + + const emptyPath = useMemo(() => Skia.Path.Make(), []); + + const initialPath = useMemo( + () => (drawingArea ? makeClipPath(drawingArea) : emptyPath), + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const [clipPaths, setClipPaths] = useState({ from: initialPath, to: initialPath }); + const clipProgress = useSharedValue(0); + + useEffect(() => { + if (!drawingArea) return; + const nextPath = makeClipPath(drawingArea); + setClipPaths((prev) => ({ from: prev.to, to: nextPath })); + if (drawingArea.width || !drawingArea.height) { + clipProgress.value = 1; + } else { + clipProgress.value = 0; + clipProgress.value = buildTransition(1, animate ? clipUpdateTransition : null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [drawingArea, animate, clipUpdateTransition]); + + const animatedClipPath = usePathInterpolation( + clipProgress, + [0, 1], + [clipPaths.from, clipPaths.to], + ); + + if (!drawingArea) return; + + // Clip path animation for bar is just for chart size changes, not for + // enter transition. One caveat, bar update transitions are staggered + // but clip path is not, so some bars could be clipped in rare cases + return ( - + {stackGroups.map((group, stackIndex) => ( ( strokeWidth={defaultStrokeWidth} totalStacks={stackGroups.length} transition={transition} + transitions={transitions} + xAxisId={group.xAxisId} yAxisId={group.yAxisId} /> ))} diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index 53aa12100f..890c114a50 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -3,46 +3,66 @@ import type { Rect } from '@coinbase/cds-common'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartScaleFunction, Series, Transition } from '../utils'; -import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; +import type { ChartScaleFunction, Series } from '../utils'; +import { EPSILON, getBars, getBaselinePx, getStackOrigin } from '../utils/bar'; +import { getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; -import { Bar, type BarProps } from './Bar'; +import { Bar, type BarBaseProps, type BarComponent, type BarProps } from './Bar'; import { DefaultBarStack } from './DefaultBarStack'; -const EPSILON = 1e-4; +/** + * Extended series type that includes bar-specific properties. + */ +export type BarSeries = Series & { + /** + * Custom component to render bars for this series. + */ + BarComponent?: BarComponent; +}; export type BarStackBaseProps = Pick< - BarProps, + BarBaseProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' > & { /** * Array of series configurations that belong to this stack. */ - series: Series[]; + series: BarSeries[]; /** * The category index for this stack. */ categoryIndex: number; /** - * X position for this stack. + * Position of this stack along the index (categorical) axis. */ - x: number; + indexPos: number; /** - * Width of this stack. + * Thickness of this stack. */ - width: number; + thickness: number; /** - * Y scale function. + * Scale for the independent (categorical) axis. */ - yScale: ChartScaleFunction; + indexScale: ChartScaleFunction; + /** + * Scale for the dependent (magnitude) axis. + */ + valueScale: ChartScaleFunction; /** * Chart rect for bounds. */ rect: Rect; + /** + * X axis ID to use. + * If not provided, defaults to defaultAxisId. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Y axis ID to use. - * If not provided, will use the yAxisId from the first series. + * If not provided, defaults to defaultAxisId. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** @@ -69,21 +89,24 @@ export type BarStackBaseProps = Pick< stackMinSize?: number; }; -export type BarStackProps = BarStackBaseProps & { - /** - * Transition configurations for different animation phases. - */ - transition?: Transition; -}; +export type BarStackProps = BarStackBaseProps & Pick; export type BarStackComponentProps = Pick< BarStackProps, - 'x' | 'width' | 'categoryIndex' | 'borderRadius' | 'transition' + 'categoryIndex' | 'borderRadius' | 'transitions' | 'transition' > & { + /** + * The x position of the stack. + */ + x: number; /** * The y position of the stack. */ y: number; + /** + * The width of the stack. + */ + width: number; /** * The height of the stack. */ @@ -101,9 +124,11 @@ export type BarStackComponentProps = Pick< */ roundBottom?: boolean; /** - * The y-origin for animations (baseline position). + * Stack animation origin. + * - number: baseline on the value axis + * - tuple: [start, end] clip range for stacked min-size enter animation */ - yOrigin?: number; + origin?: number | [number, number]; }; export type BarStackComponent = React.FC; @@ -116,10 +141,13 @@ export const BarStack = memo( ({ series, categoryIndex, - x, - width, - yScale, + indexPos, + thickness, + indexScale, + valueScale, rect, + xAxisId, + yAxisId, BarComponent: defaultBarComponent, fillOpacity: defaultFillOpacity, stroke: defaultStroke, @@ -130,28 +158,37 @@ export const BarStack = memo( barMinSize, stackMinSize, roundBaseline, + transitions, transition, }) => { const theme = useTheme(); - const { getSeriesData, getXAxis, getXScale } = useCartesianChartContext(); + const { layout, getSeriesData, getXAxis, getYAxis } = useCartesianChartContext(); - const xAxis = getXAxis(); - const xScale = getXScale(); + const xAxis = getXAxis(xAxisId); + const yAxis = getYAxis(yAxisId); - const baseline = useMemo(() => { - const domain = yScale.domain(); - const [domainMin, domainMax] = domain; - const baselineValue = domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0; - const baseline = yScale(baselineValue) ?? rect.y + rect.height; + const baseline = useMemo( + () => (layout === 'vertical' ? yAxis : xAxis)?.baseline, + [layout, yAxis, xAxis], + ); - return Math.max(rect.y, Math.min(baseline, rect.y + rect.height)); - }, [rect.height, rect.y, yScale]); + const baselinePx = useMemo( + () => getBaselinePx(valueScale, rect, layout, baseline), + [rect, valueScale, layout, baseline], + ); const seriesGradients = useMemo(() => { return series.map((s) => { - if (!s.gradient || !xScale || !yScale) return; - - const gradientScale = s.gradient.axis === 'x' ? xScale : yScale; + if (!s.gradient) return; + + const gradientScale = + s.gradient.axis === 'x' + ? layout === 'vertical' + ? indexScale + : valueScale + : layout === 'vertical' + ? valueScale + : indexScale; const serializableScale = convertToSerializableScale(gradientScale); if (!serializableScale) return; @@ -165,543 +202,141 @@ export const BarStack = memo( stops, }; }); - }, [series, xScale, yScale]); - - // Calculate bars for this specific category - const { bars, stackRect } = useMemo(() => { - let allBars: Array<{ - seriesId: string; - x: number; - y: number; - width: number; - height: number; - dataY?: number | [number, number] | null; - fill?: string; - roundTop?: boolean; - roundBottom?: boolean; - shouldApplyGap?: boolean; - }> = []; - - // Track how many bars we've stacked in each direction for gap calculation - let positiveBarCount = 0; - let negativeBarCount = 0; - - // Track stack bounds for clipping - let minY = Infinity; - let maxY = -Infinity; - - // Process each series in the stack - series.forEach((s) => { - const data = getSeriesData(s.id); - if (!data) return; - - const value = data[categoryIndex]; - if (value === null || value === undefined) return; - - const originalData = s.data; - const originalValue = originalData?.[categoryIndex]; - // Only apply gap logic if the original data wasn't tuple format - const shouldApplyGap = !Array.isArray(originalValue); - - // Sort to be in ascending order - const [bottom, top] = (value as [number, number]).sort((a, b) => a - b); - - const isAboveBaseline = bottom >= 0 && top !== bottom; - const isBelowBaseline = bottom <= 0 && bottom !== top; - - const barBottom = yScale(bottom) ?? baseline; - const barTop = yScale(top) ?? baseline; - - // Track bar counts for later gap calculations - if (shouldApplyGap) { - if (isAboveBaseline) { - positiveBarCount++; - } else if (isBelowBaseline) { - negativeBarCount++; - } - } - - // Calculate height (remember SVG y coordinates are inverted) - const height = Math.abs(barBottom - barTop); - const y = Math.min(barBottom, barTop); - - // Skip bars that would have zero or negative height - if (height <= 0) { - return; - } - - // Update stack bounds - minY = Math.min(minY, y); - maxY = Math.max(maxY, y + height); - - // Determine fill color, respecting gradient if present - let barFill = s.color || theme.color.fgPrimary; - - // Evaluate gradient if provided (using precomputed stops) - const seriesGradientConfig = seriesGradients.find((g) => g?.seriesId === s.id); - if (seriesGradientConfig) { - const axis = seriesGradientConfig.gradient.axis ?? 'y'; - // For x-axis gradient, use the categoryIndex - // For y-axis gradient, use the actual data value - const dataValue = axis === 'x' ? categoryIndex : top; - const evaluatedColor = evaluateGradientAtValue( - seriesGradientConfig.stops, - dataValue, - seriesGradientConfig.scale, - ); - if (evaluatedColor) { - // Only apply gradient color if fill is not explicitly set - barFill = evaluatedColor; - } - } - - allBars.push({ - seriesId: s.id, - x, - y, - width, - height, - dataY: value, // Store the actual data value - fill: barFill, - // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding - roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON, - roundBottom: roundBaseline || Math.abs(barBottom - baseline) >= EPSILON, - shouldApplyGap, - }); - }); - - // Apply proportional gap distribution to maintain total stack height - if (stackGap && allBars.length > 1) { - // Separate bars by baseline side - const barsAboveBaseline = allBars.filter((bar) => { - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - return bottom >= 0 && top !== bottom && bar.shouldApplyGap; - }); - const barsBelowBaseline = allBars.filter((bar) => { - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - return bottom <= 0 && bottom !== top && bar.shouldApplyGap; - }); - - // Apply proportional gaps to bars above baseline - if (barsAboveBaseline.length > 1) { - const totalGapSpace = stackGap * (barsAboveBaseline.length - 1); - const totalDataHeight = barsAboveBaseline.reduce((sum, bar) => sum + bar.height, 0); - const heightReduction = totalGapSpace / totalDataHeight; - - // Sort bars by position (from baseline upward) - const sortedBars = barsAboveBaseline.sort((a, b) => b.y - a.y); - - let currentY = baseline; - sortedBars.forEach((bar, index) => { - // Reduce bar height proportionally - const newHeight = bar.height * (1 - heightReduction); - const newY = currentY - newHeight; - - // Update the bar in allBars array - const barIndex = allBars.findIndex((b) => b.seriesId === bar.seriesId); - if (barIndex !== -1) { - allBars[barIndex] = { - ...allBars[barIndex], - height: newHeight, - y: newY, - }; - } - - // Move to next position (include gap for next bar) - currentY = newY - (index < sortedBars.length - 1 ? stackGap : 0); - }); - } - - // Apply proportional gaps to bars below baseline - if (barsBelowBaseline.length > 1) { - const totalGapSpace = stackGap * (barsBelowBaseline.length - 1); - const totalDataHeight = barsBelowBaseline.reduce((sum, bar) => sum + bar.height, 0); - const heightReduction = totalGapSpace / totalDataHeight; - - // Sort bars by position (from baseline downward) - const sortedBars = barsBelowBaseline.sort((a, b) => a.y - b.y); - - let currentY = baseline; - sortedBars.forEach((bar, index) => { - // Reduce bar height proportionally - const newHeight = bar.height * (1 - heightReduction); - - // Update the bar in allBars array - const barIndex = allBars.findIndex((b) => b.seriesId === bar.seriesId); - if (barIndex !== -1) { - allBars[barIndex] = { - ...allBars[barIndex], - height: newHeight, - y: currentY, - }; - } - - // Move to next position (include gap for next bar) - currentY = currentY + newHeight + (index < sortedBars.length - 1 ? stackGap : 0); - }); - } - - // Recalculate stack bounds after gap adjustments - if (allBars.length > 0) { - minY = Math.min(...allBars.map((bar) => bar.y)); - maxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); - } - } - - // Apply barMinSize constraints - if (barMinSize) { - // First, expand bars that need it and track the expansion - const expandedBars = allBars.map((bar, index) => { - if (bar.height < barMinSize) { - const heightIncrease = barMinSize - bar.height; - - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - - // Determine how to expand the bar - let newBottom = bottom; - let newTop = top; - - const scaleUnit = Math.abs((yScale(1) ?? 0) - (yScale(0) ?? 0)); - - if (bottom === 0) { - // Expand away from baseline (upward for positive) - newTop = top + heightIncrease / scaleUnit; - } else if (top === 0) { - // Expand away from baseline (downward for negative) - newBottom = bottom - heightIncrease / scaleUnit; - } else { - // Expand in both directions - const halfIncrease = heightIncrease / scaleUnit / 2; - newBottom = bottom - halfIncrease; - newTop = top + halfIncrease; - } - - // Recalculate bar position with new data values - const newBarBottom = yScale(newBottom) ?? baseline; - const newBarTop = yScale(newTop) ?? baseline; - const newHeight = Math.abs(newBarBottom - newBarTop); - const newY = Math.min(newBarBottom, newBarTop); - - return { - ...bar, - height: newHeight, - y: newY, - wasExpanded: true, - }; - } - return { ...bar, wasExpanded: false }; - }); - - // Now reposition all bars to avoid overlaps, similar to stackMinSize logic - - // Sort bars by position to maintain order - const sortedExpandedBars = [...expandedBars].sort((a, b) => a.y - b.y); - - // Determine if we have bars above and below baseline - const barsAboveBaseline = sortedExpandedBars.filter( - (bar) => bar.y + bar.height <= baseline, - ); - const barsBelowBaseline = sortedExpandedBars.filter((bar) => bar.y >= baseline); - - // Create a map of new positions - const newPositions = new Map(); - - // Start positioning from the baseline and work outward - let currentYAbove = baseline; // Start at baseline, work upward (decreasing Y) - let currentYBelow = baseline; // Start at baseline, work downward (increasing Y) - - // Position bars above baseline (positive values, decreasing Y) - for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { - const bar = barsAboveBaseline[i]; - const newY = currentYAbove - bar.height; - - newPositions.set(bar.seriesId, { y: newY, height: bar.height }); - - // Update currentYAbove for next bar (preserve gaps) - if (i > 0) { - const currentBar = barsAboveBaseline[i]; - const nextBar = barsAboveBaseline[i - 1]; - // Find original bars to get original gap - const originalCurrent = allBars.find((b) => b.seriesId === currentBar.seriesId)!; - const originalNext = allBars.find((b) => b.seriesId === nextBar.seriesId)!; - const originalGap = originalCurrent.y - (originalNext.y + originalNext.height); - currentYAbove = newY - originalGap; - } - } - - // Position bars below baseline (negative values, increasing Y) - for (let i = 0; i < barsBelowBaseline.length; i++) { - const bar = barsBelowBaseline[i]; - const newY = currentYBelow; - - newPositions.set(bar.seriesId, { y: newY, height: bar.height }); - - // Update currentYBelow for next bar (preserve gaps) - if (i < barsBelowBaseline.length - 1) { - const currentBar = barsBelowBaseline[i]; - const nextBar = barsBelowBaseline[i + 1]; - // Find original bars to get original gap - const originalCurrent = allBars.find((b) => b.seriesId === currentBar.seriesId)!; - const originalNext = allBars.find((b) => b.seriesId === nextBar.seriesId)!; - const originalGap = originalNext.y - (originalCurrent.y + originalCurrent.height); - currentYBelow = newY + bar.height + originalGap; - } - } - - // Apply new positions to all bars - allBars = expandedBars.map((bar) => { - const newPos = newPositions.get(bar.seriesId); - if (newPos) { - return { - ...bar, - y: newPos.y, - height: newPos.height, - }; - } - return bar; - }); - - // Recalculate stack bounds after barMinSize expansion and repositioning - if (allBars.length > 0) { - minY = Math.min(...allBars.map((bar) => bar.y)); - maxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); - } - } - - // Apply border radius logic (will be reapplied after stackMinSize if needed) - const applyBorderRadiusLogic = (bars: typeof allBars) => { - return bars - .sort((a, b) => b.y - a.y) - .map((a, index) => { - const barBefore = index > 0 ? bars[index - 1] : null; - const barAfter = index < bars.length - 1 ? bars[index + 1] : null; - - const shouldRoundTop = - index === bars.length - 1 || - (a.shouldApplyGap && stackGap) || - (!a.shouldApplyGap && barAfter && barAfter.y + barAfter.height !== a.y); - - const shouldRoundBottom = - index === 0 || - (a.shouldApplyGap && stackGap) || - (!a.shouldApplyGap && barBefore && barBefore.y !== a.y + a.height); - - return { - ...a, - roundTop: Boolean(a.roundTop && shouldRoundTop), - roundBottom: Boolean(a.roundBottom && shouldRoundBottom), - }; - }); - }; - - allBars = applyBorderRadiusLogic(allBars); - - // Calculate the bounding rect for the entire stack - let stackBounds = { - x, - y: minY === Infinity ? baseline : minY, - width, - height: maxY === -Infinity ? 0 : maxY - minY, - }; - - // Apply stackMinSize constraints - if (stackMinSize) { - if (allBars.length === 1 && stackBounds.height < stackMinSize) { - // For single bars (non-stacked), treat stackMinSize like barMinSize - - const bar = allBars[0]; - const heightIncrease = stackMinSize - bar.height; - - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - - // Determine how to expand the bar (same logic as barMinSize) - let newBottom = bottom; - let newTop = top; - - const scaleUnit = Math.abs((yScale(1) ?? 0) - (yScale(0) ?? 0)); - - if (bottom === 0) { - // Expand away from baseline (upward for positive) - newTop = top + heightIncrease / scaleUnit; - } else if (top === 0) { - // Expand away from baseline (downward for negative) - newBottom = bottom - heightIncrease / scaleUnit; - } else { - // Expand in both directions - const halfIncrease = heightIncrease / scaleUnit / 2; - newBottom = bottom - halfIncrease; - newTop = top + halfIncrease; - } - - // Recalculate bar position with new data values - const newBarBottom = yScale(newBottom) ?? baseline; - const newBarTop = yScale(newTop) ?? baseline; - const newHeight = Math.abs(newBarBottom - newBarTop); - const newY = Math.min(newBarBottom, newBarTop); - - allBars[0] = { - ...bar, - height: newHeight, - y: newY, - }; - - // Recalculate stack bounds - stackBounds = { - x, - y: newY, - width, - height: newHeight, - }; - } else if (allBars.length > 1 && stackBounds.height < stackMinSize) { - // For multiple bars (stacked), scale heights while preserving gaps - - // Calculate total bar height (excluding gaps) - const totalBarHeight = allBars.reduce((sum, bar) => sum + bar.height, 0); - const totalGapHeight = stackBounds.height - totalBarHeight; - - // Calculate how much we need to increase bar heights - const requiredBarHeight = stackMinSize - totalGapHeight; - const barScaleFactor = requiredBarHeight / totalBarHeight; - - // Sort bars by position to maintain order - const sortedBars = [...allBars].sort((a, b) => a.y - b.y); - - // Determine if we have bars above and below baseline - const barsAboveBaseline = sortedBars.filter((bar) => bar.y + bar.height <= baseline); - const barsBelowBaseline = sortedBars.filter((bar) => bar.y >= baseline); - - // Create a map of new positions - const newPositions = new Map(); - - // Start positioning from the baseline and work outward - let currentYAbove = baseline; // Start at baseline, work upward (decreasing Y) - let currentYBelow = baseline; // Start at baseline, work downward (increasing Y) - - // Position bars above baseline (positive values, decreasing Y) - for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { - const bar = barsAboveBaseline[i]; - const newHeight = bar.height * barScaleFactor; - const newY = currentYAbove - newHeight; - - newPositions.set(bar.seriesId, { y: newY, height: newHeight }); - - // Update currentYAbove for next bar (preserve gaps) - if (i > 0) { - const currentBar = barsAboveBaseline[i]; - const nextBar = barsAboveBaseline[i - 1]; - const originalGap = currentBar.y - (nextBar.y + nextBar.height); - currentYAbove = newY - originalGap; - } - } - - // Position bars below baseline (negative values, increasing Y) - for (let i = 0; i < barsBelowBaseline.length; i++) { - const bar = barsBelowBaseline[i]; - const newHeight = bar.height * barScaleFactor; - const newY = currentYBelow; - - newPositions.set(bar.seriesId, { y: newY, height: newHeight }); - - // Update currentYBelow for next bar (preserve gaps) - if (i < barsBelowBaseline.length - 1) { - const currentBar = barsBelowBaseline[i]; - const nextBar = barsBelowBaseline[i + 1]; - const originalGap = nextBar.y - (currentBar.y + currentBar.height); - currentYBelow = newY + newHeight + originalGap; - } - } - - // Apply new positions to all bars - allBars = allBars.map((bar) => { - const newPos = newPositions.get(bar.seriesId); - if (!newPos) return bar; - return { - ...bar, - height: newPos.height, - y: newPos.y, - }; - }); - - // Recalculate stack bounds - const newMinY = Math.min(...allBars.map((bar) => bar.y)); - const newMaxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); + }, [series, indexScale, valueScale, layout]); + + const categoryAxis = layout === 'vertical' ? xAxis : yAxis; + const categoryData = + categoryAxis?.data && + Array.isArray(categoryAxis.data) && + typeof categoryAxis.data[0] === 'number' + ? (categoryAxis.data as number[]) + : undefined; + const categoryValue = categoryData ? categoryData[categoryIndex] : categoryIndex; + const seriesData = useMemo( + () => Object.fromEntries(series.map((s) => [s.id, getSeriesData(s.id) ?? []])), + [series, getSeriesData], + ); - stackBounds = { - x, - y: newMinY, - width, - height: newMaxY - newMinY, - }; - } + const bars = useMemo( + () => + getBars({ + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + defaultFill: theme.color.fgPrimary, + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + }), + [ + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + theme.color.fgPrimary, + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + ], + ); - // Reapply border radius logic only if we actually scaled - if (stackBounds.height < stackMinSize) { - allBars = applyBorderRadiusLogic(allBars); - } + const stackRect = useMemo(() => { + if (bars.length === 0) { + return { + x: layout === 'vertical' ? indexPos : baselinePx, + y: layout === 'vertical' ? baselinePx : indexPos, + width: layout === 'vertical' ? thickness : 0, + height: layout === 'vertical' ? 0 : thickness, + }; } - - return { bars: allBars, stackRect: stackBounds }; - }, [ - series, - x, - width, - getSeriesData, - categoryIndex, - roundBaseline, - baseline, - stackGap, - barMinSize, - stackMinSize, - yScale, - seriesGradients, - theme.color.fgPrimary, - ]); - - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) - : undefined; - const dataX = xData ? xData[categoryIndex] : categoryIndex; + const minX = Math.min(...bars.map((b) => b.x)); + const minY = Math.min(...bars.map((b) => b.y)); + const maxX = Math.max(...bars.map((b) => b.x + b.width)); + const maxY = Math.max(...bars.map((b) => b.y + b.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [bars, baselinePx, indexPos, layout, thickness]); + + const stackOrigin = useMemo( + () => + getStackOrigin( + bars.map((b) => b.origin), + bars.map((b) => b.minSize ?? 0), + ) ?? baselinePx, + [bars, baselinePx], + ); const barElements = bars.map((bar, index) => ( )); - // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding - const stackRoundBottom = - roundBaseline || Math.abs(stackRect.y + stackRect.height - baseline) >= EPSILON; - const stackRoundTop = roundBaseline || Math.abs(stackRect.y - baseline) >= EPSILON; + const edge = layout === 'vertical' ? stackRect.y : stackRect.x; + const size = layout === 'vertical' ? stackRect.height : stackRect.width; + const stackRoundLower = roundBaseline || Math.abs(edge - baselinePx) >= EPSILON; + const stackRoundHigher = roundBaseline || Math.abs(edge + size - baselinePx) >= EPSILON; + const stackRoundTop = layout === 'vertical' ? stackRoundLower : stackRoundHigher; + const stackRoundBottom = layout === 'vertical' ? stackRoundHigher : stackRoundLower; return ( {barElements} diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 2ce56d065a..5579c6e1fe 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -17,9 +17,10 @@ export type BarStackGroupProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'transitions' | 'transition' > & - Pick & { + Pick & { /** * Index of this stack within the category (0-based). */ @@ -40,70 +41,94 @@ export type BarStackGroupProps = Pick< * Delegates the actual stacking logic to BarStack for each category. */ export const BarStackGroup = memo( - ({ series, yAxisId = defaultAxisId, stackIndex, totalStacks, barPadding = 0.1, ...props }) => { - const { getXScale, getYScale, drawingArea, dataLength } = useCartesianChartContext(); - - const xScale = getXScale(); + ({ + series, + xAxisId = defaultAxisId, + yAxisId = defaultAxisId, + stackIndex, + totalStacks, + barPadding = 0.1, + ...props + }) => { + const { layout, getXScale, getYScale, drawingArea, dataLength } = useCartesianChartContext(); + + const xScale = getXScale(xAxisId); const yScale = getYScale(yAxisId); const stackConfigs = useMemo(() => { if (!xScale || !yScale || !drawingArea || dataLength === 0) return []; - if (!isCategoricalScale(xScale)) { + const indexScale = layout !== 'horizontal' ? xScale : yScale; + + if (!isCategoricalScale(indexScale)) { return []; } - const categoryWidth = xScale.bandwidth(); + const categoryWidth = indexScale.bandwidth(); - // Calculate width for each stack within a category - // Only apply barPadding when there are multiple stacks - const gapWidth = totalStacks > 1 ? (categoryWidth * barPadding) / (totalStacks - 1) : 0; - const barWidth = categoryWidth / totalStacks - getBarSizeAdjustment(totalStacks, gapWidth); + // Calculate thickness for each stack within a category. + const gapSize = totalStacks > 1 ? (categoryWidth * barPadding) / (totalStacks - 1) : 0; + const stackThickness = + categoryWidth / totalStacks - getBarSizeAdjustment(totalStacks, gapSize); const configs: Array<{ categoryIndex: number; - x: number; - width: number; + indexPos: number; + thickness: number; }> = []; - // Calculate position for each category - // todo: look at using xDomain for this instead of dataLength + // Calculate position for each category. for (let categoryIndex = 0; categoryIndex < dataLength; categoryIndex++) { - // Get x position for this category - const categoryX = xScale(categoryIndex); - if (categoryX !== undefined) { - // Calculate x position for this specific stack within the category - const stackX = categoryX + stackIndex * (barWidth + gapWidth); + // Get position for this category along the index axis. + const categoryPos = indexScale(categoryIndex); + if (categoryPos !== undefined) { + // Calculate position for this specific stack within the category. + const stackPos = categoryPos + stackIndex * (stackThickness + gapSize); configs.push({ categoryIndex, - x: stackX, - width: barWidth, + indexPos: stackPos, + thickness: stackThickness, }); } } return configs; - }, [xScale, yScale, drawingArea, dataLength, stackIndex, totalStacks, barPadding]); + }, [xScale, yScale, drawingArea, dataLength, layout, totalStacks, barPadding, stackIndex]); - if (xScale && !isCategoricalScale(xScale)) { + const indexScaleComputed = layout !== 'horizontal' ? xScale : yScale; + const valueScaleComputed = layout !== 'horizontal' ? yScale : xScale; + + if (indexScaleComputed && !isCategoricalScale(indexScaleComputed)) { throw new Error( - 'BarStackGroup requires a band scale for x-axis. See https://cds.coinbase.com/components/graphs/XAxis/#scale-type', + `BarStackGroup requires a band scale for ${ + layout !== 'horizontal' ? 'x-axis' : 'y-axis' + }. See https://cds.coinbase.com/components/charts/${ + layout !== 'horizontal' ? 'XAxis' : 'YAxis' + }/#scale-type`, ); } - if (!yScale || !drawingArea || stackConfigs.length === 0) return; + if (!indexScaleComputed || !valueScaleComputed || !drawingArea || stackConfigs.length === 0) + return; + + // In horizontal layout, render stacks in reverse order so top rows (lower categoryIndex) + // appear on top. Otherwise bottom rows would overlap and obscure top rows during animation. + const orderedConfigs = layout === 'horizontal' ? [...stackConfigs].reverse() : stackConfigs; - return stackConfigs.map(({ categoryIndex, x, width }) => ( + return orderedConfigs.map(({ categoryIndex, indexPos, thickness }) => ( )); }, diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index ff9bd80149..420f9228e4 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -3,7 +3,14 @@ import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Path } from '../Path'; -import { getBarPath } from '../utils'; +import { + defaultBarEnterOpacityTransition, + defaultBarEnterTransition, + getBarPath, + withStaggerDelayTransition, +} from '../utils'; +import { type BarTransition, getNormalizedStagger } from '../utils/bar'; +import { defaultTransition, getTransition } from '../utils/transition'; import type { BarComponentProps } from './Bar'; @@ -18,7 +25,7 @@ export const DefaultBar = memo( y, width, height, - borderRadius, + borderRadius = 4, roundTop, roundBottom, d, @@ -26,61 +33,117 @@ export const DefaultBar = memo( fillOpacity = 1, stroke, strokeWidth, - originY, + origin, + minSize = 1, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea, layout } = useCartesianChartContext(); const theme = useTheme(); const defaultFill = fill || theme.color.fgPrimary; - const targetPath = useMemo(() => { - const effectiveBorderRadius = borderRadius ?? 0; - const effectiveRoundTop = roundTop ?? true; - const effectiveRoundBottom = roundBottom ?? true; + const normalizedStagger = useMemo( + () => getNormalizedStagger(layout, x, y, drawingArea), + [layout, x, y, drawingArea], + ); - return ( - d || - getBarPath( - x, - y, - width, - height, - effectiveBorderRadius, - effectiveRoundTop, - effectiveRoundBottom, - ) + const enterTransition = useMemo( + () => + getTransition( + transitions?.enter, + animate, + defaultBarEnterTransition, + ) as BarTransition | null, + [transitions?.enter, animate], + ); + const enterTransitionWithStagger = useMemo( + () => withStaggerDelayTransition(enterTransition, normalizedStagger), + [enterTransition, normalizedStagger], + ); + const enterOpacityTransition = useMemo(() => { + if (transitions?.enterOpacity === undefined && enterTransition === null) return null; + + const enterOpacityTransition: BarTransition | null = getTransition( + transitions?.enterOpacity, + animate, + defaultBarEnterOpacityTransition, ); - }, [x, y, width, height, borderRadius, roundTop, roundBottom, d]); + + if (!enterOpacityTransition) return null; + + return { + ...enterOpacityTransition, + delay: enterOpacityTransition.delay ?? enterTransition?.delay, + staggerDelay: enterOpacityTransition.staggerDelay ?? enterTransition?.staggerDelay, + }; + }, [transitions?.enterOpacity, animate, enterTransition]); + const enterOpacityTransitionWithStagger = useMemo( + () => withStaggerDelayTransition(enterOpacityTransition, normalizedStagger), + [enterOpacityTransition, normalizedStagger], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedStagger, + ), + [transitions?.update, transition, animate, normalizedStagger], + ); const initialPath = useMemo(() => { - const effectiveBorderRadius = borderRadius ?? 0; - const effectiveRoundTop = roundTop ?? true; - const effectiveRoundBottom = roundBottom ?? true; - const baselineY = originY ?? y + height; + if (!animate) return; + const isHorizontalLayout = layout === 'horizontal'; + const baseline = origin ?? (isHorizontalLayout ? x : y + height); + + const initialX = isHorizontalLayout ? baseline : x; + const initialY = isHorizontalLayout ? y : baseline; + const initialWidth = isHorizontalLayout ? minSize : width; + const initialHeight = isHorizontalLayout ? height : minSize; return getBarPath( - x, - baselineY, - width, - 1, - effectiveBorderRadius, - effectiveRoundTop, - effectiveRoundBottom, + initialX, + initialY, + initialWidth, + initialHeight, + borderRadius, + !!roundTop, + !!roundBottom, + layout, ); - }, [x, originY, y, height, width, borderRadius, roundTop, roundBottom]); + }, [ + animate, + layout, + x, + y, + origin, + width, + height, + borderRadius, + roundTop, + roundBottom, + minSize, + ]); return ( ); }, diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx index 18bf3064c5..96d52d132c 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx @@ -1,9 +1,16 @@ import { memo, useMemo } from 'react'; -import { Group } from '@shopify/react-native-skia'; +import { Group, Skia } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; import { getBarPath } from '../utils'; -import { usePathTransition } from '../utils/transition'; +import { + type BarTransition, + defaultBarEnterTransition, + getNormalizedStagger, + getStackInitialClipRect, + withStaggerDelayTransition, +} from '../utils/bar'; +import { defaultTransition, getTransition, usePathTransition } from '../utils/transition'; import type { BarStackComponentProps } from './BarStack'; @@ -22,29 +29,78 @@ export const DefaultBarStack = memo( borderRadius = 4, roundTop = true, roundBottom = true, - yOrigin, + origin, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea, layout } = useCartesianChartContext(); + + const normalizedStagger = useMemo( + () => getNormalizedStagger(layout, x, y, drawingArea), + [layout, x, y, drawingArea], + ); + + const enterTransition = useMemo( + () => + getTransition( + transitions?.enter, + animate, + defaultBarEnterTransition, + ) as BarTransition | null, + [transitions?.enter, animate], + ); + const enterTransitionWithStagger = useMemo( + () => withStaggerDelayTransition(enterTransition, normalizedStagger), + [enterTransition, normalizedStagger], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedStagger, + ), + [animate, transitions?.update, transition, normalizedStagger], + ); // Generate target clip path (full bar) const targetPath = useMemo(() => { - return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); - }, [x, y, width, height, borderRadius, roundTop, roundBottom]); + return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom, layout); + }, [x, y, width, height, borderRadius, roundTop, roundBottom, layout]); // Initial clip path for entry animation (bar at baseline with minimal height) const initialPath = useMemo(() => { - const baselineY = yOrigin ?? y + height; - return getBarPath(x, baselineY, width, 1, borderRadius, roundTop, roundBottom); - }, [x, yOrigin, y, height, width, borderRadius, roundTop, roundBottom]); + if (!animate) return; + + const initialClipRect = getStackInitialClipRect({ x, y, width, height }, layout, origin); + + return getBarPath( + initialClipRect.x, + initialClipRect.y, + initialClipRect.width, + initialClipRect.height, + borderRadius, + roundTop, + roundBottom, + layout, + ); + }, [animate, layout, x, y, height, width, borderRadius, roundTop, roundBottom, origin]); const animatedClipPath = usePathTransition({ currentPath: targetPath, initialPath, - transition, + transitions: { enter: enterTransitionWithStagger, update: updateTransition }, }); - const clipPath = animate ? animatedClipPath : targetPath; + const staticClipPath = useMemo( + () => Skia.Path.MakeFromSVGString(targetPath) ?? Skia.Path.Make(), + [targetPath], + ); + + const clipPath = animate ? animatedClipPath : staticClipPath; return {children}; }, diff --git a/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx b/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx new file mode 100644 index 0000000000..3b1b8a8a0c --- /dev/null +++ b/packages/mobile-visualization/src/chart/bar/PercentageBarChart.tsx @@ -0,0 +1,153 @@ +import { forwardRef, memo, useMemo } from 'react'; +import type { View } from 'react-native'; + +import type { BarChartBaseProps, BarChartProps } from './BarChart'; +import { BarChart } from './BarChart'; +import type { BarSeries } from './BarStack'; + +/** Extended series type that supports single data values. */ +export type PercentageBarSeries = Omit & { + /** + * Data for this series. + * + * Can be either: + * - Single number: `1400` + * - Array of numbers: `[10, 15, 20]` + */ + data: number | Array; +}; + +export type PercentageBarChartBaseProps = Omit< + BarChartBaseProps, + | 'series' + | 'stacked' + | 'layout' + | 'roundBaseline' + | 'inset' + | 'enableScrubbing' + | 'onScrubberPositionChange' +> & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data. + */ + series?: PercentageBarSeries[]; + /** + * Chart layout - describes the direction bars/areas grow. + * - 'vertical': Bars grow vertically. X is category axis, Y is value axis. + * - 'horizontal' (default): Bars grow horizontally. Y is category axis, X is value axis. + * @default 'horizontal' + */ + layout?: BarChartBaseProps['layout']; + /** + * Whether to round the baseline of a bar (where the value is 0). + * @default true + */ + roundBaseline?: BarChartBaseProps['roundBaseline']; + /** + * Inset around the entire chart (outside the axes). + * @default 0 + */ + inset?: BarChartBaseProps['inset']; +}; + +/** + * Returns the value for a group index from numeric shorthand or per-group series data. + * @param data - A single number (group `0` only) or an array of values per group. + * @param groupIndex - The group index to read. + * @returns The clamped value for that group, or `null` when the value is `null`, undefined, or out of range. + */ +const unwrapSeriesDataValue = ( + data: PercentageBarSeries['data'], + groupIndex: number, +): number | null => { + const raw = typeof data === 'number' ? (groupIndex === 0 ? data : null) : data[groupIndex]; + return raw != null ? Math.max(0, raw) : null; +}; + +export type PercentageBarChartProps = PercentageBarChartBaseProps & + Omit< + BarChartProps, + | 'series' + | 'stacked' + | 'layout' + | 'roundBaseline' + | 'inset' + | 'enableScrubbing' + | 'onScrubberPositionChange' + >; + +export const PercentageBarChart = memo( + forwardRef( + ( + { + series, + layout = 'horizontal', + roundBaseline = true, + inset = 0, + transitions, + xAxis, + yAxis, + testID, + children, + ...props + }, + ref, + ) => { + const barSeries = useMemo(() => { + const groupCount = Math.max( + 0, + ...(series?.map(({ data }) => (typeof data === 'number' ? 1 : data.length)) ?? []), + ); + + const totals = Array.from( + { length: groupCount }, + (_, i) => + series?.reduce((sum, { data }) => sum + (unwrapSeriesDataValue(data, i) ?? 0), 0) ?? 0, + ); + + return series?.map((s) => ({ + ...s, + data: Array.from({ length: groupCount }, (_, i) => { + const val = unwrapSeriesDataValue(s.data, i); + return val != null && totals[i] > 0 ? (val / totals[i]) * 100 : null; + }), + })); + }, [series]); + + const isHorizontalLayout = layout === 'horizontal'; + + const xAxisConfig: BarChartProps['xAxis'] = useMemo(() => { + return isHorizontalLayout + ? { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...xAxis } + : { categoryPadding: 0, ...xAxis }; + }, [isHorizontalLayout, xAxis]); + + const yAxisConfig: BarChartProps['yAxis'] = useMemo(() => { + return isHorizontalLayout + ? { categoryPadding: 0, ...yAxis } + : { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...yAxis }; + }, [isHorizontalLayout, yAxis]); + + return ( + + {children} + + ); + }, + ), +); + +PercentageBarChart.displayName = 'PercentageBarChart'; diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx index ba10f6d9d0..c5f28a3186 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,14 +1,24 @@ -import { memo, useEffect, useState } from 'react'; -import { Button } from '@coinbase/cds-mobile/buttons'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +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 { VStack } from '@coinbase/cds-mobile/layout'; +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 { XAxis, YAxis } from '../../axis'; -import { CartesianChart } from '../../CartesianChart'; -import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line'; +import { CartesianChart, type CartesianChartProps } from '../../CartesianChart'; +import { useCartesianChartContext } from '../../ChartProvider'; +import { DefaultLegendEntry } from '../../legend'; +import { type LineComponentProps, ReferenceLine, SolidLine, type SolidLineProps } from '../../line'; +import { Scrubber } from '../../scrubber'; +import { getPointOnSerializableScale, unwrapAnimatedValue, useScrubberContext } from '../../utils'; +import type { BarComponentProps } from '../Bar'; import { Bar } from '../Bar'; -import { BarChart } from '../BarChart'; +import { BarChart, type BarChartProps } from '../BarChart'; import { BarPlot } from '../BarPlot'; import type { BarStackComponentProps } from '../BarStack'; import { DefaultBarStack } from '../DefaultBarStack'; @@ -16,6 +26,9 @@ import { DefaultBarStack } from '../DefaultBarStack'; const ThinSolidLine = memo((props: SolidLineProps) => ); const defaultChartHeight = 250; +const baselineThresholdData = [40, 28, 21, 5, 48, 5, 28, 2, 29, 48, 18, 30, 29, 8].map( + (value) => value + 50, +); const PositiveAndNegativeCashFlow = () => { const theme = useTheme(); @@ -102,7 +115,7 @@ const CustomBarStackComponent = memo(({ children, ...props }: BarStackComponentP borderRadius={1000} fill={theme.color.bgTertiary} height={diameter} - originY={props.y} + origin={props.y} width={diameter} x={props.x} y={props.y - diameter} @@ -114,6 +127,7 @@ const CustomBarStackComponent = memo(({ children, ...props }: BarStackComponentP }); const MonthlyRewards = () => { + const theme = useTheme(); const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; @@ -123,10 +137,10 @@ const MonthlyRewards = () => { const [roundBaseline, setRoundBaseline] = useState(true); const series = [ - { id: 'purple', data: purple, color: '#b399ff' }, - { id: 'blue', data: blue, color: '#4f7cff' }, - { id: 'cyan', data: cyan, color: '#00c2df' }, - { id: 'green', data: green, color: '#33c481' }, + { id: 'purple', data: purple, color: `rgb(${theme.spectrum.purple30})` }, + { id: 'blue', data: blue, color: `rgb(${theme.spectrum.blue30})` }, + { id: 'cyan', data: cyan, color: `rgb(${theme.spectrum.teal30})` }, + { id: 'green', data: green, color: `rgb(${theme.spectrum.green30})` }, ]; return ( @@ -146,7 +160,7 @@ const MonthlyRewards = () => { tickLabelFormatter: (index) => { return months[index]; }, - categoryPadding: 0.27, + categoryPadding: 0.25, }} /> @@ -612,56 +626,791 @@ const BandGridPositionExample = ({ ); -const BarChartStories = () => { +// --- Composed Examples --- + +const candlestickStockData = [...btcCandles].reverse().slice(0, 90); + +const CandlesticksHeader = memo(({ currentIndex }: { currentIndex: number | undefined }) => { + const formatPrice = useCallback((price: string) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(parseFloat(price)); + }, []); + + const formatThousandsPriceNumber = useCallback((price: number) => { + const formattedPrice = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price / 1000); + + return `${formattedPrice}k`; + }, []); + + const currentText = useMemo(() => { + if (currentIndex !== undefined) { + return `Open: ${formatThousandsPriceNumber(parseFloat(candlestickStockData[currentIndex].open))}, Close: ${formatThousandsPriceNumber( + parseFloat(candlestickStockData[currentIndex].close), + )}, Volume: ${(parseFloat(candlestickStockData[currentIndex].volume) / 1000).toFixed(2)}k`; + } + return formatPrice(candlestickStockData[candlestickStockData.length - 1].close); + }, [currentIndex, formatThousandsPriceNumber, formatPrice]); + return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + {currentText} + + ); +}); + +const CandlesticksChart = memo( + ({ + infoTextId, + onScrubberPositionChange, + }: { + infoTextId: string; + onScrubberPositionChange: (index: number | undefined) => void; + }) => { + const theme = useTheme(); + const min = useMemo( + () => Math.min(...candlestickStockData.map((data) => parseFloat(data.low))), + [], + ); + + const CandleThinSolidLine = memo((props: SolidLineProps) => ( + + )); + + const BandwidthHighlight = memo(({ stroke }: LineComponentProps) => { + const { getXSerializableScale, drawingArea } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + + const rectWidth = useMemo(() => { + if (xScale !== undefined && xScale.type === 'band') { + return xScale.bandwidth; + } + return 0; + }, [xScale]); + + const xPos = useDerivedValue(() => { + const position = unwrapAnimatedValue(scrubberPosition); + const xPos = + position !== undefined && xScale + ? getPointOnSerializableScale(position, xScale) + : undefined; + return xPos !== undefined ? xPos - rectWidth / 2 : 0; + }, [scrubberPosition, xScale]); + + const opacity = useDerivedValue(() => (xPos.value !== undefined ? 1 : 0), [xPos]); + + return ( + + ); + }); + + const candlesData = useMemo( + () => + candlestickStockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]) as [ + number, + number, + ][], + [], + ); + + const CandlestickBarComponent = memo( + ({ x, y, width, height, dataX, ...props }) => { + const { getYScale } = useCartesianChartContext(); + const yScale = getYScale(); + + const wickX = x + width / 2; + + const timePeriodValue = candlestickStockData[dataX as number]; + + const open = parseFloat(timePeriodValue.open); + const close = parseFloat(timePeriodValue.close); + + const bullish = open < close; + const theme = useTheme(); + const color = bullish ? theme.color.fgPositive : theme.color.fgNegative; + const openY = yScale?.(open) ?? 0; + const closeY = yScale?.(close) ?? 0; + + const bodyHeight = Math.abs(openY - closeY); + const bodyY = openY < closeY ? openY : closeY; + + return ( + <> + + + + ); + }, + ); + + const formatThousandsPriceNumber = useCallback((price: number) => { + const formattedPrice = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(price / 1000); + + return `${formattedPrice}k`; + }, []); + + const formatTime = useCallback((index: number | null) => { + if (index === null || index === undefined || index >= candlestickStockData.length) return ''; + const ts = parseInt(candlestickStockData[index].start); + return new Date(ts * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }, []); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const candle = candlestickStockData[index]; + return `${formatTime(index)}: O ${formatThousandsPriceNumber(parseFloat(candle.open))} H ${formatThousandsPriceNumber(parseFloat(candle.high))} L ${formatThousandsPriceNumber(parseFloat(candle.low))} C ${formatThousandsPriceNumber(parseFloat(candle.close))}`; + }, + [formatTime, formatThousandsPriceNumber], + ); + + return ( + + + + + <>{children}} + /> + + ); + }, +); + +const Candlesticks = () => { + const infoTextId = useId(); + const [currentIndex, setCurrentIndex] = useState(); + + return ( + + + + ); }; -export default BarChartStories; +const DAY_LENGTH_MINUTES = 1440; + +type SunlightChartData = Array<{ + label: string; + value: number; +}>; + +const sunlightData: SunlightChartData = [ + { label: 'Jan', value: 598 }, + { label: 'Feb', value: 635 }, + { label: 'Mar', value: 688 }, + { label: 'Apr', value: 753 }, + { label: 'May', value: 812 }, + { label: 'Jun', value: 855 }, + { label: 'Jul', value: 861 }, + { label: 'Aug', value: 828 }, + { label: 'Sep', value: 772 }, + { label: 'Oct', value: 710 }, + { label: 'Nov', value: 648 }, + { label: 'Dec', value: 605 }, +]; + +const SunlightChartInner = memo( + ({ + data, + height = 300, + ...props + }: Omit & { data: SunlightChartData }) => { + const theme = useTheme(); + + const SunlightThinSolidLine = memo((props: SolidLineProps) => ( + + )); + + return ( + value), + yAxisId: 'sunlight', + color: `rgb(${theme.spectrum.yellow40})`, + }, + { + id: 'day', + data: data.map(() => DAY_LENGTH_MINUTES), + yAxisId: 'day', + color: `rgb(${theme.spectrum.blue100})`, + }, + ]} + xAxis={{ + ...props.xAxis, + scaleType: 'band', + data: data.map(({ label }) => label), + }} + yAxis={[ + { + id: 'day', + domain: { min: 0, max: DAY_LENGTH_MINUTES }, + domainLimit: 'strict', + }, + { + id: 'sunlight', + domain: { min: 0, max: DAY_LENGTH_MINUTES }, + domainLimit: 'strict', + }, + ]} + > + + + + + + ); + }, +); + +const SunlightChart = () => { + return ( + + + + 2026 Sunlight data for the first day of each month in Atlanta, Georgia, provided by NOAA. + + + ); +}; + +const PriceRange = () => { + const candles = [...btcCandles].reverse().slice(0, 180); + const data: [number, number][] = useMemo( + () => candles.map((candle) => [parseFloat(candle.low), parseFloat(candle.high)]), + [candles], + ); + + const min = useMemo(() => Math.min(...data.map(([low]) => low)), [data]); + const max = useMemo(() => Math.max(...data.map(([, high]) => high)), [data]); + + const tickFormatter = useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +}; + +const HorizontalBarChart = () => { + const labels = ['BTC', 'ETH', 'SOL', 'ADA']; + const allocation = [42, 28, 18, 12]; + + return ( + `${value}%` }} + yAxis={{ data: labels, scaleType: 'band' }} + /> + ); +}; + +const AxisBaselineExample = () => { + return ( + + ); +}; + +const AxisBaselineThresholdExample = () => { + const theme = useTheme(); + + return ( + + + + + ); +}; +function BuyVsSellExample() { + function BuyVsSellLegend({ buy, sell }: { buy: number; sell: number }) { + const theme = useTheme(); + return ( + + + {buy}% bought + + } + seriesId="buy" + /> + + {sell}% sold + + } + seriesId="sell" + /> + + ); + } + + function BuyVsSellChart({ + buy, + sell, + animate = true, + borderRadius = 3, + height = 6, + inset = 0, + layout = 'horizontal', + stackGap = 4, + xAxis, + yAxis, + barMinSize = height, + ...props + }: Omit & { buy: number; sell: number; height?: number }) { + const theme = useTheme(); + return ( + + + + + ); + } + + return ; +} + +const PopulationPyramid = () => { + const theme = useTheme(); + + const ageGroups = [ + '100+ yrs', + '95-99 yrs', + '90-94 yrs', + '85-89 yrs', + '80-84 yrs', + '75-79 yrs', + '70-74 yrs', + '65-69 yrs', + '60-64 yrs', + '55-59 yrs', + '50-54 yrs', + '45-49 yrs', + '40-44 yrs', + '35-39 yrs', + '30-34 yrs', + '25-29 yrs', + '20-24 yrs', + '15-19 yrs', + '10-14 yrs', + '5-9 yrs', + '0-4 yrs', + ]; + + const malePopulation = [ + 14587, 48604, 83560, 128957, 184152, 248505, 498683, 706420, 852333, 939629, 1002195, 1001264, + 960282, 1161371, 1105023, 1061755, 1019343, 1023264, 1026330, 984773, 944071, + ]; + + const femalePopulation = [ + 14122, 46974, 80768, 124663, 178043, 240293, 482271, 683270, 824525, 909115, 969807, 969070, + 929571, 1122380, 1068050, 1026356, 985483, 989404, 992505, 952453, 913222, + ]; + + const numberWithSuffixFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + notation: 'compact', + }), + [], + ); + + const tickLabelFormatter = useCallback( + (value: number) => numberWithSuffixFormatter.format(Math.abs(value)), + [numberWithSuffixFormatter], + ); + + const domainSymmetric = useCallback((bounds: { min: number; max: number }) => { + const extremum = Math.max(-bounds.min, bounds.max); + const roundedExtremum = Math.ceil(extremum / 100_000) * 100_000; + return { min: -roundedExtremum, max: roundedExtremum }; + }, []); + + const series = [ + { + id: 'male', + label: 'Male', + data: malePopulation.map((population) => -population), + color: `rgb(${theme.spectrum.blue40})`, + stackId: 'population', + }, + { + id: 'female', + label: 'Female', + data: femalePopulation, + color: `rgb(${theme.spectrum.pink40})`, + stackId: 'population', + }, + ]; + + return ( + + + + + + ); +}; + +type ExampleItem = { + title: string; + component: React.ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { + title: 'Basic', + component: , + }, + { + title: 'Animated Auto-Updating', + component: , + }, + { + title: 'Negative Values with Top Axis', + component: , + }, + { + title: 'Axis Baseline', + component: , + }, + { + title: 'Axis Baseline Threshold', + component: , + }, + { + title: 'Positive and Negative Cash Flow', + component: , + }, + { + title: 'Fiat & Stablecoin Balance', + component: , + }, + { + title: 'Monthly Rewards', + component: , + }, + { + title: 'Multiple Y Axes', + component: , + }, + { + title: 'Y-Axis Continuous ColorMap', + component: , + }, + { + title: 'Y-Axis Discrete ColorMap', + component: , + }, + { + title: 'X-Axis Continuous ColorMap', + component: , + }, + { + title: 'X-Axis Discrete ColorMap', + component: , + }, + { + title: 'X-Axis Multi-Segment ColorMap', + component: , + }, + { + title: 'ColorMap with Opacity', + component: , + }, + { + title: 'Band Grid Position', + component: ( + + + + + + + ), + }, + { + title: 'Candlesticks', + component: , + }, + { + title: 'Monthly Sunlight', + component: , + }, + { + title: 'Price Range', + component: , + }, + { + title: 'Horizontal Layout', + component: , + }, + { + title: 'Buy vs Sell', + component: , + }, + { + title: 'Population Pyramid', + component: , + }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + {currentExample.component} + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx b/packages/mobile-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx new file mode 100644 index 0000000000..50e0fd2540 --- /dev/null +++ b/packages/mobile-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx @@ -0,0 +1,773 @@ +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { IconButton } from '@coinbase/cds-mobile/buttons'; +import { Switch } from '@coinbase/cds-mobile/controls'; +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 { RollingNumber } from '@coinbase/cds-mobile/numbers'; +import { Text } from '@coinbase/cds-mobile/typography'; +import { Group, Path as SkiaPath, Skia } from '@shopify/react-native-skia'; + +import { useCartesianChartContext } from '../../ChartProvider'; +import { + DefaultLegendEntry, + DefaultLegendShape, + Legend, + type LegendEntryProps, +} from '../../legend'; +import { Path } from '../../Path'; +import { getBarPath } from '../../utils'; +import { getDottedAreaPath } from '../../utils/path'; +import type { BarComponentProps } from '../Bar'; +import { DefaultBar } from '../DefaultBar'; +import { PercentageBarChart, type PercentageBarSeries } from '../PercentageBarChart'; + +const DOTTED_BAR_PATTERN_SIZE = 4; +const DOTTED_BAR_DOT_SIZE = 1; +const DOTTED_BAR_OUTLINE_STROKE_WIDTH = 2; + +const DottedBarComponent = memo(function DottedBarComponent(props: BarComponentProps) { + const { x, y, width, height, fill, d } = props; + + const dottedPath = useMemo( + () => getDottedAreaPath({ x, y, width, height }, DOTTED_BAR_PATTERN_SIZE, DOTTED_BAR_DOT_SIZE), + [x, y, width, height], + ); + + const barClipPath = useMemo( + () => (d ? (Skia.Path.MakeFromSVGString(d) ?? undefined) : undefined), + [d], + ); + + const dotsSkiaPath = useMemo( + () => (dottedPath ? (Skia.Path.MakeFromSVGString(dottedPath) ?? undefined) : undefined), + [dottedPath], + ); + + return ( + <> + + {dotsSkiaPath && fill ? : null} + + + + ); +}); + +/** + * Builds an SVG path for a horizontal bar segment with a pill cap on one end + * and a slanted straight edge on the other. The two segments' inner edges + * are parallel, producing a parallelogram-shaped gap between them. + */ +function getSlantedHorizontalBarPath( + x: number, + y: number, + width: number, + height: number, + borderRadius: number, + pillLeft: boolean, + pillRight: boolean, + slantDx: number, +): string | undefined { + if (width <= 0 || height <= 0) return undefined; + if (pillLeft === pillRight) return undefined; + + const r = Math.min(borderRadius, height / 2, width / 2); + const s = Math.min(Math.max(0, slantDx), width - r * 2); + + const x0 = x; + const x1 = x + width; + const y0 = y; + const y1 = y + height; + + // Pill left, slanted right + if (pillLeft && !pillRight) { + return [ + `M ${x0 + r} ${y0}`, + `L ${x1} ${y0}`, + `L ${x1 - s} ${y1}`, + `L ${x0 + r} ${y1}`, + `A ${r} ${r} 0 0 1 ${x0} ${y1 - r}`, + `L ${x0} ${y0 + r}`, + `A ${r} ${r} 0 0 1 ${x0 + r} ${y0}`, + 'Z', + ].join(' '); + } + + // Slanted left, pill right + if (!pillLeft && pillRight) { + return [ + `M ${x0 + s} ${y0}`, + `L ${x1 - r} ${y0}`, + `A ${r} ${r} 0 0 1 ${x1} ${y0 + r}`, + `L ${x1} ${y1 - r}`, + `A ${r} ${r} 0 0 1 ${x1 - r} ${y1}`, + `L ${x0} ${y1}`, + 'Z', + ].join(' '); + } + + return undefined; +} + +const SLANT_DX = 8; +const BASELINE_THRESHOLD = 1; + +const SlantedStackBar = memo(function SlantedStackBar(props: BarComponentProps) { + const { layout } = useCartesianChartContext(); + const { + x, + y, + width, + height, + borderRadius = 4, + roundTop, + roundBottom, + d: defaultD, + fill, + fillOpacity, + dataX, + origin: _origin, + dataY: _dataY, + seriesId: _seriesId, + minSize: _minSize, + ...rest + } = props; + + const d = useMemo(() => { + if (layout !== 'horizontal') { + return ( + defaultD ?? getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + } + + const isLeftmost = Array.isArray(dataX) && Math.abs(dataX[0]) < BASELINE_THRESHOLD; + + return ( + getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + isLeftmost, + !isLeftmost, + SLANT_DX, + ) ?? + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + }, [layout, defaultD, dataX, x, y, width, height, borderRadius, roundTop, roundBottom]); + + if (!d) return null; + + return ( + + ); +}); + +const Basics = () => { + const theme = useTheme(); + return ( + + ); +}; + +const StackGap = () => { + const theme = useTheme(); + return ( + + ); +}; + +const BorderRadius = () => { + const theme = useTheme(); + return ( + + ); +}; + +const DataExample = () => { + const theme = useTheme(); + return ( + + ); +}; + +const BarStackSpacing = () => { + const theme = useTheme(); + return ( + + ); +}; + +const MinimumBarSize = () => { + const theme = useTheme(); + return ( + + ); +}; + +const TaxesStyleConfirmedVsNeedReview = () => { + const theme = useTheme(); + + const series: PercentageBarSeries[] = [ + { + id: 'confirmed', + data: 28, + label: 'Confirmed', + color: theme.color.fgPositive, + }, + { + id: 'needs-review', + data: 2, + label: 'Needs review', + color: theme.color.fgWarning, + }, + ]; + + return ( + + + + Estimated gain + + +$30,000 + + + + + + + Confirmed + + + +$28,000 + {}} + variant="foregroundMuted" + /> + + + + + + Needs review + + + + Up to $2,000 + + 11 transfers + + + {}} + variant="foregroundMuted" + /> + + + + + ); +}; + +const DottedBarFirstSeriesOnly = () => { + const theme = useTheme(); + + const dottedBarSeries = useMemo( + () => [ + { + id: 'segment-a', + data: 60, + label: 'Segment A', + color: `rgb(${theme.spectrum.teal60})`, + BarComponent: DottedBarComponent, + }, + { + id: 'segment-b', + data: 30, + label: 'Segment B', + color: `rgb(${theme.spectrum.chartreuse50})`, + }, + { id: 'segment-c', data: 10, label: 'Segment C', color: `rgb(${theme.spectrum.indigo40})` }, + ], + [theme], + ); + + return ; +}; + +const DottedBarChartLevel = () => { + const theme = useTheme(); + return ( + + ); +}; + +function randomShares(): number[] { + const raw = [Math.random() + 0.1, Math.random() + 0.1, Math.random() + 0.1]; + const sum = raw[0] + raw[1] + raw[2]; + return raw.map((v) => Math.max(1, Math.round((v / sum) * 100))); +} + +function generateAnimationData(): number[][] { + return [randomShares(), randomShares(), randomShares()]; +} + +const Animations = () => { + const theme = useTheme(); + const [animate, setAnimate] = useState(true); + const [data, setData] = useState(generateAnimationData); + + useEffect(() => { + const id = setInterval(() => setData(generateAnimationData()), 800); + return () => clearInterval(id); + }, []); + + const series = useMemo( + () => [ + { id: 'btc', data: data.map((q) => q[0]), label: 'BTC', color: assets.btc.color }, + { id: 'eth', data: data.map((q) => q[1]), label: 'ETH', color: assets.eth.color }, + { id: 'other', data: data.map((q) => q[2]), label: 'Other', color: theme.color.fgMuted }, + ], + [data, theme], + ); + + return ( + + + setAnimate((v) => !v)}> + Animate + + + `${value}%`, + }} + yAxis={{ + categoryPadding: 0.75, + data: ['Q1 2025', 'Q2 2025', 'Q3 2025'], + position: 'left', + requestedTickCount: 5, + showTickMarks: true, + }} + /> + + ); +}; + +/** Fake "projected value" copy: scales with live % so subtitles stay in sync with the bar. */ +const liveFeedSubtitleBase = 100; +const liveFeedYesDollarsPerPercentPoint = (182 - liveFeedSubtitleBase) / 50; +const liveFeedNoDollarsPerPercentPoint = (222 - liveFeedSubtitleBase) / 50; + +function getLiveFeedProjectedValue(seriesId: string, percentage: number): number | undefined { + const inverseShare = 100 - percentage; + if (seriesId === 'yes') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedYesDollarsPerPercentPoint); + } + if (seriesId === 'no') { + return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedNoDollarsPerPercentPoint); + } + return undefined; +} + +const liveFeedCurrencyFormat = { + style: 'currency' as const, + currency: 'USD', + maximumFractionDigits: 0, +}; + +const LiveFeedCTALegendEntry = memo(function LiveFeedCTALegendEntry({ + seriesId, + label, + color, +}: LegendEntryProps) { + const { series: contextSeries } = useCartesianChartContext(); + const seriesData = contextSeries.find((s) => s.id === seriesId); + const percentage = (seriesData?.data as number[])?.[0] ?? 0; + const projectedValue = getLiveFeedProjectedValue(seriesId, percentage); + + return ( + + + + + {label} {'· '} + + + + {projectedValue != null && ( + + + ${liveFeedSubtitleBase} → + + + + )} + + + ); +}); + +const LiveUpdatingData = () => { + const theme = useTheme(); + const [tick, setTick] = useState(0); + + const yesValue = 50 + Math.sin(tick * 0.05) * 49; + const noValue = 50 - Math.sin(tick * 0.05) * 49; + + const series: PercentageBarSeries[] = [ + { id: 'yes', data: yesValue, label: 'Yes', color: theme.color.fgPositive }, + { id: 'no', data: noValue, label: 'No', color: theme.color.fgNegative }, + ]; + + useEffect(() => { + const id = setInterval(() => setTick((t) => t + 4), 1000); + return () => clearInterval(id); + }, []); + + return ( + + } + legendPosition="bottom" + series={series} + stackGap={2} + /> + ); +}; + +const VerticalMix = () => { + const theme = useTheme(); + + const series: PercentageBarSeries[] = [ + { + id: 'btc', + data: [55, 52, 48, 45, 50, 58, 62, 57, 53, 49, 44, 46], + label: 'BTC', + color: assets.btc.color, + }, + { + id: 'eth', + data: [30, 33, 35, 38, 32, 27, 25, 29, 34, 37, 40, 38], + label: 'ETH', + color: assets.eth.color, + }, + { + id: 'other', + data: [15, 15, 17, 17, 18, 15, 13, 14, 13, 14, 16, 16], + label: 'Other', + color: theme.color.fgMuted, + }, + ]; + + return ( + + ); +}; + +const BuyVsSellLegend = memo(function BuyVsSellLegend({ + series, +}: { + series: PercentageBarSeries[]; +}) { + const [buy, sell] = series; + return ( + + + {buy.data}% bought + + } + seriesId={buy.id} + shape={buy.legendShape as 'circle'} + /> + + {sell.data}% sold + + } + seriesId={sell.id} + shape={sell.legendShape as 'square'} + /> + + ); +}); + +const BuyVsSell = () => { + const theme = useTheme(); + + const buySellSeries = useMemo( + () => [ + { id: 'buy', data: 76, color: theme.color.fgPositive, legendShape: 'circle' }, + { id: 'sell', data: 24, color: theme.color.fgNegative, legendShape: 'square' }, + ], + [theme], + ); + + return ( + + + + + ); +}; + +const SlantedStackGap = () => { + const theme = useTheme(); + return ( + + ); +}; + +type ExampleItem = { + title: string; + component: React.ReactNode; +}; + +function ExampleNavigator() { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { title: 'Basics', component: }, + { title: 'Stack Gap', component: }, + { title: 'Border Radius', component: }, + { title: 'Sparse Data', component: }, + { title: 'Bar Stack Spacing', component: }, + { title: 'Minimum Bar Size', component: }, + { + title: 'Taxes style', + component: , + }, + { title: 'Slanted stack gap', component: }, + { title: 'Dotted bar (first series only)', component: }, + { title: 'Dotted bar (chart-level)', component: }, + { title: 'Animations', component: }, + { title: 'Live-updating Data', component: }, + { title: 'Vertical Mix', component: }, + { title: 'Buy vs Sell', component: }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + {currentExample.component} + + + ); +} + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx b/packages/mobile-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx new file mode 100644 index 0000000000..49ef24016d --- /dev/null +++ b/packages/mobile-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx @@ -0,0 +1,124 @@ +import { DefaultThemeProvider } from '@coinbase/cds-mobile/utils/testHelpers'; +import { render, screen, within } from '@testing-library/react-native'; + +import { PercentageBarChart } from '../PercentageBarChart'; + +type MockSkPath = { + type: string; + addRect: jest.Mock; + addRRect: jest.Mock; + interpolate: jest.Mock; + toSVGString: jest.Mock; + copy: jest.Mock; +}; + +const makePath = (): MockSkPath => ({ + type: 'SkPath', + addRect: jest.fn(), + addRRect: jest.fn(), + interpolate: jest.fn(() => makePath()), + toSVGString: jest.fn(() => ''), + copy: jest.fn(() => makePath()), +}); + +jest.mock('@shopify/react-native-skia', () => { + const React = require('react'); + const { View } = require('react-native'); + return { + Canvas: ({ children, style }: { children: React.ReactNode; style?: unknown }) => + React.createElement(View, { style, testID: 'skia-canvas' }, children), + Group: ({ children }: { children?: React.ReactNode }) => children ?? null, + Path: () => null, + ClipOp: { Intersect: 0 }, + Skia: { + Path: { + Make: jest.fn(makePath), + MakeFromSVGString: jest.fn((str: string) => ({ ...makePath(), svgString: str })), + }, + TypefaceFontProvider: { Make: jest.fn(() => ({})) }, + }, + usePathInterpolation: jest.fn(() => makePath()), + notifyChange: jest.fn(), + }; +}); + +jest.mock('react-native-reanimated', () => ({ + ...jest.requireActual('react-native-reanimated/mock'), + useSharedValue: jest.fn((v: number) => ({ value: v })), +})); + +jest.mock('../../ChartContextBridge', () => { + const React = require('react'); + return { + ChartBridgeProvider: ({ children }: { children: React.ReactNode }) => children, + useChartContextBridge: + () => + ({ children }: { children: React.ReactNode }) => + children, + }; +}); + +describe('PercentageBarChart', () => { + it('renders chart shell', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-chart')).toBeTruthy(); + }); + + it('renders in vertical layout', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-vertical')).toBeTruthy(); + }); + + it('renders legend entries for each series', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-legend')).toBeTruthy(); + const legend = screen.getByLabelText('Legend'); + expect(within(legend).getAllByText('A')).toHaveLength(1); + expect(within(legend).getAllByText('B')).toHaveLength(1); + }); +}); diff --git a/packages/mobile-visualization/src/chart/bar/index.ts b/packages/mobile-visualization/src/chart/bar/index.ts index 2c4224e3c6..ed2ef254ee 100644 --- a/packages/mobile-visualization/src/chart/bar/index.ts +++ b/packages/mobile-visualization/src/chart/bar/index.ts @@ -6,4 +6,5 @@ export * from './BarStack'; export * from './BarStackGroup'; export * from './DefaultBar'; export * from './DefaultBarStack'; +export * from './PercentageBarChart'; // codegen:end diff --git a/packages/mobile-visualization/src/chart/gradient/Gradient.tsx b/packages/mobile-visualization/src/chart/gradient/Gradient.tsx index 9f642a2944..4105af7879 100644 --- a/packages/mobile-visualization/src/chart/gradient/Gradient.tsx +++ b/packages/mobile-visualization/src/chart/gradient/Gradient.tsx @@ -1,15 +1,30 @@ -import { memo, useMemo } from 'react'; -import { LinearGradient, vec } from '@shopify/react-native-skia'; +import { memo, useEffect, useMemo, useRef } from 'react'; +import { useDerivedValue, useSharedValue } from 'react-native-reanimated'; +import { LinearGradient } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; -import type { GradientDefinition } from '../utils'; -import { getColorWithOpacity, getGradientConfig } from '../utils/gradient'; +import { buildTransition, defaultTransition, instantTransition, type Transition } from '../utils'; +import { + getColorWithOpacity, + getGradientAxis, + getGradientConfig, + type GradientDefinition, +} from '../utils/gradient'; export type GradientBaseProps = { + /** + * Whether to animate gradient changes. + */ + animate?: boolean; /** * Gradient definition with stops, axis, and other configuration. */ gradient: GradientDefinition; + /** + * X-axis ID to use for gradient processing. + * When provided, the gradient will align with the specified x-axis range. + */ + xAxisId?: string; /** * Y-axis ID to use for gradient processing. * When provided, the gradient will align with the specified y-axis range. @@ -18,7 +33,13 @@ export type GradientBaseProps = { yAxisId?: string; }; -export type GradientProps = GradientBaseProps; +export type GradientProps = GradientBaseProps & { + /** + * Transition configuration for animation. + * @default defaultTransition + */ + transition?: Transition; +}; /** * Renders a Skia LinearGradient element based on a GradientDefinition. @@ -29,34 +50,145 @@ export type GradientProps = GradientBaseProps; * {gradient && } * */ -export const Gradient = memo(({ gradient, yAxisId }) => { - const context = useCartesianChartContext(); +export const Gradient = memo( + ({ gradient, xAxisId, yAxisId, animate: animateProp, transition: transitionProp }) => { + const { + animate: animateContext, + getXScale, + getYScale, + drawingArea, + layout, + } = useCartesianChartContext(); + const animate = animateProp ?? animateContext; + const transition = useMemo(() => { + if (!animate) return instantTransition; + return transitionProp ?? defaultTransition; + }, [transitionProp, animate]); + + const xScale = getXScale(xAxisId); + const yScale = getYScale(yAxisId); + + // Process gradient definition into stops + const stops = useMemo(() => { + if (!xScale || !yScale) return; + return getGradientConfig(gradient, xScale, yScale, layout); + }, [gradient, xScale, yScale, layout]); + + const axis = getGradientAxis(gradient, layout); + const scale = axis === 'x' ? xScale : yScale; + const shouldRender = !!stops && !!scale; + + const range = scale?.range() ?? [0, 0]; + const [rangeStart = 0, rangeEnd = 0] = range; + const targetStart = + axis === 'x' ? { x: rangeStart, y: drawingArea.y } : { x: drawingArea.x, y: rangeStart }; + const targetEnd = + axis === 'x' ? { x: rangeEnd, y: drawingArea.y } : { x: drawingArea.x, y: rangeEnd }; + + // Extract colors and positions for LinearGradient. + const colors = useMemo( + () => (stops ?? []).map((stop) => getColorWithOpacity(stop.color, stop.opacity ?? 1)), + [stops], + ); + const targetPositions = useMemo(() => (stops ?? []).map((stop) => stop.offset), [stops]); + + const startX = useSharedValue(targetStart.x); + const startY = useSharedValue(targetStart.y); + const endX = useSharedValue(targetEnd.x); + const endY = useSharedValue(targetEnd.y); + + const fromPositions = useSharedValue(targetPositions); + const toPositions = useSharedValue(targetPositions); + const positionsProgress = useSharedValue(1); + + const hasRendered = useRef(false); + + useEffect(() => { + if (!shouldRender) { + hasRendered.current = false; + return; + } + + if (!hasRendered.current) { + hasRendered.current = true; + + startX.value = targetStart.x; + startY.value = targetStart.y; + endX.value = targetEnd.x; + endY.value = targetEnd.y; + + fromPositions.value = [...targetPositions]; + toPositions.value = [...targetPositions]; + positionsProgress.value = 1; + return; + } + + startX.value = buildTransition(targetStart.x, transition); + startY.value = buildTransition(targetStart.y, transition); + endX.value = buildTransition(targetEnd.x, transition); + endY.value = buildTransition(targetEnd.y, transition); + + const canAnimatePositions = toPositions.value.length === targetPositions.length; + if (canAnimatePositions) { + fromPositions.value = [...toPositions.value]; + toPositions.value = [...targetPositions]; + positionsProgress.value = 0; + positionsProgress.value = buildTransition(1, transition); + } else { + fromPositions.value = [...targetPositions]; + toPositions.value = [...targetPositions]; + positionsProgress.value = 1; + } + }, [ + transition, + targetStart.x, + targetStart.y, + targetEnd.x, + targetEnd.y, + targetPositions, + startX, + startY, + endX, + endY, + fromPositions, + toPositions, + positionsProgress, + shouldRender, + ]); - const xScale = context.getXScale(); - const yScale = context.getYScale(yAxisId); + const start = useDerivedValue(() => { + return { + x: startX.value, + y: startY.value, + }; + }, [startX, startY]); - const axis = gradient.axis ?? 'y'; - const scale = axis === 'x' ? xScale : yScale; + const end = useDerivedValue(() => { + return { + x: endX.value, + y: endY.value, + }; + }, [endX, endY]); - // Process gradient definition into stops - const stops = useMemo(() => { - if (!xScale || !yScale) return; - return getGradientConfig(gradient, xScale, yScale); - }, [gradient, xScale, yScale]); + const positions = useDerivedValue(() => { + const from = fromPositions.value; + const to = toPositions.value; + const progress = positionsProgress.value; - if (!stops || !scale) return; + if (to.length === 0) return []; - const range = scale.range(); + const count = Math.max(from.length, to.length); + const interpolated = Array.from({ length: count }, (_, index) => { + const fromValue = from[Math.min(index, from.length - 1)] ?? 0; + const toValue = to[Math.min(index, to.length - 1)] ?? fromValue; + return fromValue + (toValue - fromValue) * progress; + }); - // Determine gradient direction based on axis - // For y-axis, we need to flip the gradient direction because y-scales are inverted - // (higher data values have smaller pixel values, appearing at the top) - const start = axis === 'x' ? vec(range[0], 0) : vec(0, range[0]); - const end = axis === 'x' ? vec(range[1], 0) : vec(0, range[1]); + return interpolated; + }, [fromPositions, toPositions, positionsProgress]); - // Extract colors and positions for LinearGradient - const colors = stops.map((s) => getColorWithOpacity(s.color, s.opacity ?? 1)); - const positions = stops.map((s) => s.offset); + if (!shouldRender) return null; - return ; -}); + return ; + }, +); diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts index 7042f6699e..40620f86c0 100644 --- a/packages/mobile-visualization/src/chart/index.ts +++ b/packages/mobile-visualization/src/chart/index.ts @@ -6,6 +6,7 @@ export * from './CartesianChart'; export * from './ChartContextBridge'; export * from './ChartProvider'; export * from './gradient'; +export * from './legend'; export * from './line'; export * from './Path'; export * from './PeriodSelector'; diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx new file mode 100644 index 0000000000..e20665a6f7 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendEntry.tsx @@ -0,0 +1,47 @@ +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 { DefaultLegendShape } from './DefaultLegendShape'; +import type { LegendEntryProps } from './Legend'; + +const styles = StyleSheet.create({ + legendEntry: { + alignItems: 'center', + }, +}); + +export type DefaultLegendEntryProps = LegendEntryProps & Omit; + +export const DefaultLegendEntry = memo( + ({ + label, + color, + shape, + ShapeComponent = DefaultLegendShape, + gap = 1, + style, + styles: stylesProp, + testID, + ...props + }) => { + return ( + + + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + + ); + }, +); diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx new file mode 100644 index 0000000000..e8913e4dbd --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx @@ -0,0 +1,64 @@ +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 type { LegendShape, LegendShapeVariant } from '../utils/chart'; + +import type { LegendShapeProps } from './Legend'; + +const styles = StyleSheet.create({ + container: { + width: 10, + height: 24, + alignItems: 'center', + justifyContent: 'center', + }, + pill: { + width: 6, + height: 24, + borderRadius: 3, + }, + circle: { + width: 10, + height: 10, + borderRadius: 5, + }, + square: { + width: 10, + height: 10, + }, + squircle: { + width: 10, + height: 10, + borderRadius: 2, + }, +}); + +const stylesByVariant: Record = { + pill: styles.pill, + circle: styles.circle, + square: styles.square, + squircle: styles.squircle, +}; + +const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant => + typeof shape === 'string' && shape in stylesByVariant; + +export type DefaultLegendShapeProps = LegendShapeProps & Omit; + +export const DefaultLegendShape = memo( + ({ color, shape = 'circle', style, testID, ...props }) => { + const theme = useTheme(); + + if (!isVariantShape(shape)) return shape; + + const variantStyle = stylesByVariant[shape]; + + return ( + + + + ); + }, +); diff --git a/packages/mobile-visualization/src/chart/legend/Legend.tsx b/packages/mobile-visualization/src/chart/legend/Legend.tsx new file mode 100644 index 0000000000..72373612fd --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/Legend.tsx @@ -0,0 +1,192 @@ +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 { useCartesianChartContext } from '../ChartProvider'; +import type { LegendShape } from '../utils'; + +import { DefaultLegendEntry } from './DefaultLegendEntry'; +import { DefaultLegendShape } from './DefaultLegendShape'; + +export type LegendShapeProps = { + /** + * Color of the legend shape. + * @default theme.color.fgPrimary + */ + color?: string; + /** + * Shape to display. Can be a preset shape or a custom ReactNode. + * @default 'circle' + */ + shape?: LegendShape; + /** + * Custom styles for the shape element. + */ + style?: StyleProp; +}; + +export type LegendShapeComponent = React.FC; + +export type LegendEntryProps = { + /** + * Id of the series. + */ + seriesId: string; + /** + * Label of the series. + * If a ReactNode is provided, it replaces the default Text component. + */ + label: React.ReactNode; + /** + * Color of the series. + * @default theme.color.fgPrimary + */ + color?: string; + /** + * Shape of the series. + */ + shape?: LegendShape; + /** + * Custom component to render the legend shape. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + /** + * Custom styles for the root element. + */ + style?: StyleProp; + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: StyleProp; + /** + * Custom styles for the shape element. + */ + shape?: StyleProp; + /** + * Custom styles for the label element. + * @note not applied when label is a ReactNode. + */ + label?: StyleProp; + }; +}; + +export type LegendEntryComponent = React.FC; + +export type LegendBaseProps = Omit & { + /** + * Array of series IDs to display in the legend. + * By default, all series will be displayed. + */ + seriesIds?: string[]; + /** + * Custom component to render each legend entry. + * @default DefaultLegendEntry + */ + EntryComponent?: LegendEntryComponent; + /** + * Custom component to render the legend shape within each entry. + * Only used when EntryComponent is not provided or is DefaultLegendEntry. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + /** + * Accessibility label for the legend group. + * @default 'Legend' + */ + accessibilityLabel?: string; +}; + +export type LegendProps = Omit & + LegendBaseProps & { + /** + * Custom styles for the component parts. + */ + styles?: { + /** + * Custom styles for the root element. + */ + root?: StyleProp; + /** + * Custom styles for each entry element. + */ + entry?: StyleProp; + /** + * Custom styles for the shape element within each entry. + */ + entryShape?: StyleProp; + /** + * Custom styles for the label element within each entry. + * @note not applied when label is a ReactNode. + */ + entryLabel?: StyleProp; + }; + }; + +export const Legend = memo( + forwardRef( + ( + { + flexDirection = 'row', + justifyContent = 'center', + alignItems = flexDirection === 'row' ? 'center' : 'flex-start', + flexWrap = 'wrap', + rowGap = 0.75, + columnGap = 2, + seriesIds, + EntryComponent = DefaultLegendEntry, + ShapeComponent = DefaultLegendShape, + accessibilityLabel = 'Legend', + style, + styles, + ...props + }, + ref, + ) => { + const { series } = useCartesianChartContext(); + + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series.filter((s) => s.label !== undefined); + return series.filter((s) => seriesIds.includes(s.id) && s.label !== undefined); + }, [series, seriesIds]); + + if (filteredSeries.length === 0) return; + + return ( + + {filteredSeries.map((s) => ( + + ))} + + ); + }, + ), +); diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx new file mode 100644 index 0000000000..5890ad80b5 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -0,0 +1,628 @@ +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 { XAxis, YAxis } from '../../axis'; +import type { BarComponentProps } from '../../bar'; +import { BarChart, BarPlot, DefaultBar } from '../../bar'; +import { CartesianChart } from '../../CartesianChart'; +import { LineChart } from '../../line'; +import { Scrubber } from '../../scrubber'; +import type { LegendShapeVariant, Series } from '../../utils/chart'; +import { getDottedAreaPath } from '../../utils/path'; +import { DefaultLegendShape } from '../DefaultLegendShape'; +import { Legend, type LegendEntryProps } from '../Legend'; + +const spectrumColors = [ + 'blue40', + 'green40', + 'orange40', + 'yellow40', + 'gray40', + 'indigo40', + 'pink40', + 'purple40', + 'red40', + 'teal40', +]; + +const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square']; + +const Shapes = () => { + const theme = useTheme(); + + return ( + + {shapes.map((shape) => ( + + {spectrumColors.map((color) => ( + + + + ))} + + ))} + + ); +}; + +const BasicLegend = () => { + const theme = useTheme(); + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const numberFormatter = useCallback( + (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + const chartAccessibilityLabel = `Website traffic across ${pages.length} pages showing page views and unique visitors.`; + + return ( + + + + ); +}; + +const Position = () => { + const theme = useTheme(); + + return ( + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: `rgb(${theme.spectrum.yellow40})`, + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: theme.color.fgPositive, + legendShape: 'squircle', + }, + ]} + width="100%" + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + ); +}; + +const ShapeVariants = () => { + const theme = useTheme(); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + ); +}; + +const DynamicData = () => { + const theme = useTheme(); + const [scrubberPosition, setScrubberPosition] = useState(); + + const timeLabels = useMemo( + () => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + [], + ); + + const seriesConfig: Series[] = useMemo( + () => [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: `rgb(${theme.spectrum.blue40})`, + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: `rgb(${theme.spectrum.orange40})`, + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: `rgb(${theme.spectrum.gray40})`, + legendShape: 'circle', + }, + ], + [theme.spectrum.blue40, theme.spectrum.gray40, theme.spectrum.orange40], + ); + + const dataLength = seriesConfig[0].data?.length ?? 0; + const dataIndex = scrubberPosition ?? dataLength - 1; + + const chartAccessibilityLabel = `Candidate polling data over ${timeLabels.length} months showing support percentages for 3 candidates.`; + + const ValueLegendEntry = useCallback( + ({ seriesId, label, color, shape }: LegendEntryProps) => { + const seriesData = seriesConfig.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue as number)}%`; + + return ( + + + {label} + {formattedValue} + + ); + }, + [seriesConfig, dataIndex], + ); + + return ( + } + legendPosition="top" + onScrubberPositionChange={setScrubberPosition} + series={seriesConfig} + width="100%" + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + ); +}; + +const Interactive = () => { + const theme = useTheme(); + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: 'blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: 'orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: 'green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId: string) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendEntry = memo(function ChipLegendEntry({ seriesId, label }: LegendEntryProps) { + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? 'gray'; + + const color10 = theme.spectrum[`${baseColor}10` as keyof typeof theme.spectrum]; + const color50 = theme.spectrum[`${baseColor}50` as keyof typeof theme.spectrum]; + const color90 = theme.spectrum[`${baseColor}90` as keyof typeof theme.spectrum]; + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(${isEmphasized ? color90 : color10})`, + borderWidth: 0, + borderRadius: theme.borderRadius[1000], + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(${theme.spectrum[`${config.baseColor}40` as keyof typeof theme.spectrum]})`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig, theme]); + + return ( + } + legendPosition="top" + series={series} + width="100%" + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + ); +}; + +const Accessible = () => { + const theme = useTheme(); + const months = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); + + return ( + + ); +}; + +const LegendShapes = () => { + const theme = useTheme(); + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value: number) => + `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = useMemo(() => { + // Create a small dotted pattern for the legend indicator + const indicatorSize = 10; + const legendPatternSize = patternSize / 2; + const legendDotSize = dotSize / 2; + const dottedPath = getDottedAreaPath( + { x: 1, y: 1, width: indicatorSize - 2, height: indicatorSize - 2 }, + legendPatternSize, + legendDotSize, + ); + const skiaPath = Skia.Path.MakeFromSVGString(dottedPath); + // Create squircle path for clipping + const squirclePath = Skia.Path.Make(); + squirclePath.addRRect(Skia.RRectXY(Skia.XYWHRect(1, 1, 8, 8), 2, 2)); + + return ( + + + {skiaPath && } + + + + ); + }, [theme.color.fgPositive]); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = useMemo(() => { + return memo(function DottedBar(props) { + const { x, y, width, height, fill, d } = props; + + // Generate dotted path for this bar's bounds + const dottedPath = useMemo(() => { + return getDottedAreaPath({ x, y, width, height }, patternSize, dotSize); + }, [x, y, width, height]); + + // Create Skia paths + const barClipPath = useMemo(() => { + return d ? (Skia.Path.MakeFromSVGString(d) ?? undefined) : undefined; + }, [d]); + + const dotsSkiaPath = useMemo(() => { + return Skia.Path.MakeFromSVGString(dottedPath) ?? undefined; + }, [dottedPath]); + + return ( + <> + {/* Dotted fill clipped to bar shape */} + + {dotsSkiaPath && } + + {/* Stroke outline */} + + + ); + }); + }, []); + + return ( + + ); +}; + +const LegendStories = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default LegendStories; diff --git a/packages/mobile-visualization/src/chart/legend/index.ts b/packages/mobile-visualization/src/chart/legend/index.ts new file mode 100644 index 0000000000..cad992cc86 --- /dev/null +++ b/packages/mobile-visualization/src/chart/legend/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultLegendEntry'; +export * from './DefaultLegendShape'; +export * from './Legend'; diff --git a/packages/mobile-visualization/src/chart/line/DottedLine.tsx b/packages/mobile-visualization/src/chart/line/DottedLine.tsx index 3045e9472c..7a1d638757 100644 --- a/packages/mobile-visualization/src/chart/line/DottedLine.tsx +++ b/packages/mobile-visualization/src/chart/line/DottedLine.tsx @@ -34,9 +34,11 @@ export const DottedLine = memo( strokeOpacity = 1, strokeWidth = 2, gradient, + xAxisId, yAxisId, d, animate, + transitions, transition, ...props }) => { @@ -54,10 +56,19 @@ export const DottedLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} {...props} > - {gradient && } + {gradient && ( + + )} ); }, diff --git a/packages/mobile-visualization/src/chart/line/Line.tsx b/packages/mobile-visualization/src/chart/line/Line.tsx index 4b2304eb0e..25022a8905 100644 --- a/packages/mobile-visualization/src/chart/line/Line.tsx +++ b/packages/mobile-visualization/src/chart/line/Line.tsx @@ -1,20 +1,16 @@ -import React, { memo, useEffect, useMemo } from 'react'; -import { useSharedValue, withDelay, withTiming } from 'react-native-reanimated'; +import React, { memo, useMemo } from 'react'; import { useTheme } from '@coinbase/cds-mobile'; import { type AnimatedProp, Group } from '@shopify/react-native-skia'; import { Area, type AreaComponent } from '../area/Area'; import { useCartesianChartContext } from '../ChartProvider'; -import { type PathProps } from '../Path'; +import type { PathProps } from '../Path'; import { Point, type PointBaseProps, type PointProps } from '../point'; import { - accessoryFadeTransitionDelay, - accessoryFadeTransitionDuration, type ChartPathCurveType, getLineData, getLinePath, type GradientDefinition, - type Transition, } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; @@ -49,6 +45,9 @@ export type LineBaseProps = { /** * Baseline value for the area. * When set, overrides the default baseline. + * + * @deprecated this prop has no functionality. Use 'baseline' on axis config instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v5 */ areaBaseline?: number; /** @@ -110,25 +109,27 @@ export type LineBaseProps = { animate?: boolean; }; -export type LineProps = LineBaseProps & { - /** - * Transition configuration for line animations. - */ - transition?: Transition; -}; +export type LineProps = LineBaseProps & Pick; export type LineComponentProps = Pick< LineProps, - 'stroke' | 'strokeOpacity' | 'strokeWidth' | 'gradient' | 'animate' | 'transition' + 'stroke' | 'strokeOpacity' | 'strokeWidth' | 'gradient' | 'animate' | 'transitions' | 'transition' > & Pick & { /** * Path of the line */ d: AnimatedProp; + /** + * ID of the x-axis to use. + * If not provided, defaults to the default x-axis. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * ID of the y-axis to use. * If not provided, defaults to the default y-axis. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; }; @@ -141,7 +142,6 @@ export const Line = memo( curve = 'bump', type = 'solid', areaType = 'gradient', - areaBaseline, stroke: strokeProp, strokeOpacity, showArea, @@ -150,27 +150,15 @@ export const Line = memo( opacity = 1, points, connectNulls, + transitions, transition, gradient: gradientProp, ...props }) => { const theme = useTheme(); - const { animate, getSeries, getSeriesData, getXScale, getYScale, getXAxis } = + const { layout, animate, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = useCartesianChartContext(); - // Animation state for delayed point rendering (matches web timing) - const pointsOpacity = useSharedValue(animate ? 0 : 1); - - // Delay point appearance until after path enter animation completes - useEffect(() => { - if (animate) { - pointsOpacity.value = withDelay( - accessoryFadeTransitionDelay, - withTiming(1, { duration: accessoryFadeTransitionDuration }), - ); - } - }, [animate, pointsOpacity]); - const matchedSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); const gradient = useMemo( () => gradientProp ?? matchedSeries?.gradient, @@ -178,23 +166,43 @@ export const Line = memo( ); const sourceData = useMemo(() => getSeriesData(seriesId), [getSeriesData, seriesId]); - const xAxis = useMemo(() => getXAxis(), [getXAxis]); - const xScale = useMemo(() => getXScale(), [getXScale]); + const xAxis = useMemo( + () => getXAxis(matchedSeries?.xAxisId), + [getXAxis, matchedSeries?.xAxisId], + ); + const xScale = useMemo( + () => getXScale(matchedSeries?.xAxisId), + [getXScale, matchedSeries?.xAxisId], + ); const yScale = useMemo( () => getYScale(matchedSeries?.yAxisId), [getYScale, matchedSeries?.yAxisId], ); + const yAxis = useMemo( + () => getYAxis(matchedSeries?.yAxisId), + [getYAxis, matchedSeries?.yAxisId], + ); // Convert sourceData to number array (line only supports numbers, not tuples) const chartData = useMemo(() => getLineData(sourceData), [sourceData]); + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxis = useMemo(() => { + return categoryAxisIsX ? xAxis : yAxis; + }, [categoryAxisIsX, xAxis, yAxis]); + const path = useMemo(() => { if (!xScale || !yScale || chartData.length === 0) return ''; - // Get numeric x-axis data if available - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) + // Get numeric category-axis data if available. + const indexData = + categoryAxis?.data && + Array.isArray(categoryAxis.data) && + typeof categoryAxis.data[0] === 'number' + ? (categoryAxis.data as number[]) : undefined; return getLinePath({ @@ -202,10 +210,12 @@ export const Line = memo( xScale, yScale, curve, - xData, + xData: categoryAxisIsX ? indexData : undefined, + yData: !categoryAxisIsX ? indexData : undefined, connectNulls, + layout, }); - }, [chartData, xScale, yScale, curve, xAxis?.data, connectNulls]); + }, [xScale, yScale, chartData, categoryAxis, curve, categoryAxisIsX, connectNulls, layout]); const LineComponent = useMemo((): LineComponent => { if (SelectedLineComponent) { @@ -223,12 +233,12 @@ export const Line = memo( // Get series color for stroke const stroke = strokeProp ?? matchedSeries?.color ?? theme.color.fgPrimary; - const xData = useMemo(() => { - const data = xAxis?.data; + const categoryData = useMemo(() => { + const data = categoryAxis?.data; return data && Array.isArray(data) && data.length > 0 && typeof data[0] === 'number' ? (data as number[]) : null; - }, [xAxis?.data]); + }, [categoryAxis]); const gradientConfig = useMemo(() => { if (!gradient || !xScale || !yScale) return; @@ -253,7 +263,6 @@ export const Line = memo( {showArea && ( ( gradient={gradient} seriesId={seriesId} transition={transition} + transitions={transitions} type={areaType} /> )} @@ -271,22 +281,32 @@ export const Line = memo( stroke={stroke} strokeOpacity={strokeOpacity ?? opacity} transition={transition} + transitions={transitions} + xAxisId={matchedSeries?.xAxisId} yAxisId={matchedSeries?.yAxisId} {...props} /> {points && ( - + {chartData.map((value: number | null, index: number) => { if (value === null) return; - const xValue = xData && xData[index] !== undefined ? xData[index] : index; + const indexValue = + categoryData && categoryData[index] !== undefined ? categoryData[index] : index; let pointFill = stroke; if (gradientConfig && gradient) { - // Use the appropriate data value based on gradient axis - const axis = gradient.axis ?? 'y'; - const dataValue = axis === 'x' ? xValue : value; + // Match gradient sampling to the chart axis roles for each layout. + const gradientAxis = gradient.axis ?? 'y'; + const dataValue = + gradientAxis === 'x' + ? categoryAxisIsX + ? indexValue + : value + : categoryAxisIsX + ? value + : indexValue; const evaluatedColor = evaluateGradientAtValue( gradientConfig.stops, @@ -301,9 +321,10 @@ export const Line = memo( // Build defaults that would be passed to Point const defaults: PointBaseProps = { - dataX: xValue, - dataY: value, + dataX: categoryAxisIsX ? indexValue : value, + dataY: categoryAxisIsX ? value : indexValue, fill: pointFill, + xAxisId: matchedSeries?.xAxisId, yAxisId: matchedSeries?.yAxisId, opacity, }; @@ -311,7 +332,12 @@ export const Line = memo( // If points is true, render with defaults if (points === true) { return ( - + ); } @@ -324,8 +350,9 @@ export const Line = memo( return ( diff --git a/packages/mobile-visualization/src/chart/line/LineChart.tsx b/packages/mobile-visualization/src/chart/line/LineChart.tsx index cf9855892f..52b4e2d0f0 100644 --- a/packages/mobile-visualization/src/chart/line/LineChart.tsx +++ b/packages/mobile-visualization/src/chart/line/LineChart.tsx @@ -8,10 +8,18 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type Series } from '../utils'; import { Line, type LineProps } from './Line'; +const getDefaultScrubberAccessibilityStep = ( + dataLength: number, + sampleCount: number = 10, +): number => { + if (dataLength <= 0) return 1; + return Math.max(1, Math.ceil(dataLength / sampleCount)); +}; + export type LineSeries = Series & Partial< Pick< @@ -30,6 +38,7 @@ export type LineSeries = Series & | 'points' | 'connectNulls' | 'transition' + | 'transitions' > >; @@ -47,6 +56,7 @@ export type LineChartBaseProps = Omit & { /** @@ -67,17 +77,23 @@ export type LineChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type LineChartProps = LineChartBaseProps & - Omit; + Omit & { + /** + * Number of data points to move between screen-reader samples. + * @default Computed from data length (targeting 10 samples) + */ + scrubberAccessibilityLabelStep?: number; + }; export const LineChart = memo( forwardRef( @@ -95,19 +111,20 @@ export const LineChart = memo( strokeOpacity, connectNulls, transition, + transitions, opacity, showXAxis, showYAxis, xAxis, yAxis, inset, + scrubberAccessibilityLabelStep, + layout = 'vertical', children, ...chartProps }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( @@ -116,9 +133,11 @@ export const LineChart = memo( data: s.data, label: s.label, color: s.color, + xAxisId: s.xAxisId, yAxisId: s.yAxisId, stackId: s.stackId, gradient: s.gradient, + legendShape: s.legendShape, }), ); }, [series]); @@ -131,6 +150,8 @@ export const LineChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; const { @@ -140,41 +161,60 @@ export const LineChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, }; - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, }; + const categoryAxisData = layout === 'horizontal' ? yData : xData; + const lineChartDataLength = useMemo(() => { + if (categoryAxisData && categoryAxisData.length > 0) return categoryAxisData.length; + if (!series || series.length === 0) return 0; + return series.reduce((max, s) => Math.max(max, s.data?.length ?? 0), 0); + }, [categoryAxisData, series]); + + const resolvedScrubberAccessibilityLabelStep = useMemo( + () => + scrubberAccessibilityLabelStep ?? + getDefaultScrubberAccessibilityStep(lineChartDataLength), + [scrubberAccessibilityLabelStep, lineChartDataLength], + ); + return ( {/* Render axes first for grid lines to appear behind everything else */} - {showXAxis && } + {showXAxis && } {showYAxis && } - {series?.map(({ id, data, label, color, yAxisId, ...linePropsFromSeries }) => ( + {series?.map(({ id, data, label, color, xAxisId, yAxisId, ...linePropsFromSeries }) => ( diff --git a/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx b/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx index 1078dbf776..5d1bff7622 100644 --- a/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx +++ b/packages/mobile-visualization/src/chart/line/ReferenceLine.tsx @@ -125,6 +125,7 @@ type HorizontalReferenceLineProps = ReferenceLineBaseProps & { /** * The ID of the y-axis to use for positioning. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** diff --git a/packages/mobile-visualization/src/chart/line/SolidLine.tsx b/packages/mobile-visualization/src/chart/line/SolidLine.tsx index ddd8938333..c471a18c55 100644 --- a/packages/mobile-visualization/src/chart/line/SolidLine.tsx +++ b/packages/mobile-visualization/src/chart/line/SolidLine.tsx @@ -27,9 +27,11 @@ export const SolidLine = memo( strokeOpacity = 1, strokeWidth = 2, gradient, + xAxisId, yAxisId, d, animate, + transitions, transition, ...props }) => { @@ -47,9 +49,18 @@ export const SolidLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} {...props} > - {gradient && } + {gradient && ( + + )} ); }, diff --git a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 21f6f2176e..a2a66f4a0a 100644 --- a/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { View } from 'react-native'; import { useAnimatedReaction, @@ -8,18 +8,20 @@ import { withTiming, } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; -import { assets } from '@coinbase/cds-common/internal/data/assets'; -import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; +import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { prices } from '@coinbase/cds-common/internal/data/prices'; 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 { 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 { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +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'; @@ -29,8 +31,6 @@ import { Circle, FontWeight, Group, - Line as SkiaLine, - Rect, Skia, type SkTextStyle, TextAlign, @@ -38,27 +38,20 @@ import { import { Area, DottedArea, type DottedAreaProps } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; -import { BarChart, type BarComponentProps, BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector'; import { Point } from '../../point'; import { DefaultScrubberBeacon, - DefaultScrubberBeaconLabel, - DefaultScrubberLabel, Scrubber, - type ScrubberBeaconLabelProps, type ScrubberBeaconProps, - type ScrubberLabelProps, type ScrubberRef, } from '../../scrubber'; import { type AxisBounds, buildTransition, defaultTransition, - getLineData, - getPointOnSerializableScale, projectPointWithSerializableScale, type Transition, unwrapAnimatedValue, @@ -69,7 +62,6 @@ import { type DottedLineProps, Line, LineChart, - type LineComponentProps, ReferenceLine, SolidLine, type SolidLineProps, @@ -77,7 +69,6 @@ import { function MultipleLine() { const theme = useTheme(); - const [scrubberPosition, setScrubberPosition] = useState(); const pages = useMemo( () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], [], @@ -86,11 +77,11 @@ function MultipleLine() { const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; + const chartAccessibilityHint = 'Swipe left or right to hear details for each page.'; - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`; - }, + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`, [pages, pageViews, uniqueVisitors], ); @@ -99,22 +90,16 @@ function MultipleLine() { [], ); - const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); - } - return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); - return ( (); const yData = useMemo(() => [2, 5.5, 2, 8.5, 1.5, 5], []); const xData = useMemo(() => [1, 2, 3, 5, 8, 10], []); const chartAccessibilityLabel = `Chart with custom X and Y data. ${yData.length} data points`; - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`; - }, + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`, [xData, yData], ); - const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); - } - return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); - return ( `Point ${index + 1}: ${priceData[index]}`, + [priceData], + ); + const lastDataPointTimeRef = useRef(Date.now()); const updateCountRef = useRef(0); @@ -257,6 +238,8 @@ function LiveUpdates() { ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, null, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, null, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pages.length} pages. Some data points are missing.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const pv = pageViews[index]; + const uv = uniqueVisitors[index]; + const pvStr = pv != null ? pv : 'no data'; + const uvStr = uv != null ? uv : 'no data'; + return `${pages[index]}: ${pvStr} views, ${uvStr} unique visitors.`; + }, + [pages, pageViews, uniqueVisitors], + ); const numberFormatter = useCallback( (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), @@ -290,6 +288,8 @@ function MissingData() { showArea showXAxis showYAxis + accessibilityLabel={chartAccessibilityLabel} + getScrubberAccessibilityLabel={getScrubberAccessibilityLabel} height={200} series={[ { @@ -324,6 +324,13 @@ function MissingData() { function Interaction() { const [scrubberPosition, setScrubberPosition] = useState(); + const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); + + const chartAccessibilityLabel = `Price chart with ${data.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${data[index]}`, + [data], + ); return ( @@ -335,14 +342,11 @@ function Interaction() { @@ -489,9 +493,17 @@ function Transitions() { ], }; + const chartAccessibilityLabel = `Price chart with ${data.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${valueAtIndexFormatter(index)}`, + [valueAtIndexFormatter], + ); + return ( (); const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); // Chart-level accessibility label provides overview @@ -533,29 +544,19 @@ function BasicAccessible() { return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; }, [data]); - // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; - }, + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Price at position ${index + 1} of ${data.length}: ${data[index]}`, [data], ); - const accessibilityLabel = useMemo(() => { - if (scrubberPosition !== undefined) { - return scrubberAccessibilityLabel(scrubberPosition); - } - return chartAccessibilityLabel; - }, [scrubberPosition, chartAccessibilityLabel, scrubberAccessibilityLabel]); - return ( )); + const chartAccessibilityLabel = `Price chart with ${data.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${tickLabelFormatter(data[index])}`, + [data, tickLabelFormatter], + ); + return ( ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `${pages[index]}: ${pageViews[index]} views, ${uniqueVisitors[index]} unique visitors.`, + [pages, pageViews, uniqueVisitors], + ); const numberFormatter = useCallback( (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), @@ -800,6 +819,8 @@ function StylingScrubber() { showArea showXAxis showYAxis + accessibilityLabel={chartAccessibilityLabel} + getScrubberAccessibilityLabel={getScrubberAccessibilityLabel} height={200} series={[ { @@ -888,6 +909,7 @@ function Compact() { }: CompactChartProps & { subdetail: string }) => { return ( @@ -1041,6 +1063,16 @@ function AssetPriceWithDottedArea() { return `${dayOfWeek}, ${monthDay}, ${time}`; }, []); + const chartAccessibilityLabel = `Bitcoin price chart for ${timePeriod.label} period. Current price: ${formatPrice(currentPrice)}.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = formatPrice(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `${price} ${date}`; + }, + [formatDate, formatPrice, sparklineTimePeriodDataTimestamps, sparklineTimePeriodDataValues], + ); + return ( { return ; }); -const LegendItem = memo( +const LegendEntry = memo( ({ color = assets.btc.color, label, @@ -1176,17 +1210,17 @@ const PerformanceHeader = memo( return ( - - - { + const price = formatPriceThousands(sparklineTimePeriodDataValues[index]); + const date = formatDate(sparklineTimePeriodDataTimestamps[index]); + return `Point ${index + 1}: ${price}, ${date}`; + }, + [ + formatDate, + formatPriceThousands, + sparklineTimePeriodDataTimestamps, + sparklineTimePeriodDataValues, + ], + ); + return ( { - const formatPrice = useCallback((price: string) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(parseFloat(price)); - }, []); - - const formatThousandsPriceNumber = useCallback((price: number) => { - const formattedPrice = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price / 1000); - - return `${formattedPrice}k`; - }, []); - - const currentText = useMemo(() => { - if (currentIndex !== undefined) { - return `Open: ${formatThousandsPriceNumber(parseFloat(candlestickStockData[currentIndex].open))}, Close: ${formatThousandsPriceNumber( - parseFloat(candlestickStockData[currentIndex].close), - )}, Volume: ${(parseFloat(candlestickStockData[currentIndex].volume) / 1000).toFixed(2)}k`; - } - return formatPrice(candlestickStockData[candlestickStockData.length - 1].close); - }, [currentIndex, formatThousandsPriceNumber, formatPrice]); - - return ( - - {currentText} - - ); -}); - -const CandlesticksChart = memo( - ({ - infoTextId, - onScrubberPositionChange, - }: { - infoTextId: string; - onScrubberPositionChange: (index: number | undefined) => void; - }) => { - const theme = useTheme(); - const min = useMemo( - () => Math.min(...candlestickStockData.map((data) => parseFloat(data.low))), - [], - ); - - const ThinSolidLine = memo((props: SolidLineProps) => ); - - // Custom line component that renders a rect to highlight the entire bandwidth - const BandwidthHighlight = memo(({ stroke }: LineComponentProps) => { - const { getXSerializableScale, drawingArea } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); - - const rectWidth = useMemo(() => { - if (xScale !== undefined && xScale.type === 'band') { - return xScale.bandwidth; - } - return 0; - }, [xScale]); - - const xPos = useDerivedValue(() => { - const position = unwrapAnimatedValue(scrubberPosition); - const xPos = - position !== undefined && xScale - ? getPointOnSerializableScale(position, xScale) - : undefined; - return xPos !== undefined ? xPos - rectWidth / 2 : 0; - }, [scrubberPosition, xScale]); - - const opacity = useDerivedValue(() => (xPos.value !== undefined ? 1 : 0), [xPos]); - - return ( - - ); - }); - - const candlesData = useMemo( - () => - candlestickStockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]) as [ - number, - number, - ][], - [], - ); - - const CandlestickBarComponent = memo( - ({ x, y, width, height, originY, dataX, ...props }) => { - const { getYScale } = useCartesianChartContext(); - const yScale = getYScale(); - - const wickX = x + width / 2; - - const timePeriodValue = candlestickStockData[dataX as number]; - - const open = parseFloat(timePeriodValue.open); - const close = parseFloat(timePeriodValue.close); - - const bullish = open < close; - const theme = useTheme(); - const color = bullish ? theme.color.fgPositive : theme.color.fgNegative; - const openY = yScale?.(open) ?? 0; - const closeY = yScale?.(close) ?? 0; - - const bodyHeight = Math.abs(openY - closeY); - const bodyY = openY < closeY ? openY : closeY; - - return ( - <> - - - - ); - }, - ); - - const formatThousandsPriceNumber = useCallback((price: number) => { - const formattedPrice = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }).format(price / 1000); - - return `${formattedPrice}k`; - }, []); - - const formatTime = useCallback((index: number | null) => { - if (index === null || index === undefined || index >= candlestickStockData.length) return ''; - const ts = parseInt(candlestickStockData[index].start); - return new Date(ts * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }, []); - - return ( - - - - - {children}} - /> - - ); - }, -); - -function Candlesticks() { - const infoTextId = useId(); - const [currentIndex, setCurrentIndex] = useState(); - - return ( - - - - - ); -} - function MonotoneAssetPrice() { const theme = useTheme(); const prices = sparklineInteractiveData.hour; @@ -1644,6 +1486,16 @@ function MonotoneAssetPrice() { [], ); + const chartAccessibilityLabel = `Price chart with ${prices.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => { + const price = scrubberPriceFormatter.format(prices[index].value); + const date = formatDate(prices[index].date); + return `${price} USD ${date}`; + }, + [formatDate, prices, scrubberPriceFormatter], + ); + const CustomScrubberBeacon = memo( ({ dataX, dataY, seriesId, isIdle, animate = true }: ScrubberBeaconProps) => { const { getSeries, getXSerializableScale, getYSerializableScale } = @@ -1707,6 +1559,8 @@ function MonotoneAssetPrice() { + `Point ${index + 1}: ${availabilityEvents[index].availability}% availability on ${availabilityEvents[index].date.toLocaleDateString()}`, + [availabilityEvents], + ); + return ( [50, 45, 47, 46, 54, 54, 60, 61, 63, 66, 70], []); const currentIndex = 6; const strokeWidth = 3; @@ -1928,9 +1791,17 @@ function ForecastAssetPrice() { ); }); + const chartAccessibilityLabel = `Forecast chart with ${data.length} data points. Swipe to navigate.`; + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Point ${index + 1}: ${axisFormatter(index)}, value ${data[index]}`, + [axisFormatter, data], + ); + return ( @@ -1942,343 +1813,151 @@ function ForecastAssetPrice() { ); } -function ImperativeHandle() { - const theme = useTheme(); - const scrubberRef = useRef(null); - - return ( - - ({ min, max: max - 8 }), - }} - yAxis={{ - domain: { - min: 0, - }, - showGrid: true, - tickLabelFormatter: (value) => value.toLocaleString(), - }} - > - - - - - ); -} - -function CustomBeaconLabel() { - const theme = useTheme(); - // This custom component label shows the percentage value of the data at the scrubber position. - const MyScrubberBeaconLabel = memo( - ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { - const { getSeriesData, series } = useCartesianChartContext(); - const { scrubberPosition } = useScrubberContext(); - - const seriesData = useMemo( - () => getLineData(getSeriesData(seriesId)), - [getSeriesData, seriesId], - ); - - const dataLength = useMemo( - () => - series?.reduce((max, s) => { - const seriesData = getSeriesData(s.id); - return Math.max(max, seriesData?.length ?? 0); - }, 0) ?? 0, - [series, getSeriesData], - ); - - const dataIndex = useDerivedValue(() => { - return scrubberPosition.value ?? Math.max(0, dataLength - 1); - }, [scrubberPosition, dataLength]); - - const percentageLabel = useDerivedValue(() => { - if (seriesData !== undefined) { - const dataAtPosition = seriesData[dataIndex.value]; - return `${unwrapAnimatedValue(label)} · ${dataAtPosition}%`; - } - return unwrapAnimatedValue(label); - }, [label, seriesData, dataIndex]); - - return ( - - ); - }, +function DataCardWithLineChart() { + const { spectrum } = useTheme(); + const exampleThumbnail = ( + ); - return ( - - - + const getLineChartSeries = useCallback( + () => [ + { + id: 'price', + data: prices.slice(0, 30).map((price: string) => parseFloat(price)), + color: `rgb(${spectrum.green70})`, + }, + ], + [spectrum.green70], ); -} -function CustomLabelComponent() { - const CustomLabelComponent = memo((props: ScrubberLabelProps) => { - const theme = useTheme(); - const { drawingArea } = useCartesianChartContext(); - - if (!drawingArea) return; + const lineChartSeries = useMemo(() => getLineChartSeries(), [getLineChartSeries]); + const lineChartSeries2 = useMemo(() => getLineChartSeries(), [getLineChartSeries]); + const ref = useRef(null); - return ( - - ); - }); return ( - - `Day ${dataIndex + 1}`} - /> - + + + + + + ↗ 25.25% + + } + > + + + + ↗ 8.5% + + } + > + + + + } + title="Card with Line Chart" + titleAccessory={ + + ↗ 25.25% + + } + > + + + ); } -function HiddenScrubberWhenIdle() { - const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => { - const { scrubberPosition } = useScrubberContext(); - const beaconOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); - - return ; - }); - - const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { - const { scrubberPosition } = useScrubberContext(); - const labelOpacity = useDerivedValue( - () => (scrubberPosition.value !== undefined ? 1 : 0), - [scrubberPosition], - ); - - return ; - }); +function HorizontalLayoutLineChart() { + const symbols = ['BTC', 'ETH', 'SOL', 'DOGE', 'ADA']; + const allocations = [72, 46, 33, 21, 14]; return ( - - - ); -} - -function TwoLineScrubberLabel() { - const theme = useTheme(); - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - const [alignment, setAlignment] = useState(TextAlign.Center); - - const fontMgr = useMemo(() => { - const fontProvider = Skia.TypefaceFontProvider.Make(); - return fontProvider; - }, []); - - const formatPrice = useCallback((price: number) => { - return new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(price); - }, []); - - const scrubberLabel = useCallback( - (index: number) => { - const price = formatPrice(data[index] * 100); - const day = `Day ${index + 100}`; - - const priceStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 16, - fontStyle: { - weight: FontWeight.Bold, - }, - color: Skia.Color(theme.color.fg), - }; - - const dayStyle: SkTextStyle = { - fontFamilies: ['Inter'], - fontSize: 14, - fontStyle: { - weight: FontWeight.Normal, - }, - color: Skia.Color(theme.color.fgMuted), - }; - - const builder = Skia.ParagraphBuilder.Make( - { - textAlign: alignment, - }, - fontMgr, - ); - - builder.pushStyle(priceStyle); - builder.addText(price); - builder.addText('\n'); - - builder.pushStyle(dayStyle); - builder.addText(day); - - const para = builder.build(); - // First layout with large width to get intrinsic size - para.layout(384); - return para; - }, - [data, formatPrice, theme.color.fg, theme.color.fgMuted, fontMgr, alignment], - ); - - // Custom scrubber label component that uses the selected alignment - const AlignedScrubberLabel = memo((props: ScrubberLabelProps) => ( - - )); - - return ( - - - - - - - - - - + xAxis={{ domain: { min: 0, max: 80 }, tickLabelFormatter: (value) => `${value}%` }} + yAxis={{ data: symbols, scaleType: 'band' }} + /> ); } @@ -2309,8 +1988,8 @@ function ExampleNavigator() { ), }, { - title: 'Imperative Handle', - component: , + title: 'Horizontal Layout', + component: , }, { title: 'Multiple Lines', @@ -2466,6 +2145,8 @@ function ExampleNavigator() { `Point ${index + 1}`} height={200} series={[ { @@ -2510,10 +2191,6 @@ function ExampleNavigator() { title: 'Performance', component: , }, - { - title: 'Candlesticks', - component: , - }, { title: 'Monotone Asset Price', component: , @@ -2527,50 +2204,35 @@ function ExampleNavigator() { component: , }, { - title: 'Custom Beacon Label', - component: , - }, - { - title: 'Custom Label Component', - component: , - }, - { - title: 'Hidden Scrubber When Idle', - component: , - }, - { - title: 'Two-Line Scrubber Label', - component: , + title: 'In DataCard', + component: , }, ], [theme.color.fg, theme.color.fgPositive, theme.spectrum.gray50], ); const currentExample = examples[currentIndex]; - const isFirstExample = currentIndex === 0; - const isLastExample = currentIndex === examples.length - 1; const handlePrevious = useCallback(() => { - setCurrentIndex((prev) => Math.max(0, prev - 1)); - }, []); + setCurrentIndex((prev) => (prev - 1 + examples.length) % examples.length); + }, [examples.length]); const handleNext = useCallback(() => { - setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1)); + setCurrentIndex((prev) => (prev + 1 + examples.length) % examples.length); }, [examples.length]); return ( - + - + {currentExample.title} {currentIndex + 1} / {examples.length} @@ -2579,7 +2241,6 @@ function ExampleNavigator() { { /> + + + ); }; +const FADE_ZONE = 128; + +const StartPriceLabel = memo((props: DefaultReferenceLineLabelProps) => { + const theme = useTheme(); + const { scrubberPosition } = useScrubberContext(); + const { getXSerializableScale, drawingArea } = useCartesianChartContext(); + const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + + const opacity = useDerivedValue(() => { + if (scrubberPosition.value === undefined) return withTiming(0, { duration: 250 }); + if (!xScale) return withTiming(1, { duration: 250 }); + const scrubX = getPointOnSerializableScale(scrubberPosition.value, xScale); + const rightEdge = drawingArea.x + drawingArea.width; + const target = rightEdge - scrubX >= FADE_ZONE ? 1 : 0; + return withTiming(target, { duration: 250 }); + }, [scrubberPosition, xScale, drawingArea]); + + return ( + + ); +}); + +function StartPriceReferenceLine() { + const theme = useTheme(); + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? theme.color.fgPositive : theme.color.fgNegative; + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + } + dataY={startPrice} + label={startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + labelDx={-12} + labelHorizontalAlignment="right" + stroke={theme.color.fgMuted} + /> + + ); +} + export default ReferenceLineStories; diff --git a/packages/mobile-visualization/src/chart/point/Point.tsx b/packages/mobile-visualization/src/chart/point/Point.tsx index 39a302cc63..55ea47e4e8 100644 --- a/packages/mobile-visualization/src/chart/point/Point.tsx +++ b/packages/mobile-visualization/src/chart/point/Point.tsx @@ -7,7 +7,13 @@ import { Circle, type Color, Group, interpolateColors } from '@shopify/react-nat import { useCartesianChartContext } from '../ChartProvider'; import type { ChartTextChildren, ChartTextProps } from '../text/ChartText'; import { type PointLabelPosition, projectPoint } from '../utils'; -import { buildTransition, defaultTransition, type Transition } from '../utils/transition'; +import { + buildTransition, + defaultAccessoryEnterTransition, + defaultTransition, + getTransition, + type Transition, +} from '../utils/transition'; import { DefaultPointLabel } from './DefaultPointLabel'; @@ -28,8 +34,15 @@ export type PointBaseProps = { /** * Optional Y-axis id to specify which axis to plot along. * @default first y-axis defined in chart props. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; + /** + * Optional X-axis id to specify which axis to plot along. + * @default first x-axis defined in chart props. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Radius of the point. * @default 5 @@ -123,8 +136,25 @@ export type PointProps = PointBaseProps & { */ label?: ChartTextChildren; /** - * Transition configuration for point animations. - * Defines how the point transitions when position or color changes. + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ transition?: Transition; }; @@ -133,6 +163,7 @@ export const Point = memo( ({ dataX, dataY, + xAxisId, yAxisId, fill: fillProp, radius = 5, @@ -144,7 +175,8 @@ export const Point = memo( labelPosition = 'center', labelOffset, labelFont, - transition = defaultTransition, + transitions, + transition, animate: animateProp, }) => { const theme = useTheme(); @@ -159,11 +191,26 @@ export const Point = memo( } = useCartesianChartContext(); const animate = animateProp ?? animationEnabled; - const xScale = getXScale(); + const xScale = getXScale(xAxisId); const yScale = getYScale(yAxisId); const shouldAnimate = animate ?? false; + const updateTransition = useMemo( + () => + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + [animate, transitions?.update, transition], + ); + + const enterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [animate, transitions?.enter], + ); + // Calculate pixel coordinates from data coordinates const pixelCoordinate = useMemo(() => { if (!xScale || !yScale) { @@ -185,9 +232,17 @@ export const Point = memo( const animatedX = useSharedValue(0); const animatedY = useSharedValue(0); - // Animated value for color interpolation (0 = old color, 1 = new color) + const enterOpacity = useSharedValue(shouldAnimate ? 0 : 1); + const colorProgress = useSharedValue(1); + const isReady = !!xScale && !!yScale; + + useEffect(() => { + if (!shouldAnimate || !isReady) return; + enterOpacity.value = buildTransition(1, enterTransition); + }, [shouldAnimate, isReady, enterTransition, enterOpacity]); + // Update position when coordinates change useEffect(() => { if (!pixelCoordinate) { @@ -195,26 +250,33 @@ export const Point = memo( } if (shouldAnimate && previousPixelCoordinate) { - animatedX.value = buildTransition(pixelCoordinate.x, transition); - animatedY.value = buildTransition(pixelCoordinate.y, transition); + animatedX.value = buildTransition(pixelCoordinate.x, updateTransition); + animatedY.value = buildTransition(pixelCoordinate.y, updateTransition); } else { cancelAnimation(animatedX); cancelAnimation(animatedY); animatedX.value = pixelCoordinate.x; animatedY.value = pixelCoordinate.y; } - }, [pixelCoordinate, shouldAnimate, previousPixelCoordinate, animatedX, animatedY, transition]); + }, [ + pixelCoordinate, + shouldAnimate, + previousPixelCoordinate, + animatedX, + animatedY, + updateTransition, + ]); // Update color when fill changes useEffect(() => { if (shouldAnimate && previousFill && previousFill !== fill) { colorProgress.value = 0; - colorProgress.value = buildTransition(1, transition); + colorProgress.value = buildTransition(1, updateTransition); } else { cancelAnimation(colorProgress); colorProgress.value = 1; } - }, [fill, shouldAnimate, previousFill, colorProgress, transition]); + }, [fill, shouldAnimate, previousFill, colorProgress, updateTransition]); // Create animated point for circles const animatedPoint = useDerivedValue(() => { @@ -229,21 +291,20 @@ export const Point = memo( return interpolateColors(colorProgress.value, [0, 1], [previousFill, fill]); }, [colorProgress, previousFill, fill]); - // Check if point is within drawing area - const isWithinDrawingArea = useDerivedValue(() => { + const isWithinDrawingArea = useMemo(() => { + if (!pixelCoordinate) return false; return ( - animatedX.value >= drawingArea.x && - animatedX.value <= drawingArea.x + drawingArea.width && - animatedY.value >= drawingArea.y && - animatedY.value <= drawingArea.y + drawingArea.height + pixelCoordinate.x >= drawingArea.x && + pixelCoordinate.x <= drawingArea.x + drawingArea.width && + pixelCoordinate.y >= drawingArea.y && + pixelCoordinate.y <= drawingArea.y + drawingArea.height ); - }, [animatedX, animatedY, drawingArea]); + }, [pixelCoordinate, drawingArea]); - // Compute effective opacity based on drawing area bounds const effectiveOpacity = useDerivedValue(() => { const baseOpacity = opacity ?? 1; - return isWithinDrawingArea.value ? baseOpacity : 0; - }, [isWithinDrawingArea, opacity]); + return isWithinDrawingArea ? baseOpacity * enterOpacity.value : 0; + }, [isWithinDrawingArea, opacity, enterOpacity]); const offset = useMemo(() => labelOffset ?? radius * 2, [labelOffset, radius]); @@ -251,8 +312,7 @@ export const Point = memo( return null; } - // If animation is disabled or on first render, use static rendering - if (!shouldAnimate || !previousPixelCoordinate) { + if (!shouldAnimate) { const isWithinBounds = pixelCoordinate.x >= drawingArea.x && pixelCoordinate.x <= drawingArea.x + drawingArea.width && @@ -296,17 +356,12 @@ export const Point = memo( ); } - // Animated rendering return ( - <> - - {/* Outer stroke circle */} - {strokeWidth > 0 && ( - - )} - {/* Inner fill circle with animated color */} - - + + {strokeWidth > 0 && ( + + )} + {label && ( ( {label} )} - + ); }, ); diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx index 1849956e10..0f05bc9cd8 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx @@ -16,17 +16,22 @@ import { Circle, Group } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; import { unwrapAnimatedValue } from '../utils'; import { projectPointWithSerializableScale } from '../utils/point'; -import { buildTransition, defaultTransition, type Transition } from '../utils/transition'; +import { + buildTransition, + defaultTransition, + getTransition, + type Transition, +} from '../utils/transition'; import type { ScrubberBeaconProps, ScrubberBeaconRef } from './Scrubber'; -const radius = 5; -const strokeWidth = 2; +const defaultRadius = 5; +const defaultStrokeWidth = 2; const pulseOpacityStart = 0.5; const pulseOpacityEnd = 0; -const pulseRadiusStart = 10; -const pulseRadiusEnd = 15; +const pulseRadiusStartMultiplier = 2; +const pulseRadiusEndMultiplier = 3; const defaultPulseTransition: Transition = { type: 'timing', @@ -36,7 +41,18 @@ const defaultPulseTransition: Transition = { const defaultPulseRepeatDelay = 400; -export type DefaultScrubberBeaconProps = ScrubberBeaconProps; +export type DefaultScrubberBeaconProps = ScrubberBeaconProps & { + /** + * Radius of the beacon circle. + * @default 5 + */ + radius?: number; + /** + * Stroke width of the beacon circle. + * @default 2 + */ + strokeWidth?: number; +}; export const DefaultScrubberBeacon = memo( forwardRef( @@ -51,6 +67,9 @@ export const DefaultScrubberBeacon = memo( animate = true, transitions, opacity: opacityProp = 1, + radius = defaultRadius, + stroke, + strokeWidth = defaultStrokeWidth, }, ref, ) => { @@ -59,7 +78,10 @@ export const DefaultScrubberBeacon = memo( useCartesianChartContext(); const targetSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + const xScale = useMemo( + () => getXSerializableScale(targetSeries?.xAxisId), + [getXSerializableScale, targetSeries?.xAxisId], + ); const yScale = useMemo( () => getYSerializableScale(targetSeries?.yAxisId), [getYSerializableScale, targetSeries?.yAxisId], @@ -71,8 +93,8 @@ export const DefaultScrubberBeacon = memo( ); const updateTransition = useMemo( - () => transitions?.update ?? defaultTransition, - [transitions?.update], + () => getTransition(transitions?.update, animate, defaultTransition), + [transitions?.update, animate], ); const pulseTransition = useMemo( () => transitions?.pulse ?? defaultPulseTransition, @@ -83,6 +105,9 @@ export const DefaultScrubberBeacon = memo( [transitions?.pulseRepeatDelay], ); + const pulseRadiusStart = radius * pulseRadiusStartMultiplier; + const pulseRadiusEnd = radius * pulseRadiusEndMultiplier; + const pulseOpacity = useSharedValue(0); const pulseRadius = useSharedValue(pulseRadiusStart); @@ -94,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(() => { @@ -129,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, @@ -149,15 +176,20 @@ export const DefaultScrubberBeacon = memo( } }, }), - [idlePulseShared, pulseOpacity, pulseRadius, pulseTransition], + [ + idlePulseShared, + pulseOpacity, + pulseRadius, + pulseTransition, + pulseRadiusStart, + pulseRadiusEnd, + ], ); // Watch idlePulse changes and control continuous pulse useAnimatedReaction( () => idlePulseShared.value, - (current, previous) => { - if (!animate) return; - + (current) => { if (current) { // Start continuous pulse when idlePulse is enabled pulseOpacity.value = pulseOpacityStart; @@ -188,7 +220,7 @@ export const DefaultScrubberBeacon = memo( pulseRadius.value = pulseRadiusStart; } }, - [animate, pulseTransition, pulseRepeatDelay], + [pulseTransition, pulseRepeatDelay, pulseRadiusStart, pulseRadiusEnd], ); const pulseVisibility = useDerivedValue(() => { @@ -211,7 +243,7 @@ export const DefaultScrubberBeacon = memo( return ( - + ); diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx index 7f2255a3ff..95af73d5e0 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useCartesianChartContext } from '../ChartProvider'; import { DefaultReferenceLineLabel } from '../line'; @@ -10,19 +10,30 @@ export type DefaultScrubberLabelProps = ScrubberLabelProps; /** * DefaultScrubberLabel is the default label component for the scrubber line. * It will automatically add padding around the label when elevated to fit within chart bounds to prevent shadow from being cutoff. - * It will also center the label vertically with the top available area. + * In vertical layout, it positions the label above the scrubber line. + * In horizontal layout, it centers the label in the chart's right inset. */ export const DefaultScrubberLabel = memo( - ({ verticalAlignment = 'middle', dy, boundsInset, ...props }) => { - const { drawingArea } = useCartesianChartContext(); + ({ dx: dxProp, dy: dyProp, ...props }) => { + const { drawingArea, layout, width: chartWidth } = useCartesianChartContext(); + const isHorizontalLayout = layout === 'horizontal'; - return ( - - ); + const dx = useMemo(() => { + if (dxProp !== undefined) return dxProp; + if (isHorizontalLayout) { + const drawingAreaEnd = drawingArea.x + drawingArea.width; + const rightOffset = chartWidth - drawingAreaEnd; + return rightOffset / 2; + } + return 0; + }, [drawingArea.width, drawingArea.x, dxProp, isHorizontalLayout, chartWidth]); + + const dy = useMemo(() => { + if (dyProp !== undefined) return dyProp; + if (isHorizontalLayout) return 0; + return -0.5 * drawingArea.y; + }, [dyProp, isHorizontalLayout, drawingArea.y]); + + return ; }, ); diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index 89d619b2da..21e1e121e0 100644 --- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx @@ -11,8 +11,6 @@ import { useAnimatedReaction, useDerivedValue, useSharedValue, - withDelay, - withTiming, } from 'react-native-reanimated'; import { useTheme } from '@coinbase/cds-mobile'; import { type AnimatedProp, Group, Rect, type SkParagraph } from '@shopify/react-native-skia'; @@ -23,16 +21,17 @@ import { type ReferenceLineBaseProps, type ReferenceLineLabelComponentProps, } from '../line'; -import type { ChartTextProps } from '../text'; +import type { ChartTextChildren, ChartTextProps } from '../text'; import { - accessoryFadeTransitionDelay, - accessoryFadeTransitionDuration, type ChartInset, + defaultAccessoryEnterTransition, getPointOnSerializableScale, + getTransition, type Series, useScrubberContext, } from '../utils'; import type { Transition } from '../utils/transition'; +import { buildTransition } from '../utils/transition'; import { DefaultScrubberBeacon } from './DefaultScrubberBeacon'; import { DefaultScrubberLabel } from './DefaultScrubberLabel'; @@ -56,7 +55,7 @@ export type ScrubberBeaconRef = { pulse: () => void; }; -export type ScrubberBeaconProps = { +export type ScrubberBeaconBaseProps = { /** * Id of the series. */ @@ -67,10 +66,14 @@ export type ScrubberBeaconProps = { color?: AnimatedProp; /** * X coordinate in data space. + * In vertical layout this is the scrubber index-axis value. + * In horizontal layout this is the series value. */ dataX: AnimatedProp; /** * Y coordinate in data space. + * In vertical layout this is the series value. + * In horizontal layout this is the scrubber index-axis value. */ dataY: AnimatedProp; /** @@ -79,25 +82,46 @@ export type ScrubberBeaconProps = { isIdle: AnimatedProp; /** * Pulse the beacon while it is at rest. + * + * @note Only has an effect when `isIdle` is `true`. Pulse animations work + * regardless of the chart's `animate` prop. */ idlePulse?: boolean; /** - * Whether animations are enabled. - * @default true + * Whether position animations are enabled. + * @default to ChartContext's animate value */ animate?: boolean; + /** + * Opacity of the beacon. + * @default 1 + */ + opacity?: AnimatedProp; + /** + * Stroke color of the beacon circle. + * @default theme.color.bg + */ + stroke?: string; +}; + +export type ScrubberBeaconProps = ScrubberBeaconBaseProps & { /** * Transition configuration for beacon animations. */ transitions?: { /** - * Transition used for beacon position updates. - * @default defaultTransition + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. */ - update?: Transition; + update?: Transition | null; /** * Transition used for the pulse animation. - * @default { type: 'timing', duration: 1600, easing: Easing.bezier(0.0, 0.0, 0.0, 1.0) } + * @default transition { type: 'timing', duration: 1600, easing: Easing.bezier(0.0, 0.0, 0.0, 1.0) } */ pulse?: Transition; /** @@ -107,11 +131,6 @@ export type ScrubberBeaconProps = { */ pulseRepeatDelay?: number; }; - /** - * Opacity of the beacon. - * @default 1 - */ - opacity?: AnimatedProp; }; export type ScrubberBeaconComponent = React.FC< @@ -126,7 +145,7 @@ export type ScrubberBeaconLabelProps = Pick & /** * Label for the series. */ - label: AnimatedProp; + label: ChartTextChildren; /** * Id of the series. */ @@ -146,6 +165,12 @@ export type ScrubberBaseProps = Pick * By default, all series will be highlighted. */ seriesIds?: string[]; + /** + * Hides the beacon labels while keeping the line label visible (if provided). + * @default true in horizontal layout, false in vertical layout. + * @note Beacon labels are always hidden in horizontal layout, and cannot be overridden. + */ + hideBeaconLabels?: boolean; /** * Hides the scrubber line. * @note This hides Scrubber's ReferenceLine including the label. @@ -171,6 +196,12 @@ export type ScrubberBaseProps = Pick * Measured in pixels. */ beaconLabelHorizontalOffset?: ScrubberBeaconLabelGroupBaseProps['labelHorizontalOffset']; + /** + * Preferred side for beacon labels. + * @note labels will switch to the opposite side if there's not enough space on the preferred side. + * @default 'right' + */ + beaconLabelPreferredSide?: ScrubberBeaconLabelGroupBaseProps['labelPreferredSide']; /** * Label text displayed above the scrubber line. * Can be a static string or a function that receives the current dataIndex. @@ -194,12 +225,25 @@ export type ScrubberBaseProps = Pick */ lineStroke?: ReferenceLineBaseProps['stroke']; /** - * Transition configuration for the scrubber beacon. + * Stroke color of the scrubber beacon circle. + * @default theme.color.bg */ - beaconTransitions?: ScrubberBeaconProps['transitions']; + beaconStroke?: string; }; -export type ScrubberProps = ScrubberBaseProps; +export type ScrubberProps = ScrubberBaseProps & { + /** + * Transition configuration for the scrubber. + * Controls enter, update, and pulse animations for beacons and beacon labels. + */ + transitions?: ScrubberBeaconProps['transitions']; + /** + * Transition configuration for the scrubber beacon. + * @deprecated Use `transitions` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + */ + beaconTransitions?: ScrubberBeaconProps['transitions']; +}; export type ScrubberRef = ScrubberBeaconGroupRef; @@ -211,6 +255,7 @@ export const Scrubber = memo( ( { seriesIds, + hideBeaconLabels, hideLine, label, lineStroke, @@ -223,11 +268,14 @@ export const Scrubber = memo( overlayOffset = 2, beaconLabelMinGap, beaconLabelHorizontalOffset, + beaconLabelPreferredSide, labelFont, labelBoundsInset, beaconLabelFont, idlePulse, beaconTransitions, + transitions = beaconTransitions, + beaconStroke, }, ref, ) => { @@ -235,25 +283,31 @@ export const Scrubber = memo( const beaconGroupRef = React.useRef(null); const { scrubberPosition } = useScrubberContext(); - const { getXSerializableScale, getXAxis, series, drawingArea, animate, dataLength } = - useCartesianChartContext(); + const { + layout, + getXSerializableScale, + getYSerializableScale, + getXAxis, + getYAxis, + series, + drawingArea, + animate, + dataLength, + } = useCartesianChartContext(); - const xAxis = useMemo(() => getXAxis(), [getXAxis]); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); + const indexAxis = useMemo( + () => (categoryAxisIsX ? getXAxis() : getYAxis()), + [categoryAxisIsX, getXAxis, getYAxis], + ); + const indexScale = useMemo( + () => (categoryAxisIsX ? getXSerializableScale() : getYSerializableScale()), + [categoryAxisIsX, getXSerializableScale, getYSerializableScale], + ); // Animation state for delayed scrubber rendering (matches web timing) const scrubberOpacity = useSharedValue(animate ? 0 : 1); - // Delay scrubber appearance until after path enter animation completes - useEffect(() => { - if (animate) { - scrubberOpacity.value = withDelay( - accessoryFadeTransitionDelay, - withTiming(1, { duration: accessoryFadeTransitionDuration }), - ); - } - }, [animate, scrubberOpacity]); - // Expose imperative handle with pulse method useImperativeHandle(ref, () => ({ pulse: () => { @@ -272,13 +326,17 @@ export const Scrubber = memo( return scrubberPosition.value ?? Math.max(0, dataLength - 1); }, [scrubberPosition, dataLength]); - const dataX = useDerivedValue(() => { - if (xAxis?.data && Array.isArray(xAxis.data) && xAxis.data[dataIndex.value] !== undefined) { - const dataValue = xAxis.data[dataIndex.value]; - return typeof dataValue === 'string' ? dataIndex.value : dataValue; + const dataValue = useDerivedValue(() => { + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex.value] !== undefined + ) { + const axisValue = indexAxis.data[dataIndex.value]; + return typeof axisValue === 'string' ? dataIndex.value : axisValue; } return dataIndex.value; - }, [xAxis, dataIndex]); + }, [indexAxis, dataIndex]); const lineOpacity = useDerivedValue(() => { return scrubberPosition.value !== undefined ? 1 : 0; @@ -288,21 +346,34 @@ export const Scrubber = memo( return scrubberPosition.value !== undefined ? 0.8 : 0; }, [scrubberPosition]); + const pixelPosition = useDerivedValue(() => { + if (dataValue.value === undefined || !indexScale) return undefined; + return getPointOnSerializableScale(dataValue.value, indexScale); + }, [dataValue, indexScale]); + const overlayWidth = useDerivedValue(() => { - const pixelX = - dataX.value !== undefined && xScale - ? getPointOnSerializableScale(dataX.value, xScale) - : 0; - return drawingArea.x + drawingArea.width - pixelX + overlayOffset; - }, [dataX, xScale]); + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX + ? drawingArea.x + drawingArea.width - pixel + overlayOffset + : drawingArea.width + overlayOffset * 2; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const overlayHeight = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX + ? drawingArea.height + overlayOffset * 2 + : drawingArea.y + drawingArea.height - pixel + overlayOffset; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); const overlayX = useDerivedValue(() => { - const xValue = - dataX.value !== undefined && xScale - ? getPointOnSerializableScale(dataX.value, xScale) - : 0; - return xValue; - }, [dataX, xScale]); + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX ? pixel : drawingArea.x - overlayOffset; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); + + const overlayY = useDerivedValue(() => { + const pixel = pixelPosition.value ?? 0; + return categoryAxisIsX ? drawingArea.y - overlayOffset : pixel; + }, [pixelPosition, categoryAxisIsX, drawingArea, overlayOffset]); const resolvedLabelValue = useSharedValue(''); @@ -346,25 +417,39 @@ export const Scrubber = memo( [series, filteredSeriesIds], ); - if (!xScale) return; + const showBeaconLabels = !hideBeaconLabels && categoryAxisIsX && beaconLabels.length > 0; + const isReady = !!indexScale; + + const groupEnterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [transitions?.enter, animate], + ); + + useEffect(() => { + if (animate && isReady) { + scrubberOpacity.value = buildTransition(1, groupEnterTransition); + } + }, [animate, isReady, scrubberOpacity, groupEnterTransition]); + + if (!isReady) return; return ( {!hideOverlay && ( )} {!hideLine && ( - {beaconLabels.length > 0 && ( + {showBeaconLabels && ( )} diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx new file mode 100644 index 0000000000..ed9c73f5a9 --- /dev/null +++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberAccessibilityView.tsx @@ -0,0 +1,255 @@ +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 { useCartesianChartContext } from '../ChartProvider'; +import { useScrubberContext } from '../utils'; +import type { AxisConfig } from '../utils/axis'; +import { getPointOnSerializableScale } from '../utils/point'; +import type { SerializableBandScale, SerializableScale } from '../utils/scale'; + +const normalizeScrubberAccessibilityStep = ( + step: number | undefined, + defaultStep: number = 1, +): number => { + const resolvedDefaultStep = Number.isFinite(defaultStep) + ? Math.max(1, Math.floor(defaultStep)) + : 1; + + if (step === undefined || !Number.isFinite(step)) { + return resolvedDefaultStep; + } + + return Math.max(1, Math.floor(step)); +}; + +const getScrubberSampledIndices = (dataLength: number, step: number): number[] => { + if (dataLength <= 0) return []; + + const lastIndex = dataLength - 1; + if (lastIndex === 0) return [0]; + + const normalizedStep = Math.max(1, Math.floor(step)); + const sampledIndices = [0]; + + for (let dataIndex = normalizedStep; dataIndex < lastIndex; dataIndex += normalizedStep) { + sampledIndices.push(dataIndex); + } + + sampledIndices.push(lastIndex); + return sampledIndices; +}; + +const getCategoryValueForIndex = ( + index: number, + scale: SerializableScale, + axis: AxisConfig | undefined, +): number => { + if (scale.type === 'band') { + return index; + } + const axisData = axis?.data; + if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') { + const numericData = axisData as number[]; + return numericData[index] ?? index; + } + return index; +}; + +type ScrubberSegmentOrientation = 'horizontal' | 'vertical'; + +const getScrubberSegmentWeights = ( + sampledIndices: number[], + dataLength: number, + categoryScale: SerializableScale | undefined, + categoryAxis: AxisConfig | undefined, + drawingArea: Rect, + orientation: ScrubberSegmentOrientation = 'horizontal', +): { leading: number; segmentWeights: number[]; trailing: number } => { + const dimensionSize = orientation === 'horizontal' ? drawingArea.width : drawingArea.height; + const dimensionStart = orientation === 'horizontal' ? drawingArea.x : drawingArea.y; + const dimensionEnd = dimensionStart + dimensionSize; + + if (sampledIndices.length === 0 || !categoryScale || !categoryAxis || dimensionSize <= 0) { + const segmentWeights = sampledIndices.map((index, position) => { + const nextIndex = sampledIndices[position + 1] ?? dataLength; + return Math.max(1, nextIndex - index); + }); + return { leading: 0, segmentWeights, trailing: 0 }; + } + + if (categoryScale.type === 'band') { + const bandScale = categoryScale as SerializableBandScale; + const segmentWeights: number[] = []; + let leading = 0; + let trailing = 0; + + for (let i = 0; i < sampledIndices.length; i++) { + const categoryValue = getCategoryValueForIndex( + sampledIndices[i], + categoryScale, + categoryAxis, + ); + const posStart = getPointOnSerializableScale(categoryValue, bandScale, 'stepStart'); + const posEnd = getPointOnSerializableScale(categoryValue, bandScale, 'stepEnd'); + segmentWeights.push(Math.max(1, Math.abs(posEnd - posStart))); + if (i === 0) { + leading = Math.max(0, Math.min(posStart, posEnd) - dimensionStart); + } + if (i === sampledIndices.length - 1) { + trailing = Math.max(0, dimensionEnd - Math.max(posStart, posEnd)); + } + } + + return { leading, segmentWeights, trailing }; + } + + const segmentWeights = sampledIndices.map((index, position) => { + const prevIndex = position > 0 ? sampledIndices[position - 1] : -1; + const categoryValue = getCategoryValueForIndex(index, categoryScale, categoryAxis); + const posEnd = getPointOnSerializableScale(categoryValue, categoryScale); + const posStart = + prevIndex < 0 + ? dimensionStart + : getPointOnSerializableScale( + getCategoryValueForIndex(prevIndex, categoryScale, categoryAxis), + categoryScale, + ); + return Math.max(1, Math.abs(posEnd - posStart)); + }); + + return { leading: 0, segmentWeights, trailing: 0 }; +}; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + }, + segments: { + flex: 1, + }, +}); + +export type ScrubberAccessibilityViewProps = { + accessibilityLabel?: (dataIndex: number) => string; + accessibilityStep?: number; +}; + +export const ScrubberAccessibilityView = memo( + ({ accessibilityLabel, accessibilityStep }: ScrubberAccessibilityViewProps) => { + const isScreenReaderEnabled = useScreenReaderStatus(); + const { + dataLength, + drawingArea, + layout, + getXAxis, + getYAxis, + getXSerializableScale, + getYSerializableScale, + } = useCartesianChartContext(); + const { enableScrubbing } = useScrubberContext(); + + const isHorizontalLayout = layout === 'horizontal'; + const categoryAxis = useMemo( + () => (isHorizontalLayout ? getYAxis() : getXAxis()), + [isHorizontalLayout, getXAxis, getYAxis], + ); + const categoryScale = useMemo( + () => (isHorizontalLayout ? getYSerializableScale() : getXSerializableScale()), + [isHorizontalLayout, getXSerializableScale, getYSerializableScale], + ); + + const resolvedStep = useMemo( + () => normalizeScrubberAccessibilityStep(accessibilityStep), + [accessibilityStep], + ); + + const sampledIndices = useMemo( + () => getScrubberSampledIndices(dataLength, resolvedStep), + [dataLength, resolvedStep], + ); + + const segmentOrientation = isHorizontalLayout ? 'vertical' : 'horizontal'; + const { leading, segmentWeights, trailing } = useMemo( + () => + getScrubberSegmentWeights( + sampledIndices, + dataLength, + categoryScale, + categoryAxis, + drawingArea, + segmentOrientation, + ), + [sampledIndices, dataLength, categoryScale, categoryAxis, drawingArea, segmentOrientation], + ); + + const sampledSegments = useMemo(() => { + if (accessibilityLabel === undefined) return []; + + return sampledIndices.map((index, position) => { + const weight = segmentWeights[position] ?? 1; + const pointLabel = accessibilityLabel(index); + + return { + index, + weight, + accessibilityLabel: pointLabel || `Data point ${index + 1}`, + }; + }); + }, [accessibilityLabel, sampledIndices, segmentWeights]); + + const getSegmentStyle = useCallback((weight: number) => ({ flex: weight }), []); + + const overlayStyle = useMemo( + () => ({ + left: drawingArea.x, + top: drawingArea.y, + width: drawingArea.width, + height: drawingArea.height, + }), + [drawingArea.x, drawingArea.y, drawingArea.width, drawingArea.height], + ); + + const shouldHide = useMemo( + () => + !isScreenReaderEnabled || + !enableScrubbing || + !accessibilityLabel || + dataLength <= 0 || + drawingArea.width <= 0 || + drawingArea.height <= 0 || + sampledSegments.length === 0, + [ + isScreenReaderEnabled, + enableScrubbing, + accessibilityLabel, + dataLength, + drawingArea.width, + drawingArea.height, + sampledSegments.length, + ], + ); + + if (shouldHide) return; + + const segmentsFlexDirection = isHorizontalLayout ? 'column' : 'row'; + + return ( + + + {leading > 0 && } + {sampledSegments.map((segment) => ( + + ))} + {trailing > 0 && } + + + ); + }, +); diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx index 7cfcd51129..239408eb86 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx @@ -11,30 +11,32 @@ import { convertToSerializableScale } from '../utils/scale'; import { DefaultScrubberBeacon } from './DefaultScrubberBeacon'; import type { ScrubberBeaconComponent, ScrubberBeaconProps, ScrubberBeaconRef } from './Scrubber'; -// Helper component to calculate beacon data for a specific series -const BeaconWithData = memo<{ - seriesId: string; +type BeaconWithDataProps = Pick< + ScrubberBeaconProps, + 'seriesId' | 'idlePulse' | 'animate' | 'transitions' | 'stroke' +> & { dataIndex: SharedValue; - dataX: SharedValue; + dataIndexValue: SharedValue; isIdle: SharedValue; BeaconComponent: ScrubberBeaconComponent; - idlePulse?: boolean; - animate?: boolean; - transitions?: ScrubberBeaconProps['transitions']; beaconRef: (ref: ScrubberBeaconRef | null) => void; -}>( +}; + +// Helper component to calculate beacon data for a specific series +const BeaconWithData = memo( ({ seriesId, dataIndex, - dataX, + dataIndexValue, isIdle, BeaconComponent, idlePulse, animate, transitions, beaconRef, + stroke, }) => { - const { getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); + const { layout, getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); const theme = useTheme(); const series = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); @@ -65,10 +67,10 @@ const BeaconWithData = memo<{ // Get scales for gradient evaluation const gradientScale = useMemo(() => { if (!gradient) return undefined; - const scale = gradient.axis === 'x' ? getXScale() : getYScale(series?.yAxisId); + const scale = gradient.axis === 'x' ? getXScale(series?.xAxisId) : getYScale(series?.yAxisId); if (!scale) return undefined; return convertToSerializableScale(scale); - }, [gradient, getXScale, getYScale, series?.yAxisId]); + }, [gradient, getXScale, getYScale, series?.xAxisId, series?.yAxisId]); const gradientStops = useMemo(() => { if (!gradient || !gradientScale) return undefined; @@ -82,14 +84,20 @@ const BeaconWithData = memo<{ // Evaluate gradient if present if (gradient && gradientScale && gradientStops) { - const axis = gradient.axis ?? 'y'; - const dataValue = axis === 'x' ? dataX.value : dataY.value; - - if (dataValue !== undefined) { - const evaluatedColor = evaluateGradientAtValue(gradientStops, dataValue, gradientScale); - if (evaluatedColor) { - return evaluatedColor; - } + const categoryAxisIsX = layout !== 'horizontal'; + const gradientAxis = gradient.axis ?? 'y'; + const valueForAxis = + gradientAxis === 'x' + ? categoryAxisIsX + ? dataIndexValue.value + : dataY.value + : categoryAxisIsX + ? dataY.value + : dataIndexValue.value; + + const evaluatedColor = evaluateGradientAtValue(gradientStops, valueForAxis, gradientScale); + if (evaluatedColor) { + return evaluatedColor; } } @@ -99,22 +107,26 @@ const BeaconWithData = memo<{ gradient, gradientScale, gradientStops, - dataX, + dataIndexValue, dataY, series?.color, theme.color.fgPrimary, + layout, ]); + const categoryAxisIsX = layout !== 'horizontal'; + return ( ); @@ -149,16 +161,29 @@ export type ScrubberBeaconGroupProps = ScrubberBeaconGroupBaseProps & { * @default DefaultScrubberBeacon */ BeaconComponent?: ScrubberBeaconComponent; + /** + * Stroke color of the beacon circle. + * @default theme.color.bg + */ + stroke?: string; }; export const ScrubberBeaconGroup = memo( forwardRef( - ({ seriesIds, idlePulse, transitions, BeaconComponent = DefaultScrubberBeacon }, ref) => { + ( + { seriesIds, idlePulse, transitions, BeaconComponent = DefaultScrubberBeacon, stroke }, + ref, + ) => { const ScrubberBeaconRefs = useRefMap(); const { scrubberPosition } = useScrubberContext(); - const { getXAxis, series, dataLength, animate } = useCartesianChartContext(); + const { layout, getXAxis, getYAxis, series, dataLength, animate } = + useCartesianChartContext(); - const xAxis = useMemo(() => getXAxis(), [getXAxis]); + const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); + const indexAxis = useMemo( + () => (categoryAxisIsX ? getXAxis() : getYAxis()), + [categoryAxisIsX, getXAxis, getYAxis], + ); // Expose imperative handle with pulse method useImperativeHandle(ref, () => ({ @@ -177,14 +202,18 @@ export const ScrubberBeaconGroup = memo( return scrubberPosition.value ?? Math.max(0, dataLength - 1); }, [scrubberPosition, dataLength]); - const dataX = useDerivedValue(() => { - // Convert index to actual x value if axis has data - if (xAxis?.data && Array.isArray(xAxis.data) && xAxis.data[dataIndex.value] !== undefined) { - const dataValue = xAxis.data[dataIndex.value]; + const dataIndexValue = useDerivedValue(() => { + // Convert index to actual category-axis value if axis has data. + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex.value] !== undefined + ) { + const dataValue = indexAxis.data[dataIndex.value]; return typeof dataValue === 'string' ? dataIndex.value : dataValue; } return dataIndex.value; - }, [xAxis, dataIndex]); + }, [indexAxis, dataIndex]); const isIdle = useDerivedValue(() => { return scrubberPosition.value === undefined; @@ -208,10 +237,11 @@ export const ScrubberBeaconGroup = memo( animate={animate} beaconRef={createBeaconRef(s.id)} dataIndex={dataIndex} - dataX={dataX} + dataIndexValue={dataIndexValue} idlePulse={idlePulse} isIdle={isIdle} seriesId={s.id} + stroke={stroke} transitions={transitions} /> )); diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx index b68e8d9278..0e49c8be5e 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx @@ -1,11 +1,11 @@ import { memo, useCallback, useMemo, useState } from 'react'; import type { SharedValue } from 'react-native-reanimated'; -import { useDerivedValue } from 'react-native-reanimated'; +import { useAnimatedReaction, useDerivedValue, useSharedValue } from 'react-native-reanimated'; import type { AnimatedProp } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; -import type { ChartTextProps } from '../text'; -import { applySerializableScale, useScrubberContext } from '../utils'; +import type { ChartTextChildren, ChartTextProps } from '../text'; +import { applySerializableScale, unwrapAnimatedValue, useScrubberContext } from '../utils'; import { calculateLabelYPositions, getLabelPosition, @@ -13,15 +13,27 @@ import { type LabelPosition, type ScrubberLabelPosition, } from '../utils/scrubber'; +import { + buildTransition, + defaultTransition, + getTransition, + type Transition, +} from '../utils/transition'; import { DefaultScrubberBeaconLabel } from './DefaultScrubberBeaconLabel'; -import type { ScrubberBeaconLabelComponent, ScrubberBeaconLabelProps } from './Scrubber'; +import type { + ScrubberBeaconLabelComponent, + ScrubberBeaconLabelProps, + ScrubberBeaconProps, +} from './Scrubber'; const PositionedLabel = memo<{ index: number; positions: SharedValue<(LabelPosition | null)[]>; position: SharedValue; - label: AnimatedProp; + isIdle: AnimatedProp; + updateTransition: Transition | null; + label: ChartTextChildren; color?: string; seriesId: string; onDimensionsChange: (id: string, dimensions: LabelDimensions) => void; @@ -33,6 +45,8 @@ const PositionedLabel = memo<{ index, positions, position, + isIdle, + updateTransition, label, color, seriesId, @@ -46,7 +60,30 @@ const PositionedLabel = memo<{ [positions, index], ); const x = useDerivedValue(() => positions.value[index]?.x ?? 0, [positions, index]); - const y = useDerivedValue(() => positions.value[index]?.y ?? 0, [positions, index]); + const targetY = useDerivedValue(() => positions.value[index]?.y ?? 0, [positions, index]); + + const idleAnimatedY = useSharedValue(null); + useAnimatedReaction( + () => ({ y: targetY.value, idle: unwrapAnimatedValue(isIdle) }), + (current, previous) => { + // 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 { + 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; @@ -93,6 +130,12 @@ export type ScrubberBeaconLabelGroupBaseProps = { * Font style for the beacon labels. */ labelFont?: ChartTextProps['font']; + /** + * Preferred side for labels. + * @note labels will switch to the opposite side if there's not enough space on the preferred side. + * @default 'right' + */ + labelPreferredSide?: ScrubberLabelPosition; }; export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & { @@ -101,6 +144,10 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & * @default DefaultScrubberBeaconLabel */ BeaconLabelComponent?: ScrubberBeaconLabelComponent; + /** + * Transition configuration for beacon label animations. + */ + transitions?: ScrubberBeaconProps['transitions']; }; export const ScrubberBeaconLabelGroup = memo( @@ -109,7 +156,9 @@ export const ScrubberBeaconLabelGroup = memo( labelMinGap = 4, labelHorizontalOffset = 16, labelFont, + labelPreferredSide = 'right', BeaconLabelComponent = DefaultScrubberBeaconLabel, + transitions, }) => { const { getSeries, @@ -119,9 +168,19 @@ export const ScrubberBeaconLabelGroup = memo( getXAxis, drawingArea, dataLength, + animate, } = useCartesianChartContext(); const { scrubberPosition } = useScrubberContext(); + const isIdle = useDerivedValue(() => { + return scrubberPosition.value === undefined; + }, [scrubberPosition]); + + const updateTransition = useMemo( + () => getTransition(transitions?.update, animate, defaultTransition), + [transitions?.update, animate], + ); + const [labelDimensions, setLabelDimensions] = useState>({}); const handleDimensionsChange = useCallback((id: string, dimensions: LabelDimensions) => { @@ -255,9 +314,15 @@ export const ScrubberBeaconLabelGroup = memo( const maxWidth = Math.max(...Object.values(labelDimensions).map((dim) => dim.width)); - const position = getLabelPosition(pixelX, maxWidth, drawingArea, labelHorizontalOffset); + const position = getLabelPosition( + pixelX, + maxWidth, + drawingArea, + labelHorizontalOffset, + labelPreferredSide, + ); return position; - }, [dataX, xScale, labelDimensions, drawingArea, labelHorizontalOffset]); + }, [dataX, xScale, labelDimensions, drawingArea, labelHorizontalOffset, labelPreferredSide]); return seriesInfo.map((info, index) => { const labelInfo = labels.find((label) => label.seriesId === info.seriesId); @@ -268,6 +333,7 @@ export const ScrubberBeaconLabelGroup = memo( BeaconLabelComponent={BeaconLabelComponent} color={labelInfo.color} index={index} + isIdle={isIdle} label={labelInfo.label} labelFont={labelFont} labelHorizontalOffset={labelHorizontalOffset} @@ -275,6 +341,7 @@ export const ScrubberBeaconLabelGroup = memo( position={currentPosition} positions={allLabelPositions} seriesId={info.seriesId} + updateTransition={updateTransition} /> ); }); diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx index 5aab2ef818..0989ae598d 100644 --- a/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/ScrubberProvider.tsx @@ -37,28 +37,35 @@ export const ScrubberProvider: React.FC = ({ throw new Error('ScrubberProvider must be used within a ChartContext'); } - const { getXSerializableScale, getXAxis } = chartContext; + const { layout, getXSerializableScale, getYSerializableScale, getXAxis, getYAxis } = chartContext; const scrubberPosition = useSharedValue(undefined); - const xAxis = useMemo(() => getXAxis(), [getXAxis]); - const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]); + const categoryAxisIsX = useMemo(() => layout !== 'horizontal', [layout]); + const categoryAxis = useMemo( + () => (categoryAxisIsX ? getXAxis() : getYAxis()), + [categoryAxisIsX, getXAxis, getYAxis], + ); + const categoryScale = useMemo( + () => (categoryAxisIsX ? getXSerializableScale() : getYSerializableScale()), + [categoryAxisIsX, getXSerializableScale, getYSerializableScale], + ); - const getDataIndexFromX = useCallback( - (touchX: number): number => { + const getDataIndexFromPosition = useCallback( + (touchPosition: number): number => { 'worklet'; - if (!xScale || !xAxis) return 0; + if (!categoryScale || !categoryAxis) return 0; - if (xScale.type === 'band') { - const [domainMin, domainMax] = xScale.domain; + if (categoryScale.type === 'band') { + const [domainMin, domainMax] = categoryScale.domain; const categoryCount = domainMax - domainMin + 1; let closestIndex = 0; let closestDistance = Infinity; for (let i = 0; i < categoryCount; i++) { - const xPos = getPointOnSerializableScale(i, xScale); - if (xPos !== undefined) { - const distance = Math.abs(touchX - xPos); + const categoryPos = getPointOnSerializableScale(i, categoryScale); + if (categoryPos !== undefined) { + const distance = Math.abs(touchPosition - categoryPos); if (distance < closestDistance) { closestDistance = distance; closestIndex = i; @@ -67,19 +74,18 @@ export const ScrubberProvider: React.FC = ({ } return closestIndex; } else { - // For numeric scales with axis data, find the nearest data point - const axisData = xAxis.data; + // For numeric scales with axis data, find the nearest data point. + const axisData = categoryAxis.data; if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') { - // We have numeric axis data - find the closest data point const numericData = axisData as number[]; let closestIndex = 0; let closestDistance = Infinity; for (let i = 0; i < numericData.length; i++) { - const xValue = numericData[i]; - const xPos = getPointOnSerializableScale(xValue, xScale); - if (xPos !== undefined) { - const distance = Math.abs(touchX - xPos); + const dataValue = numericData[i]; + const categoryPos = getPointOnSerializableScale(dataValue, categoryScale); + if (categoryPos !== undefined) { + const distance = Math.abs(touchPosition - categoryPos); if (distance < closestDistance) { closestDistance = distance; closestIndex = i; @@ -88,14 +94,14 @@ export const ScrubberProvider: React.FC = ({ } return closestIndex; } else { - const xValue = invertSerializableScale(touchX, xScale); - const dataIndex = Math.round(xValue); - const domain = xAxis.domain; + const dataValue = invertSerializableScale(touchPosition, categoryScale); + const dataIndex = Math.round(dataValue); + const domain = categoryAxis.domain; return Math.max(domain.min ?? 0, Math.min(dataIndex, domain.max ?? 0)); } } }, - [xAxis, xScale], + [categoryAxis, categoryScale], ); const handleStartEndHaptics = useCallback(() => { @@ -125,14 +131,16 @@ export const ScrubberProvider: React.FC = ({ // Android does not trigger onUpdate when the gesture starts. This achieves consistent behavior across both iOS and Android if (Platform.OS === 'android') { - const newScrubberPosition = getDataIndexFromX(event.x); + const pointerPosition = categoryAxisIsX ? event.x : event.y; + const newScrubberPosition = getDataIndexFromPosition(pointerPosition); if (newScrubberPosition !== scrubberPosition.value) { scrubberPosition.value = newScrubberPosition; } } }) .onUpdate(function onUpdate(event) { - const newScrubberPosition = getDataIndexFromX(event.x); + const pointerPosition = categoryAxisIsX ? event.x : event.y; + const newScrubberPosition = getDataIndexFromPosition(pointerPosition); if (newScrubberPosition !== scrubberPosition.value) { scrubberPosition.value = newScrubberPosition; } @@ -151,7 +159,8 @@ export const ScrubberProvider: React.FC = ({ [ allowOverflowGestures, handleStartEndHaptics, - getDataIndexFromX, + getDataIndexFromPosition, + categoryAxisIsX, scrubberPosition, enableScrubbing, ], diff --git a/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx b/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx new file mode 100644 index 0000000000..4f3a63f782 --- /dev/null +++ b/packages/mobile-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx @@ -0,0 +1,1049 @@ +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 { useCartesianChartContext } from '../../ChartProvider'; +import { LineChart, SolidLine } from '../../line'; +import { + getLineData, + type ScrubberLabelPosition, + unwrapAnimatedValue, + useScrubberContext, +} from '../../utils'; +import { + DefaultScrubberBeacon, + DefaultScrubberBeaconLabel, + DefaultScrubberLabel, + Scrubber, + type ScrubberBeaconLabelProps, + type ScrubberBeaconProps, + type ScrubberLabelProps, + type ScrubberRef, +} from '..'; + +const sampleData = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + +const chartAccessibilityLabel = `Price chart with ${sampleData.length} data points. Swipe to navigate.`; +const getScrubberAccessibilityLabel = (index: number) => `Point ${index + 1}: ${sampleData[index]}`; + +const BasicScrubber = () => { + return ( + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} + > + + + ); +}; + +const seriesFilterData = { + top: [15, 28, 32, 44, 46, 36, 40, 45, 48, 38], + upperMiddle: [12, 23, 21, 29, 34, 28, 31, 38, 42, 35], + lowerMiddle: [8, 15, 14, 25, 20, 18, 22, 28, 24, 30], + bottom: [4, 8, 11, 15, 16, 14, 16, 10, 12, 14], +}; + +const SeriesFilter = () => { + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `Point ${index + 1}: top ${seriesFilterData.top[index]}, lowerMiddle ${seriesFilterData.lowerMiddle[index]}`, + [], + ); + + return ( + + + + ); +}; + +const WithLabels = () => { + return ( + + `Day ${dataIndex + 1}`} /> + + ); +}; + +const IdlePulse = () => { + const theme = useTheme(); + + return ( + + + + ); +}; + +const ImperativePulse = () => { + const scrubberRef = useRef(null); + + return ( + + + + + + + ); +}; + +const BeaconStroke = () => { + const theme = useTheme(); + const backgroundColor = `rgb(${theme.spectrum.red40})`; + const foregroundColor = `rgb(${theme.spectrum.gray0})`; + + return ( + + + + + + ); +}; + +const CustomBeacon = () => { + const theme = useTheme(); + + const InvertedBeacon = memo((props: ScrubberBeaconProps) => ( + + )); + + return ( + ({ min, max: max - 16 }), + }} + yAxis={{ + showGrid: true, + domain: { min: 0, max: 100 }, + }} + > + + + ); +}; + +const CustomBeaconLabel = () => { + const theme = useTheme(); + + const MyScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, series } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataLength = useMemo( + () => + series?.reduce((max, s) => { + const data = getSeriesData(s.id); + return Math.max(max, data?.length ?? 0); + }, 0) ?? 0, + [series, getSeriesData], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel = useDerivedValue(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex.value]; + return `${unwrapAnimatedValue(label)} · ${dataAtPosition}%`; + } + return unwrapAnimatedValue(label); + }, [label, seriesData, dataIndex]); + + return ( + + ); + }, + ); + + return ( + + `Point ${index + 1}: ${[25, 30, 35, 45, 60, 100][index]}°F` + } + height={150} + series={[ + { + id: 'Boston', + data: [25, 30, 35, 45, 60, 100], + color: `rgb(${theme.spectrum.green40})`, + label: 'Boston', + }, + { + id: 'Miami', + data: [20, 25, 30, 35, 20, 0], + color: `rgb(${theme.spectrum.blue40})`, + label: 'Miami', + }, + { + id: 'Denver', + data: [10, 15, 20, 25, 40, 0], + color: `rgb(${theme.spectrum.orange40})`, + label: 'Denver', + }, + { + id: 'Phoenix', + data: [15, 10, 5, 0, 0, 0], + color: `rgb(${theme.spectrum.red40})`, + label: 'Phoenix', + }, + ]} + yAxis={{ + showGrid: true, + }} + > + + + ); +}; + +const PercentageBeaconLabels = () => { + const theme = useTheme(); + + const PercentageScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, series, fontProvider } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataLength = useMemo( + () => + series?.reduce((max, s) => { + const data = getSeriesData(s.id); + return Math.max(max, data?.length ?? 0); + }, 0) ?? 0, + [series, getSeriesData], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const labelColor = `rgb(${theme.spectrum.gray0})`; + + const regularStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: 14, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(labelColor), + }), + [labelColor], + ); + + const boldStyle: SkTextStyle = useMemo( + () => ({ + ...regularStyle, + fontStyle: { + weight: FontWeight.Bold, + }, + }), + [regularStyle], + ); + + const percentageLabel = useDerivedValue(() => { + const labelValue = unwrapAnimatedValue(label); + + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex.value]; + + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Left }, fontProvider); + + builder.pushStyle(boldStyle); + builder.addText(`${dataAtPosition}%`); + builder.pushStyle(regularStyle); + builder.addText(` ${labelValue}`); + + const para = builder.build(); + para.layout(512); + return para; + } + + return labelValue; + }, [label, seriesData, dataIndex, fontProvider, boldStyle, regularStyle]); + + return ( + + ); + }, + ); + + const isLightTheme = theme.activeColorScheme === 'light'; + const background = isLightTheme + ? `rgb(${theme.spectrum.gray90})` + : `rgb(${theme.spectrum.gray0})`; + const scrubberLineStroke = isLightTheme + ? `rgb(${theme.spectrum.gray0})` + : `rgb(${theme.spectrum.gray90})`; + + const seriesData = [ + { + id: 'prices2', + data: [90, 78, 71, 55, 2, 55, 78, 48, 79, 96, 32, 80, 79, 42], + color: `rgb(${theme.spectrum.blue40})`, + label: 'ATL', + }, + { + id: 'prices', + data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], + color: `rgb(${theme.spectrum.chartreuse40})`, + label: 'NYC', + }, + ]; + + return ( + + + `Point ${index + 1}`} + height={150} + inset={{ bottom: 8, left: 8, top: 8, right: 0 }} + series={seriesData} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 92 }), + }} + > + + + + + `Point ${index + 1}`} + height={150} + inset={{ bottom: 8, left: 8, top: 8, right: 0 }} + series={seriesData} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 92 }), + }} + > + + + + + ); +}; + +const HideBeaconLabels = () => { + const theme = useTheme(); + + return ( + + `Page ${index + 1}: ${[2400, 1398, 9800, 3908, 4800, 3800, 4300][index]} views` + } + height={200} + inset={{ top: 60 }} + series={[ + { + id: 'pageViews', + data: [2400, 1398, 9800, 3908, 4800, 3800, 4300], + color: theme.color.accentBoldGreen, + label: 'Page Views', + }, + { + id: 'uniqueVisitors', + data: [4000, 3000, 2000, 2780, 1890, 2390, 3490], + color: theme.color.accentBoldPurple, + label: 'Unique Visitors', + }, + ]} + > + `Day ${dataIndex + 1}`} + /> + + ); +}; + +const LabelElevated = () => { + return ( + + `Day ${dataIndex + 1}`} /> + + ); +}; + +const CustomLabelComponent = () => { + const theme = useTheme(); + + const MyLabelComponent = memo((props: ScrubberLabelProps) => { + const { drawingArea } = useCartesianChartContext(); + + if (!drawingArea) return null; + + return ( + + ); + }); + + return ( + + `Day ${dataIndex + 1}`} + /> + + ); +}; + +const ethData = [5, 15, 18, 30, 65, 30, 15, 35, 15, 2, 45, 12, 15, 40]; + +const LabelFonts = () => { + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `Day ${index + 1}: BTC ${sampleData[index]}, ETH ${ethData[index]}`, + [], + ); + + return ( + + `Day ${dataIndex + 1}`} + labelFont="legal" + /> + + ); +}; + +const LabelBoundsInset = () => { + return ( + + + + + + + + + ); +}; + +const CustomLine = () => { + return ( + + + + ); +}; + +const HiddenScrubberWhenIdle = () => { + const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => { + const { scrubberPosition } = useScrubberContext(); + const beaconOpacity = useDerivedValue( + () => (scrubberPosition.value !== undefined ? 1 : 0), + [scrubberPosition], + ); + + return ; + }); + + const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { + const { scrubberPosition } = useScrubberContext(); + const labelOpacity = useDerivedValue( + () => (scrubberPosition.value !== undefined ? 1 : 0), + [scrubberPosition], + ); + + return ; + }); + + return ( + + + + ); +}; + +const HideOverlay = () => { + return ( + + + + ); +}; + +const matchupBlueData = [ + 47, 50, 51, 52, 53, 53, 53, 53, 52, 51, 51, 52, 53, 55, 57, 58, 59, 61, 63, 65, 64, 64, 64, 64, + 64, 63, 63, 63, 64, 66, 68, 70, 71, 72, 74, 76, 76, 75, 74, 73, 74, 75, 75, 78, +]; +const matchupRedData = matchupBlueData.map((value) => 100 - value); +const matchupTeamLabels: Record = { + blue: 'BLUE', + red: 'RED', +}; + +const MatchupBeaconLabels = () => { + const theme = useTheme(); + + const MatchupScrubberBeaconLabel = memo( + ({ seriesId, color, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, series, fontProvider } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataLength = useMemo( + () => + series?.reduce((max, currentSeries) => { + const data = getSeriesData(currentSeries.id); + return Math.max(max, data?.length ?? 0); + }, 0) ?? 0, + [series, getSeriesData], + ); + + const dataIndex = useDerivedValue(() => { + return scrubberPosition.value ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const teamLabel = matchupTeamLabels[seriesId] ?? String(seriesId).toUpperCase(); + const labelColor = color ?? theme.color.fgPrimary; + const legalFontSize = theme.fontSize.legal; + const title3FontSize = theme.fontSize.title3; + + const teamStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: legalFontSize, + fontStyle: { + weight: FontWeight.Normal, + }, + color: Skia.Color(labelColor), + }), + [labelColor, legalFontSize], + ); + + const percentageStyle: SkTextStyle = useMemo( + () => ({ + fontFamilies: ['Inter'], + fontSize: title3FontSize, + fontStyle: { + weight: FontWeight.Bold, + }, + color: Skia.Color(labelColor), + }), + [title3FontSize, labelColor], + ); + + const matchupLabel = useDerivedValue(() => { + if (seriesData === undefined) { + return teamLabel; + } + + const value = seriesData[dataIndex.value]; + const builder = Skia.ParagraphBuilder.Make({ textAlign: TextAlign.Left }, fontProvider); + + builder.pushStyle(teamStyle); + builder.addText(teamLabel); + builder.addText('\n'); + builder.pushStyle(percentageStyle); + builder.addText(`${value}%`); + + const paragraph = builder.build(); + paragraph.layout(240); + return paragraph; + }, [dataIndex, fontProvider, percentageStyle, seriesData, teamLabel, teamStyle]); + + return ( + + ); + }, + ); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => + `Point ${index + 1}: BLUE ${matchupBlueData[index]}%, RED ${matchupRedData[index]}%`, + [], + ); + + return ( + ({ min, max: max - 64 }), + }} + yAxis={{ + domain: { min: 0, max: 100 }, + }} + > + + + ); +}; + +type ExampleItem = { + title: string; + component: React.ReactNode; +}; + +const ExampleNavigator = () => { + const [currentIndex, setCurrentIndex] = useState(0); + + const examples = useMemo( + () => [ + { + title: 'Basic', + component: , + }, + { + title: 'Series Filter', + component: , + }, + { + title: 'With Labels', + component: , + }, + { + title: 'Idle Pulse', + component: , + }, + { + title: 'Imperative Pulse', + component: , + }, + { + title: 'Beacon Stroke', + component: , + }, + { + title: 'Custom Beacon', + component: , + }, + { + title: 'Custom Beacon Label', + component: , + }, + { + title: 'Percentage Beacon Labels', + component: , + }, + { + title: 'Hide Beacon Labels', + component: , + }, + { + title: 'Label Elevated', + component: , + }, + { + title: 'Custom Label Component', + component: , + }, + { + title: 'Label Fonts', + component: , + }, + { + title: 'Label Bounds Inset', + component: , + }, + { + title: 'Custom Line', + component: , + }, + { + title: 'Hidden Scrubber When Idle', + component: , + }, + { + title: 'Hide Overlay', + component: , + }, + { + title: 'Matchup Beacon Labels', + component: , + }, + ], + [], + ); + + const currentExample = examples[currentIndex]; + const isFirstExample = currentIndex === 0; + const isLastExample = currentIndex === examples.length - 1; + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1)); + }, [examples.length]); + + return ( + + + + + + {currentExample.title} + + {currentIndex + 1} / {examples.length} + + + + + {currentExample.component} + + + ); +}; + +export default ExampleNavigator; diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts index f2e612a4d6..efbdba7872 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/axis.test.ts @@ -1,4 +1,10 @@ -import { formatAxisTick, getAxisTicksData } from '../axis'; +import { + formatAxisTick, + getAxisTicksData, + getCartesianAxisDomain, + getCartesianAxisScale, + withBaselineDomain, +} from '../axis'; import { type CategoricalScale, getCategoricalScale, @@ -489,6 +495,69 @@ describe('getAxisTicksData', () => { }); }); +describe('getCartesianAxisDomain', () => { + const series = [ + { id: 's1', data: [10, 20, 30] }, + { id: 's2', data: [5, 15, 25] }, + ]; + + it('does not apply baseline adjustments by default', () => { + const domain = getCartesianAxisDomain( + { id: 'y', scaleType: 'linear', domainLimit: 'strict', baseline: 30 }, + [{ id: 's1', data: [-100, -50] }], + 'y', + 'vertical', + ); + expect(domain).toEqual({ min: -100, max: -50 }); + }); +}); + +describe('withBaselineDomain', () => { + it('extends max when baseline is above computed bounds', () => { + const domain = withBaselineDomain(undefined, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: -100, max: -50 })).toEqual({ min: -100, max: 30 }); + }); + + it('extends min when baseline is below computed bounds', () => { + const domain = withBaselineDomain(undefined, 0); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: 25, max: 80 })).toEqual({ min: 0, max: 80 }); + }); + + it('does not change bounds when baseline is already in range', () => { + const domain = withBaselineDomain(undefined, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: 20, max: 55 })).toEqual({ min: 20, max: 55 }); + }); + + it('preserves explicit max while extending only implicit side', () => { + const domain = withBaselineDomain({ max: -50 }, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: -100, max: -80 })).toEqual({ min: -100, max: -50 }); + }); + + it('preserves fully explicit bounds', () => { + expect(withBaselineDomain({ min: -100, max: -50 }, 30)).toEqual({ + min: -100, + max: -50, + }); + }); + + it('preserves function domain identity', () => { + const domainFn = (bounds: { min: number; max: number }) => bounds; + expect(withBaselineDomain(domainFn, 30)).toBe(domainFn); + }); +}); + describe('formatAxisTick', () => { it('should use custom formatter when provided', () => { const formatter = (value: number) => `$${value}`; @@ -511,3 +580,58 @@ describe('formatAxisTick', () => { expect(formatAxisTick(undefined)).toBe(undefined); }); }); + +describe('cartesian layout helpers', () => { + it('should invert y-axis range only for vertical layout', () => { + const verticalScale = getCartesianAxisScale({ + type: 'y', + range: { min: 0, max: 100 }, + dataDomain: { min: 0, max: 10 }, + layout: 'vertical', + }); + const horizontalScale = getCartesianAxisScale({ + type: 'y', + range: { min: 0, max: 100 }, + dataDomain: { min: 0, max: 10 }, + layout: 'horizontal', + }); + + expect(verticalScale(0)).toBe(100); + expect(verticalScale(10)).toBe(0); + expect(horizontalScale(0)).toBe(0); + expect(horizontalScale(10)).toBe(100); + }); + + it('should treat y-axis as category axis in horizontal layout', () => { + const domain = getCartesianAxisDomain( + { + id: 'DEFAULT_AXIS_ID', + scaleType: 'band', + domainLimit: 'strict', + }, + [{ id: 'series1', data: [10, 20, 30] }], + 'y', + 'horizontal', + ); + + expect(domain).toEqual({ min: 0, max: 2 }); + }); + + it('should compute horizontal x-axis domain from provided series', () => { + const domain = getCartesianAxisDomain( + { + id: 'left', + scaleType: 'linear', + domainLimit: 'strict', + }, + [ + { id: 'series1', data: [1, 2, 3], xAxisId: 'left' }, + { id: 'series2', data: [100, 200, 300], xAxisId: 'right' }, + ], + 'x', + 'horizontal', + ); + + expect(domain).toEqual({ min: 1, max: 300 }); + }); +}); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts index a4952fb69b..b2a4724ab1 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts @@ -1,54 +1,381 @@ -import { getBarSizeAdjustment } from '../bar'; +import { + getBars, + getBarSizeAdjustment, + getBaselinePx, + getNormalizedStagger, + getStackGroups, + getStackOrigin, +} from '../bar'; + +jest.mock('@shopify/react-native-skia', () => ({ + Skia: { Path: { Make: jest.fn(), MakeFromSVGString: jest.fn() } }, + notifyChange: jest.fn(), +})); + +// Baseline constants for `getStackOrigin` expectations (match typical chart layout) +const VERTICAL_BASELINE = 300; +const HORIZONTAL_BASELINE = 0; describe('getBarSizeAdjustment', () => { - it('should return 0 when barCount is 0', () => { - const result = getBarSizeAdjustment(0, 10); - expect(result).toBe(0); + it('returns 0 when barCount is 0', () => { + expect(getBarSizeAdjustment(0, 10)).toBe(0); + }); + + it('returns 0 when barCount is 1', () => { + expect(getBarSizeAdjustment(1, 10)).toBe(0); + }); + + it('calculates correct adjustment for 2 bars', () => { + expect(getBarSizeAdjustment(2, 10)).toBe(5); + }); + + it('calculates correct adjustment for 3 bars', () => { + expect(getBarSizeAdjustment(3, 12)).toBe(8); + }); + + it('calculates correct adjustment for 4 bars', () => { + expect(getBarSizeAdjustment(4, 15)).toBe(11.25); + }); + + it('handles zero gap size', () => { + expect(getBarSizeAdjustment(3, 0)).toBe(0); + }); + + it('handles negative gap size', () => { + expect(getBarSizeAdjustment(3, -6)).toBe(-4); + }); + + it('handles fractional bar count', () => { + expect(getBarSizeAdjustment(2.5, 10)).toBe(6); + }); + + it('handles large numbers', () => { + expect(getBarSizeAdjustment(100, 1000)).toBe(990); + }); +}); + +describe('getStackGroups', () => { + it('groups series by stackId and axis IDs', () => { + const groups = getStackGroups([ + { id: 'a', stackId: 'price', xAxisId: 'x1', yAxisId: 'y1' }, + { id: 'b', stackId: 'price', xAxisId: 'x1', yAxisId: 'y1' }, + { id: 'c', stackId: 'price', xAxisId: 'x1', yAxisId: 'y2' }, + ]); + + expect(groups).toHaveLength(2); + expect(groups[0].stackId).toBe('price:x1:y1'); + expect(groups[0].series.map((s) => s.id)).toEqual(['a', 'b']); + expect(groups[1].stackId).toBe('price:x1:y2'); + expect(groups[1].series.map((s) => s.id)).toEqual(['c']); + }); + + it('falls back to individual stackId when missing', () => { + const groups = getStackGroups([{ id: 'a' }, { id: 'b' }]); + + expect(groups).toHaveLength(2); + expect(groups[0].stackId).toContain('individual-a'); + expect(groups[1].stackId).toContain('individual-b'); + }); + + it('uses provided default axis id for missing axis values', () => { + const groups = getStackGroups( + [ + { id: 'a', stackId: 's1' }, + { id: 'b', stackId: 's1' }, + ], + 'custom-default', + ); + + expect(groups).toHaveLength(1); + expect(groups[0].stackId).toBe('s1:custom-default:custom-default'); + }); +}); + +describe('getBaselinePx', () => { + const rect = { x: 10, y: 20, width: 100, height: 200 }; + + function createValueScale(domain: [number, number], map: (value: number) => number | undefined) { + return Object.assign((value: number) => map(value), { domain: () => domain }) as any; + } + + it('uses domain min for fully positive vertical domains', () => { + const valueScale = createValueScale([5, 15], (value) => 220 - value * 10); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(170); + }); + + it('uses domain max for fully negative horizontal domains', () => { + const valueScale = createValueScale([-20, -5], (value) => 60 + value); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(55); }); - it('should return 0 when barCount is 1', () => { - const result = getBarSizeAdjustment(1, 10); - expect(result).toBe(0); + it('uses zero for domains that cross zero', () => { + const valueScale = createValueScale([-10, 10], (value) => 120 + value * 5); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(110); }); - it('should calculate correct adjustment for 2 bars', () => { - const result = getBarSizeAdjustment(2, 10); - // (10 * (2 - 1)) / 2 = 10 / 2 = 5 - expect(result).toBe(5); + it('clamps vertical baseline to chart bounds when scale output is outside rect', () => { + const valueScale = createValueScale([-5, 5], () => -1000); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(rect.y); }); - it('should calculate correct adjustment for 3 bars', () => { - const result = getBarSizeAdjustment(3, 12); - // (12 * (3 - 1)) / 3 = 24 / 3 = 8 - expect(result).toBe(8); + it('uses orientation-aware fallback when scale returns undefined', () => { + const valueScale = createValueScale([-5, 5], () => undefined); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(rect.y + rect.height); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(rect.x); }); - it('should calculate correct adjustment for 4 bars', () => { - const result = getBarSizeAdjustment(4, 15); - // (15 * (4 - 1)) / 4 = 45 / 4 = 11.25 - expect(result).toBe(11.25); + it('uses explicit baseline value when provided', () => { + const valueScale = createValueScale([-10, 50], (value) => 300 - value * 2); + expect(getBaselinePx(valueScale, rect, 'vertical', 30)).toBe(220); }); +}); - it('should handle zero gap size', () => { - const result = getBarSizeAdjustment(3, 0); - expect(result).toBe(0); +describe('getStackOrigin', () => { + it('returns undefined when barMinSize is 0', () => { + expect(getStackOrigin([0, 10], 0)).toBeUndefined(); }); - it('should handle negative gap size', () => { - const result = getBarSizeAdjustment(3, -6); - // (-6 * (3 - 1)) / 3 = -12 / 3 = -4 - expect(result).toBe(-4); + it('returns undefined when origins array is empty', () => { + expect(getStackOrigin([], 6)).toBeUndefined(); }); - it('should handle fractional bar count', () => { - const result = getBarSizeAdjustment(2.5, 10); - // (10 * (2.5 - 1)) / 2.5 = 15 / 2.5 = 6 - expect(result).toBe(6); + describe('horizontal positive: buy+sell with minSize=6, gap=4', () => { + it('rangeStart is min origin (0)', () => { + const [start] = getStackOrigin([0, 10], 6)!; + expect(start).toBe(0); + }); + + it('rangeEnd is max origin + minSize (16)', () => { + const [, end] = getStackOrigin([0, 10], 6)!; + expect(end).toBe(16); + }); + }); + + describe('single bar', () => { + it('single positive horizontal bar → [baseline, baseline + minSize]', () => { + const origins = [HORIZONTAL_BASELINE]; + expect(getStackOrigin(origins, 6)).toEqual([HORIZONTAL_BASELINE, HORIZONTAL_BASELINE + 6]); + }); + + it('single positive vertical bar → [baseline - minSize, baseline]', () => { + const origins = [VERTICAL_BASELINE - 6]; + expect(getStackOrigin(origins, 6)).toEqual([VERTICAL_BASELINE - 6, VERTICAL_BASELINE]); + }); }); - it('should handle large numbers', () => { - const result = getBarSizeAdjustment(100, 1000); - // (1000 * (100 - 1)) / 100 = 99000 / 100 = 990 - expect(result).toBe(990); + describe('two positive horizontal bars (minSize=6, gap=4)', () => { + it('range covers [0, 16] — both initial bar positions', () => { + const origins = [0, 10]; + expect(getStackOrigin(origins, 6)).toEqual([0, 16]); + }); + }); + + describe('two positive vertical bars (minSize=6, gap=4)', () => { + it('range covers from furthest bar top to baseline', () => { + const origins = [294, 284]; + expect(getStackOrigin(origins, 6)).toEqual([284, VERTICAL_BASELINE]); + }); + }); + + describe('two negative horizontal bars (minSize=6, gap=4, baseline=150)', () => { + // near gets idx=0: origin = 150 - 1*6 - 0*4 = 144 + // far gets idx=1: origin = 150 - 2*6 - 1*4 = 134 + // range = [134, 144+6] = [134, 150] + it('range covers from furthest bar to baseline', () => { + const origins = [144, 134]; + expect(getStackOrigin(origins, 6)).toEqual([134, 150]); + }); + }); + + it('supports per-bar min sizes', () => { + expect(getStackOrigin([0, 10], [4, 8])).toEqual([0, 18]); + }); +}); + +describe('getBars horizontal barMinSize from baseline (regression)', () => { + /** + * Applying the vertical "above baseline" restack to horizontal stacks once shifted + * the whole stack left by ~its full width (e.g. x ≈ -1008 with a [0, 1008] value range). + */ + function linearValueScale(domain: [number, number], range: [number, number]) { + const [d0, d1] = domain; + const [r0, r1] = range; + return Object.assign((v: number) => r0 + ((v - d0) / (d1 - d0)) * (r1 - r0), { + domain: () => domain, + }) as any; + } + + const WIDE_CHART_WIDTH = 1008; + + it('anchors a buy/sell-style percentage stack at x=0 on a wide linear range (barMinSize + stackGap)', () => { + const valueScale = linearValueScale([0, 100], [0, WIDE_CHART_WIDTH]); + const bars = getBars({ + series: [ + { id: 'buy', data: [76], stackId: 'bs' }, + { id: 'sell', data: [24], stackId: 'bs' }, + ] as any, + seriesData: { + buy: [[0, 76]], + sell: [[76, 100]], + }, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 6, + valueScale, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 4, + barMinSize: 6, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + expect(bars).toHaveLength(2); + const buyBar = bars.find((b) => b.seriesId === 'buy')!; + const sellBar = bars.find((b) => b.seriesId === 'sell')!; + + expect(buyBar.x).toBeCloseTo(0, 4); + expect(buyBar.x).toBeGreaterThanOrEqual(-0.01); + expect(sellBar.x).toBeGreaterThan(buyBar.x); + + const minX = Math.min(...bars.map((b) => b.x)); + const maxX = Math.max(...bars.map((b) => b.x + b.width)); + expect(minX).toBeCloseTo(0, 4); + expect(maxX).toBeCloseTo(WIDE_CHART_WIDTH, 4); + }); + + it('does not push a horizontal stack to negative x when only the trailing segment needs barMinSize', () => { + const valueScale = linearValueScale([0, 100], [0, WIDE_CHART_WIDTH]); + const bars = getBars({ + series: [ + { id: 'big', data: [99.9], stackId: 's' }, + { id: 'tiny', data: [0.1], stackId: 's' }, + ] as any, + seriesData: { + big: [[0, 99.9]], + tiny: [[99.9, 100]], + }, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 6, + valueScale, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 2, + barMinSize: 24, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + expect(Math.min(...bars.map((b) => b.x))).toBeGreaterThanOrEqual(-0.01); + const bigBar = bars.find((b) => b.seriesId === 'big')!; + expect(bigBar.x).toBeCloseTo(0, 4); + }); +}); + +describe('getBars stackMinSize entrance behavior', () => { + const valueScale = Object.assign((value: number) => value, { + domain: () => [0, 10] as [number, number], + }); + + const series = [ + { id: 'buy', data: [2], stackId: 'orders' }, + { id: 'sell', data: [4], stackId: 'orders' }, + ]; + + const seriesData = { + buy: [[0, 2]] as [number, number][], + sell: [[2, 6]] as [number, number][], + }; + + const getBarsResult = (barMinSize?: number, stackMinSize?: number) => + getBars({ + series: series as any, + seriesData, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 8, + valueScale: valueScale as any, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 0, + barMinSize, + stackMinSize, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + it('distributes stackMinSize proportionally to segment entrance min sizes', () => { + const bars = getBarsResult(undefined, 12); + expect(bars.map((bar) => bar.minSize)).toEqual([4, 8]); + }); + + it('uses max of barMinSize and stackMinSize-derived min size', () => { + const bars = getBarsResult(6, 12); + expect(bars.map((bar) => bar.minSize)).toEqual([6, 6]); + }); +}); + +describe('getNormalizedStagger', () => { + const drawingArea = { x: 10, y: 20, width: 200, height: 100 }; + + describe('vertical layout (stagger along x axis)', () => { + it('returns 0 at the left edge of the drawing area', () => { + expect(getNormalizedStagger('vertical', 10, 0, drawingArea)).toBe(0); + }); + + it('returns 1 at the right edge of the drawing area', () => { + expect(getNormalizedStagger('vertical', 210, 0, drawingArea)).toBe(1); + }); + + it('returns 0.5 at the midpoint of the drawing area', () => { + expect(getNormalizedStagger('vertical', 110, 0, drawingArea)).toBe(0.5); + }); + + it('returns 0 when drawing area width is 0', () => { + expect(getNormalizedStagger('vertical', 50, 0, { ...drawingArea, width: 0 })).toBe(0); + }); + }); + + describe('horizontal layout (stagger along y axis)', () => { + it('returns 0 at the top edge of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 20, drawingArea)).toBe(0); + }); + + it('returns 1 at the bottom edge of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 120, drawingArea)).toBe(1); + }); + + it('returns 0.5 at the midpoint of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 70, drawingArea)).toBe(0.5); + }); + + it('returns 0 when drawing area height is 0', () => { + expect(getNormalizedStagger('horizontal', 0, 50, { ...drawingArea, height: 0 })).toBe(0); + }); }); }); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb7..5aa34d6ec6 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,3 +1,4 @@ +import type { CartesianAxisConfigProps } from '../axis'; import { type AxisBounds, type ChartInset, @@ -87,7 +88,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6] }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); expect(result.get('series1')).toEqual([ @@ -102,6 +103,77 @@ describe('getStackedSeriesData', () => { ]); }); + it('should apply axis baseline map to non-stacked numeric series', () => { + const series: Series[] = [ + { id: 'series1', data: [11, 12, 13], yAxisId: 'yA' }, + { id: 'series2', data: [4, 5, 6], yAxisId: 'yB' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'yA', baseline: 10 }, + { id: 'yB', baseline: 3 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([ + [10, 11], + [10, 12], + [10, 13], + ]); + expect(result.get('series2')).toEqual([ + [3, 4], + [3, 5], + [3, 6], + ]); + }); + + it('should not override tuple data when baseline map is provided', () => { + const series: Series[] = [ + { + id: 'series1', + data: [ + [8, 11], + [8, 12], + ], + }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], []); + + expect(result.get('series1')).toEqual([ + [8, 11], + [8, 12], + ]); + }); + + it('should stack numeric series around axis baseline values', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 30 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([[20, 30]]); + expect(result.get('series2')).toEqual([[30, 40]]); + expect(result.get('series3')).toEqual([[40, 70]]); + }); + + it('should apply axis baseline map to single-series stack groups', () => { + const series: Series[] = [{ id: 'series1', data: [1, 2], stackId: 'stack1' }]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 10 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([ + [10, 1], + [10, 2], + ]); + }); + it('should handle series with tuple data', () => { const series: Series[] = [ { @@ -114,7 +186,7 @@ describe('getStackedSeriesData', () => { }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(1); expect(result.get('series1')).toEqual([ @@ -130,7 +202,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); // D3 stack will create cumulative values @@ -149,7 +221,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); // Should be treated as individual series since they have different y-axes @@ -165,23 +237,112 @@ describe('getStackedSeriesData', () => { ]); }); + it('should not stack series with different xAxisId', () => { + const series: Series[] = [ + { id: 'series1', data: [1, 2, 3], stackId: 'stack1', xAxisId: 'top' }, + { id: 'series2', data: [4, 5, 6], stackId: 'stack1', xAxisId: 'bottom' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], []); + + expect(result.size).toBe(2); + expect(result.get('series1')).toEqual([ + [0, 1], + [0, 2], + [0, 3], + ]); + expect(result.get('series2')).toEqual([ + [0, 4], + [0, 5], + [0, 6], + ]); + }); + + it('should apply axis baseline map to non-stacked numeric series in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [11, 12, 13], xAxisId: 'xA' }, + { id: 'series2', data: [4, 5, 6], xAxisId: 'xB' }, + ]; + + const result = getStackedSeriesData( + series, + 'horizontal', + [ + { id: 'xA', baseline: 10 }, + { id: 'xB', baseline: 3 }, + ] as CartesianAxisConfigProps[], + [], + ); + + expect(result.get('series1')).toEqual([ + [10, 11], + [10, 12], + [10, 13], + ]); + expect(result.get('series2')).toEqual([ + [3, 4], + [3, 5], + [3, 6], + ]); + }); + + it('should stack numeric series around x-axis baseline values in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getStackedSeriesData( + series, + 'horizontal', + [{ id: 'DEFAULT_AXIS_ID', baseline: 30 }] as CartesianAxisConfigProps[], + [], + ); + + expect(result.get('series1')).toEqual([[20, 30]]); + expect(result.get('series2')).toEqual([[30, 40]]); + expect(result.get('series3')).toEqual([[40, 70]]); + }); + + it('should not stack series with different xAxisId in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [1, 2, 3], stackId: 'stack1', xAxisId: 'left' }, + { id: 'series2', data: [4, 5, 6], stackId: 'stack1', xAxisId: 'right' }, + ]; + + const result = getStackedSeriesData(series, 'horizontal', [], []); + + expect(result.size).toBe(2); + expect(result.get('series1')).toEqual([ + [0, 1], + [0, 2], + [0, 3], + ]); + expect(result.get('series2')).toEqual([ + [0, 4], + [0, 5], + [0, 6], + ]); + }); + it('should handle null values in data', () => { const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.get('series1')).toEqual([[0, 1], null, [0, 3]]); }); it('should handle empty series array', () => { - const result = getStackedSeriesData([]); + const result = getStackedSeriesData([], 'vertical', [], []); expect(result.size).toBe(0); }); it('should handle series without data', () => { const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(0); }); @@ -192,7 +353,7 @@ describe('getStackedSeriesData', () => { { id: 'series3', data: [7, 8, 9] }, // No stackId ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(3); expect(result.get('series3')).toEqual([ @@ -207,7 +368,7 @@ describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -10, 20); + const result = getChartRange(series, 'vertical', [], [], -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); @@ -217,7 +378,7 @@ describe('getChartRange', () => { { id: 'series2', data: [2, 4, 6] }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: 1, max: 6 }); }); @@ -240,7 +401,7 @@ describe('getChartRange', () => { }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: -1, max: 7 }); }); @@ -250,7 +411,7 @@ describe('getChartRange', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); // Stacked values should be cumulative expect(result.min).toBeDefined(); @@ -259,10 +420,41 @@ describe('getChartRange', () => { expect(result.max).toBeGreaterThanOrEqual(9); // 3 + 6 = 9 at minimum }); + it('should calculate range from baseline-centered stacked data', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getChartRange(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 30 }, + ] as CartesianAxisConfigProps[]); + + expect(result).toEqual({ min: 20, max: 70 }); + }); + + it('should calculate range from baseline-centered stacked data in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getChartRange( + series, + 'horizontal', + [{ id: 'DEFAULT_AXIS_ID', baseline: 30 }] as CartesianAxisConfigProps[], + [], + ); + + expect(result).toEqual({ min: 20, max: 70 }); + }); + it('should handle negative values', () => { const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: -5, max: 3 }); }); @@ -272,7 +464,7 @@ describe('getChartRange', () => { { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result.min).toBeDefined(); expect(result.max).toBeDefined(); @@ -281,35 +473,35 @@ describe('getChartRange', () => { }); it('should handle empty series array', () => { - const result = getChartRange([]); + const result = getChartRange([], 'vertical', [], []); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -5); + const result = getChartRange(series, 'vertical', [], [], -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, undefined, 10); + const result = getChartRange(series, 'vertical', [], [], undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); @@ -319,7 +511,7 @@ describe('getChartRange', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); // Should treat as individual series, not stacked expect(result).toEqual({ min: 0, max: 6 }); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts index 037a0b6c25..64a8806de4 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/gradient.test.ts @@ -1,3 +1,5 @@ +import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; + import { evaluateGradientAtValue, getGradientConfig, @@ -69,7 +71,7 @@ describe('getGradientConfig with band scale', () => { ], }; - const result = getGradientConfig(gradient, xScale, yScale); + const result = getGradientConfig(gradient, xScale, yScale, 'vertical'); expect(result).toBeTruthy(); expect(result).toHaveLength(2); }); @@ -89,7 +91,7 @@ describe('evaluateGradientAtValue with band scale', () => { ], }; - const stops = getGradientConfig(gradient, bandScale, bandScale) ?? []; + const stops = getGradientConfig(gradient, bandScale, bandScale, 'vertical') ?? []; // First index should be closer to red const color0 = evaluateGradientAtValue(stops, 0, bandScale); @@ -199,7 +201,7 @@ describe('getGradientConfig with numeric scale', () => { ], }; - const result = getGradientConfig(gradient, xScale, yScale); + const result = getGradientConfig(gradient, xScale, yScale, 'vertical'); expect(result).toBeTruthy(); expect(result).toHaveLength(3); expect(result?.[0].offset).toBe(0); @@ -207,6 +209,34 @@ describe('getGradientConfig with numeric scale', () => { expect(result?.[2].offset).toBe(1); }); + it('should use horizontal layout default (x axis) when gradient axis is omitted', () => { + const stopColorStart = defaultTheme.lightColor.fgNegative; + const stopColorEnd = defaultTheme.lightColor.fgPositive; + const localXScale = getNumericScale({ + scaleType: 'linear', + domain: { min: 0, max: 4 }, + range: { min: 0, max: 400 }, + }); + const localYScale = getNumericScale({ + scaleType: 'linear', + domain: { min: 0, max: 100 }, + range: { min: 400, max: 0 }, + }); + + const gradient: GradientDefinition = { + stops: [ + { offset: 0, color: stopColorStart }, + { offset: 4, color: stopColorEnd }, + ], + }; + + const result = getGradientConfig(gradient, localXScale, localYScale, 'horizontal'); + expect(result).toBeTruthy(); + expect(result).toHaveLength(2); + expect(result?.[0].offset).toBe(0); + expect(result?.[1].offset).toBe(1); + }); + it('should handle gradient with custom stops', () => { const gradient: GradientDefinition = { stops: [ @@ -216,7 +246,7 @@ describe('getGradientConfig with numeric scale', () => { ], }; - const result = getGradientConfig(gradient, xScale, yScale); + const result = getGradientConfig(gradient, xScale, yScale, 'vertical'); expect(result).toBeTruthy(); expect(result?.[0].offset).toBe(0); expect(result?.[1].offset).toBeCloseTo(0.3); @@ -231,7 +261,7 @@ describe('getGradientConfig with numeric scale', () => { ], }; - const result = getGradientConfig(gradient, xScale, yScale); + const result = getGradientConfig(gradient, xScale, yScale, 'vertical'); expect(result).toBeTruthy(); expect(result).toHaveLength(2); expect(result?.[0].offset).toBe(0); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts index b34ad2afdb..9bd84c4c7e 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/path.test.ts @@ -45,6 +45,16 @@ describe('getPathCurveFunction', () => { expect(curveFunction).toBeDefined(); expect(typeof curveFunction).toBe('function'); }); + + it('should switch orientation-aware curves for horizontal layout', () => { + const verticalMonotone = getPathCurveFunction('monotone', 'vertical'); + const horizontalMonotone = getPathCurveFunction('monotone', 'horizontal'); + const verticalBump = getPathCurveFunction('bump', 'vertical'); + const horizontalBump = getPathCurveFunction('bump', 'horizontal'); + + expect(horizontalMonotone).not.toBe(verticalMonotone); + expect(horizontalBump).not.toBe(verticalBump); + }); }); describe('getLinePath', () => { @@ -179,6 +189,18 @@ describe('getLinePath', () => { }); expect(result).toBe('M0,50Z'); }); + + it('should project line points for horizontal layout', () => { + const result = getLinePath({ + data: [1, 2, 3], + curve: 'linear', + xScale, + yScale, + layout: 'horizontal', + }); + + expect(result).toBe('M10,100L20,90L30,80'); + }); }); describe('getAreaPath', () => { @@ -317,6 +339,18 @@ describe('getAreaPath', () => { }); expect(result).toBe('M0,50L0,100Z'); }); + + it('should generate area path for horizontal layout', () => { + const result = getAreaPath({ + data: [1, 2], + curve: 'linear', + xScale, + yScale, + layout: 'horizontal', + }); + + expect(result).toBe('M10,100L20,90L0,90L0,100Z'); + }); }); describe('getBarPath', () => { @@ -374,4 +408,11 @@ describe('getBarPath', () => { expect(bottomRounding).not.toBe(bothRounding); expect(noRounding).not.toBe(bothRounding); }); + + it('should map roundTop/roundBottom to left-right faces in horizontal layout', () => { + const result = getBarPath(10, 20, 30, 40, 5, true, false, 'horizontal'); + expect(result).toBe( + 'M 10 20 L 35 20 A 5 5 0 0 1 40 25 L 40 55 A 5 5 0 0 1 35 60 L 10 60 A 0 0 0 0 1 10 60 L 10 20 A 0 0 0 0 1 10 20 Z', + ); + }); }); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts index 4cb6c93fee..e0bfa6523b 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/point.test.ts @@ -702,6 +702,35 @@ describe('projectPoints', () => { expect(result[2]).toEqual({ x: 20, y: 70 }); // data[2] = 3 used as y }); + it('should project points for horizontal layout', () => { + const result = projectPoints({ + data: [1, 2, 3], + xScale, + yScale, + layout: 'horizontal', + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ x: 10, y: 100 }); // value on x, index on y + expect(result[1]).toEqual({ x: 20, y: 90 }); + expect(result[2]).toEqual({ x: 30, y: 80 }); + }); + + it('should use yData as category values in horizontal layout', () => { + const result = projectPoints({ + data: [1, 2, 3], + yData: [0, 5, 10], + xScale, + yScale, + layout: 'horizontal', + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ x: 10, y: 100 }); + expect(result[1]).toEqual({ x: 20, y: 50 }); + expect(result[2]).toEqual({ x: 30, y: 0 }); + }); + it('should handle single data point', () => { const result = projectPoints({ data: [5], diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts index 97570597e5..282f33064e 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/scrubber.test.ts @@ -2,6 +2,27 @@ import type { Rect } from '@coinbase/cds-common/types'; import { calculateLabelYPositions, getLabelPosition } from '../scrubber'; +const calculateLabelStackedPositions = ( + dimensions: Array<{ + seriesId: string; + width: number; + height: number; + preferredX: number; + preferredY: number; + }>, + stackingStart: number, + stackingSize: number, + labelThickness: number, + minGap: number, +) => { + return calculateLabelYPositions( + dimensions, + { x: 0, y: stackingStart, width: 0, height: stackingSize }, + labelThickness, + minGap, + ); +}; + describe('getLabelPosition', () => { const drawingArea: Rect = { x: 0, @@ -85,7 +106,7 @@ describe('getLabelPosition', () => { }); }); -describe('calculateLabelYPositions', () => { +describe('calculateLabelStackedPositions', () => { const drawingArea: Rect = { x: 0, y: 0, @@ -97,7 +118,13 @@ describe('calculateLabelYPositions', () => { describe('with no labels', () => { it('should return empty map', () => { - const result = calculateLabelYPositions([], drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + [], + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); expect(result.size).toBe(0); }); }); diff --git a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts index 6d21762226..378a128c60 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts @@ -1,11 +1,10 @@ -import { Skia } from '@shopify/react-native-skia'; import { renderHook } from '@testing-library/react-hooks'; import { buildTransition, defaultTransition, + getTransition, type Transition, - useD3PathInterpolation, usePathTransition, } from '../transition'; @@ -82,6 +81,16 @@ describe('accessory transition constants', () => { }); }); +describe('getTransition', () => { + it('should return null when animate is false', () => { + expect(getTransition(defaultTransition, false, defaultTransition)).toBeNull(); + }); + + it('should return null when value is null', () => { + expect(getTransition(null, true, defaultTransition)).toBeNull(); + }); +}); + describe('buildTransition', () => { beforeEach(() => { jest.clearAllMocks(); @@ -165,68 +174,6 @@ describe('buildTransition', () => { }); }); -describe('useD3PathInterpolation', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('should create interpolated path', () => { - const progress = { value: 0 }; - const fromPath = 'M0,0L10,10'; - const toPath = 'M0,0L20,20'; - - const { result } = renderHook(() => useD3PathInterpolation(progress as any, fromPath, toPath)); - - expect(result.current).toBeDefined(); - expect(result.current).toHaveProperty('value'); - }); - - it('should handle path changes', () => { - const progress = { value: 0.5 }; - const fromPath1 = 'M0,0L10,10'; - const toPath1 = 'M0,0L20,20'; - - const { result, rerender } = renderHook( - ({ from, to }) => useD3PathInterpolation(progress as any, from, to), - { - initialProps: { from: fromPath1, to: toPath1 }, - }, - ); - - const firstResult = result.current; - expect(firstResult).toBeDefined(); - - // Update paths - const fromPath2 = 'M0,0L15,15'; - const toPath2 = 'M0,0L25,25'; - rerender({ from: fromPath2, to: toPath2 }); - - // Result should be updated - expect(result.current).toBeDefined(); - }); - - it('should call d3 interpolatePath', () => { - const { interpolatePath } = require('d3-interpolate-path'); - const progress = { value: 0 }; - const fromPath = 'M0,0L10,10'; - const toPath = 'M0,0L20,20'; - - renderHook(() => useD3PathInterpolation(progress as any, fromPath, toPath)); - - expect(interpolatePath).toHaveBeenCalledWith(fromPath, toPath); - }); - - it('should create Skia paths from SVG strings', () => { - const progress = { value: 0 }; - const fromPath = 'M0,0L10,10'; - const toPath = 'M0,0L20,20'; - - renderHook(() => useD3PathInterpolation(progress as any, fromPath, toPath)); - - expect(Skia.Path.MakeFromSVGString).toHaveBeenCalled(); - }); -}); - describe('useInterpolator', () => { beforeEach(() => { jest.clearAllMocks(); @@ -362,13 +309,35 @@ describe('usePathTransition', () => { const { result } = renderHook(() => usePathTransition({ currentPath, - transition, + transitions: { update: transition }, }), ); expect(result.current).toBeDefined(); }); + it('should short-circuit interpolation when update transition is null', () => { + const { interpolatePath } = require('d3-interpolate-path'); + const nextPath = 'M0,0L30,30'; + + const { result, rerender } = renderHook( + ({ path }) => + usePathTransition({ + currentPath: path, + transitions: { update: null }, + }), + { + initialProps: { path: 'M0,0L10,10' }, + }, + ); + + interpolatePath.mockClear(); + rerender({ path: nextPath }); + + expect(interpolatePath).not.toHaveBeenCalled(); + expect((result.current.value as any).svgString).toBe(nextPath); + }); + it('should handle empty paths', () => { const { result } = renderHook(() => usePathTransition({ diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index 1abdf96a59..33fd189920 100644 --- a/packages/mobile-visualization/src/chart/utils/axis.ts +++ b/packages/mobile-visualization/src/chart/utils/axis.ts @@ -9,9 +9,9 @@ import { isValidBounds, type Series, } from './chart'; +import type { CartesianChartLayout } from './context'; import { getPointOnScale } from './point'; import { - type CategoricalScale, type ChartAxisScaleType, type ChartScaleFunction, getCategoricalScale, @@ -71,7 +71,8 @@ export type AxisConfig = { */ range: AxisBounds; /** - * Data for the axis + * Data for the axis. + * @note only used by the category axis. */ data?: string[] | number[]; /** @@ -88,10 +89,22 @@ export type AxisConfig = { domainLimit: 'nice' | 'strict'; }; +export type CartesianAxisConfig = AxisConfig & { + /** + * Baseline value used as the origin for numeric series on this axis. + * Only applies when this axis is the value axis for the current chart layout. + * - Non-stacked numeric series render from `[baseline, value]`. + * - Multi-series stacks are normalized around this baseline before stacking. + * + * @default 0 for value axes, undefined for category axes + */ + baseline?: number; +}; + /** * Axis configuration without computed bounds (used for input) */ -export type AxisConfigProps = Omit & { +export type CartesianAxisConfigProps = Omit & { /** * Unique identifier for this axis. */ @@ -120,8 +133,38 @@ export type AxisConfigProps = Omit & { range?: Partial | ((bounds: AxisBounds) => AxisBounds); }; +const includeBaselineInBounds = (bounds: AxisBounds, baseline: number): AxisBounds => { + if (baseline < bounds.min) return { ...bounds, min: baseline }; + if (baseline > bounds.max) return { ...bounds, max: baseline }; + return bounds; +}; + +export const withBaselineDomain = ( + domain: CartesianAxisConfigProps['domain'], + baseline: number = 0, +): CartesianAxisConfigProps['domain'] => { + if (typeof domain === 'function') return domain; + if (domain?.min !== undefined && domain?.max !== undefined) return domain; + + const hasExplicitMin = domain?.min !== undefined; + const hasExplicitMax = domain?.max !== undefined; + + return (bounds: AxisBounds): AxisBounds => { + const resolvedBounds: AxisBounds = { + min: hasExplicitMin ? (domain?.min as number) : bounds.min, + max: hasExplicitMax ? (domain?.max as number) : bounds.max, + }; + const baselineAdjustedBounds = includeBaselineInBounds(resolvedBounds, baseline); + + return { + min: hasExplicitMin ? resolvedBounds.min : baselineAdjustedBounds.min, + max: hasExplicitMax ? resolvedBounds.max : baselineAdjustedBounds.max, + }; + }; +}; + /** - * Gets a D3 scale based on the axis configuration. + * Gets a D3 scale based on the cartesian axis configuration. * Handles both numeric (linear/log) and categorical (band) scales. * * For numeric scales, the domain limit controls whether bounds are "nice" (human-friendly) @@ -131,23 +174,33 @@ export type AxisConfigProps = Omit & { * @returns The D3 scale function * @throws An Error if bounds are invalid */ -export const getAxisScale = ({ +export const getCartesianAxisScale = ({ config, type, range, dataDomain, + layout = 'vertical', }: { - config?: AxisConfig; + config?: CartesianAxisConfig; type: 'x' | 'y'; range: AxisBounds; dataDomain: AxisBounds; + layout?: CartesianChartLayout; }): ChartScaleFunction => { const scaleType = config?.scaleType ?? 'linear'; let adjustedRange = range; - // Invert range for Y axis for SVG coordinate system - if (type === 'y') { + // Determine if this axis needs range inversion for SVG coordinate system. + // SVG Y coordinates increase downward, so we need to invert for value axes + // where we want higher values at the top. + // + // For vertical layout: Y axis is the value axis -> invert (higher values at top) + // For horizontal layout: Y axis is the category axis -> don't invert (first category at top is natural) + // X axis never needs inversion (left-to-right is natural for both layouts) + const shouldInvertRange = type === 'y' && layout !== 'horizontal'; + + if (shouldInvertRange) { adjustedRange = { min: adjustedRange.max, max: adjustedRange.min }; } @@ -162,7 +215,7 @@ export const getAxisScale = ({ if (!isValidBounds(adjustedDomain)) throw new Error( - 'Invalid domain bounds. See https://cds.coinbase.com/http://localhost:3000/components/graphs/XAxis/#domain', + 'Invalid domain bounds. See https://cds.coinbase.com/components/charts/XAxis/#domain', ); if (scaleType === 'band') { @@ -197,11 +250,16 @@ export const getAxisScale = ({ */ export const getAxisConfig = ( type: 'x' | 'y', - axes: Partial | Partial[] | undefined, + axes: Partial | Partial[] | undefined, defaultId: string = defaultAxisId, defaultScaleType: ChartAxisScaleType = defaultAxisScaleType, -): AxisConfigProps[] => { +): CartesianAxisConfigProps[] => { const defaultDomainLimit = type === 'x' ? 'strict' : 'nice'; + const axisName = type === 'x' ? 'x-axis' : 'y-axis'; + const axisDocUrl = + type === 'x' + ? 'https://cds.coinbase.com/components/charts/XAxis' + : 'https://cds.coinbase.com/components/charts/YAxis'; if (!axes) { return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit }]; } @@ -211,21 +269,37 @@ export const getAxisConfig = ( // forces id to be defined on every input config when there are multiple axes if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { throw new Error( - 'When defining multiple axes, each must have a unique id. See https://cds.coinbase.com/components/graphs/YAxis/#multiple-y-axes.', + `When defining multiple ${axisName}, each must have a unique id. See ${axisDocUrl}.`, ); } + if (axesLength > 1) { + const ids = axes.map(({ id }) => id).filter((id): id is string => id !== undefined); + if (new Set(ids).size !== ids.length) { + throw new Error( + `When defining multiple ${axisName}, each must have a unique id. See ${axisDocUrl}.`, + ); + } + } + return axes.map(({ id, ...axis }) => ({ // defaults the axis id if only a single axis is provided - id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + id: axesLength > 1 ? (id ?? defaultAxisId) : (id ?? defaultId), scaleType: defaultScaleType, domainLimit: defaultDomainLimit, ...axis, - })); + })) as CartesianAxisConfigProps[]; } // Single axis config - return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit, ...axes }]; + return [ + { + id: defaultId, + scaleType: defaultScaleType, + domainLimit: defaultDomainLimit, + ...axes, + } as CartesianAxisConfigProps, + ]; }; /** @@ -235,12 +309,14 @@ export const getAxisConfig = ( * @param axisParam - The axis configuration * @param series - Array of series objects (for stacking support) * @param axisType - Whether this is an 'x' or 'y' axis + * @param layout - The chart layout orientation * @returns The calculated axis bounds */ -export const getAxisDomain = ( - axisParam: AxisConfigProps, +export const getCartesianAxisDomain = ( + axisParam: CartesianAxisConfigProps, series: Series[], axisType: 'x' | 'y', + layout: CartesianChartLayout = 'vertical', ): AxisBounds => { let dataDomain: AxisBounds | null = null; if (axisParam.data && Array.isArray(axisParam.data) && axisParam.data.length > 0) { @@ -264,7 +340,18 @@ export const getAxisDomain = ( } // Calculate domain from series data - const seriesDomain = axisType === 'x' ? getChartDomain(series) : getChartRange(series); + // In vertical layout: X is category (index), Y is value (value) + // In horizontal layout: Y is category (index), X is value (value) + const isCategoryAxis = + (layout !== 'horizontal' && axisType === 'x') || (layout === 'horizontal' && axisType === 'y'); + const seriesDomain = isCategoryAxis + ? getChartDomain(series) + : getChartRange( + series, + layout, + axisType === 'x' ? [axisParam] : [], + axisType === 'y' ? [axisParam] : [], + ); // If data sets the domain, use that instead of the series domain const preferredDataDomain = dataDomain ?? seriesDomain; @@ -290,7 +377,6 @@ export const getAxisDomain = ( finalDomain = preferredDataDomain; } - // Ensure we always return valid bounds with no undefined values return { min: finalDomain.min ?? 0, max: finalDomain.max ?? 0, @@ -307,7 +393,7 @@ export const getAxisDomain = ( * @returns The calculated axis range bounds */ export const getAxisRange = ( - axisParam: AxisConfigProps, + axisParam: CartesianAxisConfigProps, chartRect: Rect, axisType: 'x' | 'y', ): AxisBounds => { diff --git a/packages/mobile-visualization/src/chart/utils/bar.ts b/packages/mobile-visualization/src/chart/utils/bar.ts index c30de9154e..bc69979e21 100644 --- a/packages/mobile-visualization/src/chart/utils/bar.ts +++ b/packages/mobile-visualization/src/chart/utils/bar.ts @@ -1,3 +1,96 @@ +import type { Rect } from '@coinbase/cds-common/types'; + +import type { BarBaseProps, BarComponent } from '../bar/Bar'; +import type { BarSeries } from '../bar/BarStack'; + +import { defaultAxisId as fallbackAxisId } from './axis'; +import type { Series } from './chart'; +import type { CartesianChartLayout } from './context'; +import type { GradientDefinition, GradientStop } from './gradient'; +import { evaluateGradientAtValue } from './gradient'; +import type { ChartScaleFunction, SerializableScale } from './scale'; +import { defaultTransition, type Transition } from './transition'; + +/** + * A bar-specific transition that extends Transition with stagger support. + * When `staggerDelay` is provided, bars will animate with increasing delays + * based on their position along the category axis (vertical: left-to-right, + * horizontal: top-to-bottom). + * + * @example + * // Bars stagger in from left to right over 250ms, each animating for 750ms + * { type: 'timing', duration: 750, staggerDelay: 250 } + */ +export type BarTransition = Transition & { + /** + * Maximum stagger delay (ms) distributed across bars by x position. + * Leftmost bar starts immediately, rightmost starts after this delay. + */ + staggerDelay?: number; +}; + +/** + * Computes a bar's normalized [0, 1] position along the category axis, used for + * stagger-delay calculations. + * + * Vertical charts stagger left-to-right (x axis); horizontal charts stagger + * top-to-bottom (y axis). Returns 0 when the drawing area has no extent. + * + * @param layout - The layout of the chart + * @param x - Bar's left edge in pixels + * @param y - Bar's top edge in pixels + */ +export const getNormalizedStagger = ( + layout: CartesianChartLayout, + x: number, + y: number, + drawingArea: Rect, +): number => { + if (layout === 'horizontal') { + return drawingArea.height > 0 ? (y - drawingArea.y) / drawingArea.height : 0; + } + return drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0; +}; + +/** + * Strips `staggerDelay` from a transition and computes a positional delay. + * + * @param transition - The transition config (may include staggerDelay) + * @param normalizedPosition - The bar's normalized position along the category axis (0–1) + * @returns A standard Transition with computed delay + */ +export const withStaggerDelayTransition = ( + transition: BarTransition | null, + normalizedPosition: number, +): Transition | null => { + if (!transition) return null; + const { staggerDelay, ...baseTransition } = transition; + if (!staggerDelay) return transition; + return { + ...baseTransition, + delay: (baseTransition?.delay ?? 0) + normalizedPosition * staggerDelay, + }; +}; + +/** + * Default bar enter transition. Uses the default spring with a stagger delay + * so bars spring into place from left to right. + * `{ type: 'spring', stiffness: 900, damping: 120, staggerDelay: 250 }` + */ +export const defaultBarEnterTransition: BarTransition = { + ...defaultTransition, + staggerDelay: 250, +}; + +/** + * Default bar enter opacity transition. + * `{ type: 'timing', duration: 200 }` + */ +export const defaultBarEnterOpacityTransition: BarTransition = { + type: 'timing', + duration: 200, +}; + /** * Calculates the size adjustment needed for bars when accounting for gaps between them. * This function helps determine how much to reduce each bar's width to accommodate @@ -23,3 +116,975 @@ export function getBarSizeAdjustment(barCount: number, gapSize: number): number return (gapSize * (barCount - 1)) / barCount; } + +type StackGroup = { + stackId: string; + series: BarSeries[]; + xAxisId?: string; + yAxisId?: string; +}; + +/** + * Groups bar series into stack groups scoped by stackId + axis IDs. + * + * Series with no `stackId` are treated as independent stacks keyed by series id. + * Axis IDs are included in the group key so series on different axes never stack together. + */ +export function getStackGroups( + series: BarSeries[], + defaultAxisId: string = fallbackAxisId, +): StackGroup[] { + const groups: Record = {}; + + series.forEach((entry) => { + const xAxisId = entry.xAxisId ?? defaultAxisId; + const yAxisId = entry.yAxisId ?? defaultAxisId; + const stackId = entry.stackId || `individual-${entry.id}`; + const stackKey = `${stackId}:${xAxisId}:${yAxisId}`; + + if (!groups[stackKey]) { + groups[stackKey] = { + stackId: stackKey, + series: [], + xAxisId: entry.xAxisId, + yAxisId: entry.yAxisId, + }; + } + + groups[stackKey].series.push(entry); + }); + + return Object.values(groups); +} + +type BarData = BarBaseProps & { + /** The ID of the series this bar belongs to. */ + seriesId: string; + /** Coordinate of the baseline/origin for animations. */ + origin: number; + /** Position along the value axis in pixels (axis-agnostic, used by layout helpers). */ + valuePos: number; + /** Size along the value axis in pixels (axis-agnostic, used by layout helpers). */ + length: number; + /** The raw data value as [baseline, value], used by layout helpers for gap/rounding logic. */ + dataValue: [number, number]; + /** Whether gap distribution should be applied to this bar in a stack. */ + shouldApplyGap?: boolean; +}; + +/** + * Applies proportional gap distribution to a stack of bars, maintaining total stack length. + * Gaps are only inserted between bars that have `shouldApplyGap = true`. + * Positive (above-baseline) and negative (below-baseline) groups are gapped independently. + * + * @param bars - Array of bar items with current valuePos and length + * @param stackGap - Gap size in pixels between adjacent bars + * @param layout - The layout of the chart + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @returns New array of bars with adjusted valuePos and length + */ +function applyStackGap( + bars: BarData[], + stackGap: number, + layout: CartesianChartLayout, + baseline: number, + baselinePx: number, +): BarData[] { + if (!stackGap || bars.length <= 1) return bars; + + const result = [...bars]; + + const barsAboveBaseline = bars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return bottom >= baseline && top !== bottom && bar.shouldApplyGap; + }); + const barsBelowBaseline = bars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return top <= baseline && bottom !== top && bar.shouldApplyGap; + }); + + const applyGapGroup = (group: BarData[], growing: boolean) => { + if (group.length <= 1) return; + + const totalGapSpace = stackGap * (group.length - 1); + const totalDataLength = group.reduce((sum, bar) => sum + bar.length, 0); + const lengthReduction = totalGapSpace / totalDataLength; + + const sortedBars = growing + ? [...group].sort((a, b) => b.valuePos - a.valuePos) + : [...group].sort((a, b) => a.valuePos - b.valuePos); + + let currentEdge = baselinePx; + sortedBars.forEach((bar, index) => { + const newLength = bar.length * (1 - lengthReduction); + let newValuePos: number; + + if (growing) { + newValuePos = currentEdge - newLength; + currentEdge = newValuePos - (index < sortedBars.length - 1 ? stackGap : 0); + } else { + newValuePos = currentEdge; + currentEdge = newValuePos + newLength + (index < sortedBars.length - 1 ? stackGap : 0); + } + + const barIndex = result.findIndex((b) => b.seriesId === bar.seriesId); + if (barIndex !== -1) { + result[barIndex] = { ...result[barIndex], length: newLength, valuePos: newValuePos }; + } + }); + }; + + // Positive bars: grow up in vertical (decreasing Y), grow right in horizontal (increasing X) + applyGapGroup(barsAboveBaseline, layout === 'vertical'); + // Negative bars: grow down in vertical (increasing Y), grow left in horizontal (decreasing X) + applyGapGroup(barsBelowBaseline, layout !== 'vertical'); + + return result; +} + +/** + * Computes per-bar initial animation origin positions for bar entrance animations. + * + * Bars are stacked from the baseline in their respective directions so they start at + * distinct, non-overlapping positions with the gap already applied: + * - Positive bars: stack rightward (horizontal) / upward (vertical) from the baseline. + * - Negative bars: stack leftward (horizontal) / downward (vertical) from the baseline. + * + * The bar closest to the baseline always gets index 0 and starts exactly at the baseline. + * + * @param bars - Array of bar items with final valuePos, length, and dataValue + * @param initialBarMinSizes - Per-bar initial sizes in pixels for entrance animation + * @param stackGap - Gap between adjacent bars in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @param layout - The layout of the chart + * @returns Array of origin positions (one per bar, parallel to input), all defaulting to baselinePx + */ +function getBarOrigins( + bars: BarData[], + initialBarMinSizes: number[], + stackGap: number, + baseline: number, + baselinePx: number, + layout: CartesianChartLayout, +): number[] { + const result = bars.map(() => baselinePx); + if (bars.length === 0 || initialBarMinSizes.every((size) => !size)) return result; + + const isPositive = (bar: BarData) => { + const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b); + return lo >= baseline && hi !== lo; + }; + + const isNegative = (bar: BarData) => { + const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b); + return hi <= baseline && hi !== lo; + }; + + const positiveBars = bars + .map((bar, i) => ({ bar, i })) + .filter(({ bar }) => isPositive(bar)) + .sort((a, b) => + layout === 'vertical' ? b.bar.valuePos - a.bar.valuePos : a.bar.valuePos - b.bar.valuePos, + ); + + if (layout === 'vertical') { + let currentPositive = baselinePx; + positiveBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + currentPositive -= initialSize; + result[i] = currentPositive; + if (idx < positiveBars.length - 1) { + currentPositive -= stackGap; + } + }); + } else { + let currentPositive = baselinePx; + positiveBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + result[i] = currentPositive; + currentPositive += initialSize; + if (idx < positiveBars.length - 1) { + currentPositive += stackGap; + } + }); + } + + const negativeBars = bars + .map((bar, i) => ({ bar, i })) + .filter(({ bar }) => isNegative(bar)) + .sort((a, b) => + layout === 'vertical' + ? a.bar.valuePos - b.bar.valuePos + : b.bar.valuePos + b.bar.length - (a.bar.valuePos + a.bar.length), + ); + + if (layout === 'vertical') { + let currentNegative = baselinePx; + negativeBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + result[i] = currentNegative; + currentNegative += initialSize; + if (idx < negativeBars.length - 1) { + currentNegative += stackGap; + } + }); + } else { + let currentNegative = baselinePx; + negativeBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + currentNegative -= initialSize; + result[i] = currentNegative; + if (idx < negativeBars.length - 1) { + currentNegative -= stackGap; + } + }); + } + + return result; +} + +/** + * Computes stack clip origin [start, end] that covers the bounding box + * of all bars at their stacked starting positions (as computed by `getBarOrigins`). + * + * This is passed to `DefaultBarStack` so the clip animation starts in sync with the + * individual bar animations — no bars leak outside the clip on frame 0. + * + * @param barOrigins - Per-bar initial origins from `getBarOrigins` + * @param barMinSizes - Per-bar minimum sizes in pixels (or a uniform value) + * @returns [originStart, originEnd] or undefined when barMinSize is 0 / no bars + */ +export function getStackOrigin( + barOrigins: number[], + barMinSizes: number[] | number, +): [number, number] | undefined { + if (barOrigins.length === 0) return undefined; + const minSizes = Array.isArray(barMinSizes) ? barMinSizes : barOrigins.map(() => barMinSizes); + + let rangeStart = Number.POSITIVE_INFINITY; + let rangeEnd = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < barOrigins.length; i++) { + const minSize = minSizes[i] ?? 0; + if (minSize <= 0) continue; + + const barStart = barOrigins[i]; + const barEnd = barStart + minSize; + rangeStart = Math.min(rangeStart, barStart, barEnd); + rangeEnd = Math.max(rangeEnd, barStart, barEnd); + } + + if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd)) return undefined; + return [rangeStart, rangeEnd]; +} + +function getInitialBarMinSizes( + bars: BarData[], + barMinSize: number | undefined, + stackMinSize: number | undefined, +): number[] { + const perBarMinFromBarMinSize = barMinSize ?? 0; + if (bars.length === 0) return []; + if (!stackMinSize) { + return bars.map(() => perBarMinFromBarMinSize); + } + + const totalBarLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const perBarMinFromStack = totalBarLength + ? bars.map((bar) => (stackMinSize * bar.length) / totalBarLength) + : bars.map(() => stackMinSize / bars.length); + + return perBarMinFromStack.map((stackMin) => Math.max(perBarMinFromBarMinSize, stackMin)); +} + +/** + * Computes the initial clip rect used for stack enter animations. + */ +export function getStackInitialClipRect( + stackRect: Rect, + layout: CartesianChartLayout, + origin?: number | [number, number], +): Rect { + const { x, y, width, height } = stackRect; + + if (Array.isArray(origin)) { + const [originStart, originEnd] = origin; + if (layout === 'vertical') { + return { x, y: originStart, width, height: originEnd - originStart }; + } + return { x: originStart, y, width: originEnd - originStart, height }; + } + + const initialSize = 1; + if (layout === 'vertical') { + const valueBaseline = origin ?? y + height; + return { x, y: valueBaseline, width, height: initialSize }; + } + + const valueBaseline = origin ?? x; + return { x: valueBaseline, y, width: initialSize, height }; +} + +/** + * Expands bars that are shorter than `barMinSize` to the minimum size. + * Non-expanded bars are scaled down proportionally to keep the total bar length constant, + * preventing stacked bars from overflowing the chart area. + * + * Bars are then repositioned from the baseline, preserving original gaps between them. + * + * @param bars - Array of bar items with current valuePos and length + * @param barMinSize - Minimum bar size in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @param layout - Chart layout + * @returns New array of bars with adjusted valuePos and length + */ +function applyBarMinSize( + bars: BarData[], + barMinSize: number, + baseline: number, + baselinePx: number, + layout: CartesianChartLayout, +): BarData[] { + if (!barMinSize || bars.length === 0) return bars; + + const originalTotalLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const needsExpansion = bars.map((bar) => bar.length < barMinSize); + const expandedTotalLength = bars.reduce( + (sum, bar, i) => sum + (needsExpansion[i] ? barMinSize : bar.length), + 0, + ); + + let finalLengths: number[]; + if (expandedTotalLength > originalTotalLength) { + // Scale down non-expanded bars to keep total bar length constant + const spaceForExpanded = needsExpansion.filter(Boolean).length * barMinSize; + const spaceForNonExpanded = Math.max(0, originalTotalLength - spaceForExpanded); + const nonExpandedOrigTotal = bars.reduce( + (sum, bar, i) => (!needsExpansion[i] ? sum + bar.length : sum), + 0, + ); + const scaleFactor = nonExpandedOrigTotal > 0 ? spaceForNonExpanded / nonExpandedOrigTotal : 0; + finalLengths = bars.map((bar, i) => + needsExpansion[i] ? barMinSize : bar.length * scaleFactor, + ); + } else { + finalLengths = bars.map((bar, i) => (needsExpansion[i] ? barMinSize : bar.length)); + } + + const expandedBars = bars.map((bar, i) => ({ + ...bar, + length: finalLengths[i], + })); + + const newPositions = new Map(); + + // Range bars (shouldApplyGap=false) float at data-defined coordinates independent of the + // baseline. Restacking them from the zero baseline would place them off-screen when the + // y-axis domain doesn't include 0 (e.g., a price chart with domain [28000, 37000]). + // Instead, expand them in-place, centered on their original midpoint. + for (let i = 0; i < bars.length; i++) { + if (bars[i].shouldApplyGap === false) { + const originalMid = bars[i].valuePos + bars[i].length / 2; + newPositions.set(bars[i].seriesId, { + valuePos: originalMid - expandedBars[i].length / 2, + length: expandedBars[i].length, + }); + } + } + + // Stacked bars (shouldApplyGap=true/undefined): classify by which side of the baseline + // they're on and restack from the baseline outward. + const stackedSortedBars = [...expandedBars] + .filter((bar) => bar.shouldApplyGap !== false) + .sort((a, b) => a.valuePos - b.valuePos); + + if (stackedSortedBars.length > 0) { + // Classify using dataValue to correctly identify which side of the baseline each bar is on, + // independent of the current valuePos (which hasn't been repositioned yet). + const barsAboveBaseline = stackedSortedBars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return bottom >= baseline && top !== bottom; + }); + const barsBelowBaseline = stackedSortedBars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return top <= baseline && bottom !== top; + }); + + // Restack bars above baseline (positive data side). + // vertical → grow up (−Y from baseline); horizontal → grow right (+X from baseline). + if (layout === 'vertical') { + let currentAbove = baselinePx; + for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { + const bar = barsAboveBaseline[i]; + const newValuePos = currentAbove - bar.length; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: bar.length }); + if (i > 0) { + const nextBar = barsAboveBaseline[i - 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalCurrent.valuePos - (originalNext.valuePos + originalNext.length); + currentAbove = newValuePos - originalGap; + } + } + } else { + let currentEdge = baselinePx; + for (let i = 0; i < barsAboveBaseline.length; i++) { + const bar = barsAboveBaseline[i]; + newPositions.set(bar.seriesId, { valuePos: currentEdge, length: bar.length }); + if (i < barsAboveBaseline.length - 1) { + const nextBar = barsAboveBaseline[i + 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length); + currentEdge = currentEdge + bar.length + originalGap; + } + } + } + + // Restack bars below baseline (negative data side). + // vertical → grow down (+Y); horizontal → grow left (−X). + if (layout === 'vertical') { + let currentBelow = baselinePx; + for (let i = 0; i < barsBelowBaseline.length; i++) { + const bar = barsBelowBaseline[i]; + newPositions.set(bar.seriesId, { valuePos: currentBelow, length: bar.length }); + if (i < barsBelowBaseline.length - 1) { + const nextBar = barsBelowBaseline[i + 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length); + currentBelow = currentBelow + bar.length + originalGap; + } + } + } else { + const sortedBelow = [...barsBelowBaseline].sort((a, b) => b.valuePos - a.valuePos); + let currentEdge = baselinePx; + for (let i = sortedBelow.length - 1; i >= 0; i--) { + const bar = sortedBelow[i]; + const newValuePos = currentEdge - bar.length; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: bar.length }); + if (i > 0) { + const nextBar = sortedBelow[i - 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalCurrent.valuePos - (originalNext.valuePos + originalNext.length); + currentEdge = newValuePos - originalGap; + } + } + } + } + + return expandedBars.map((bar) => { + const newPos = newPositions.get(bar.seriesId); + if (newPos) return { ...bar, valuePos: newPos.valuePos, length: newPos.length }; + return bar; + }); +} + +/** + * Scales a stack of bars up so the total stack extent meets `stackMinSize`. + * For a single bar, the bar is expanded away from the baseline. + * For multiple bars, all bars are scaled proportionally, preserving relative gaps. + * + * @param bars - Array of bar items with current valuePos and length + * @param stackMinSize - Minimum stack size in pixels + * @param stackSize - Current total pixel extent of the stack + * @param stackBounds - Current bounding rect of the stack + * @param layout - The layout of the chart + * @param indexPos - Pixel position along the categorical (index) axis + * @param thickness - Bar thickness in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @returns Updated bars and stackBounds; unchanged if stackSize >= stackMinSize + */ +function applyStackMinSize( + bars: BarData[], + stackMinSize: number, + stackSize: number, + stackBounds: Rect, + layout: CartesianChartLayout, + indexPos: number, + thickness: number, + baseline: number, + baselinePx: number, +): { bars: BarData[]; stackBounds: Rect } { + if (!stackMinSize || stackSize >= stackMinSize) return { bars, stackBounds }; + if (bars.length === 0) return { bars, stackBounds }; + + let updatedBars = [...bars]; + let updatedBounds = { ...stackBounds }; + + if (bars.length === 1) { + const bar = bars[0]; + const sizeIncrease = stackMinSize - bar.length; + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + + let newValuePos: number; + const newLength = stackMinSize; + + if (bottom >= baseline && top !== bottom) { + // Bar is on the positive side: vertical→expands upward (↑), horizontal→expands rightward (→) + newValuePos = layout === 'vertical' ? bar.valuePos - sizeIncrease : bar.valuePos; + } else if (top <= baseline && top !== bottom) { + // Bar is on the negative side: vertical→expands downward (↓), horizontal→expands leftward (←) + newValuePos = layout === 'vertical' ? bar.valuePos : bar.valuePos - sizeIncrease; + } else { + // Bar spans baseline or is zero: expand equally in both directions + newValuePos = bar.valuePos - sizeIncrease / 2; + } + + updatedBars = [{ ...bar, valuePos: newValuePos, length: newLength }]; + updatedBounds = { + x: layout === 'vertical' ? indexPos : newValuePos, + y: layout === 'vertical' ? newValuePos : indexPos, + width: layout === 'vertical' ? thickness : newLength, + height: layout === 'vertical' ? newLength : thickness, + }; + } else { + const totalBarLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const totalGapLength = stackSize - totalBarLength; + const requiredBarLength = stackMinSize - totalGapLength; + const barScaleFactor = requiredBarLength / totalBarLength; + + const sortedBars = [...bars].sort((a, b) => a.valuePos - b.valuePos); + + // For vertical: positive bars are above baseline (smaller Y), negative bars are below (larger Y) + // For horizontal: positive bars are right of baseline (larger X), negative bars are left (smaller X) + const barsOnPositiveSide = + layout === 'vertical' + ? sortedBars.filter((bar) => bar.valuePos + bar.length <= baselinePx) + : sortedBars.filter((bar) => bar.valuePos >= baselinePx); + const barsOnNegativeSide = + layout === 'vertical' + ? sortedBars.filter((bar) => bar.valuePos >= baselinePx) + : sortedBars.filter((bar) => bar.valuePos + bar.length <= baselinePx); + + const newPositions = new Map(); + + if (layout === 'vertical') { + // Stack from baseline upward (decreasing valuePos) for positive bars + let currentPos = baselinePx; + for (let i = barsOnPositiveSide.length - 1; i >= 0; i--) { + const bar = barsOnPositiveSide[i]; + const newLength = bar.length * barScaleFactor; + const newValuePos = currentPos - newLength; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: newLength }); + if (i > 0) { + const nextBar = barsOnPositiveSide[i - 1]; + const originalGap = bar.valuePos - (nextBar.valuePos + nextBar.length); + currentPos = newValuePos - originalGap; + } + } + // Stack from baseline downward (increasing valuePos) for negative bars + let currentPosBelow = baselinePx; + for (let i = 0; i < barsOnNegativeSide.length; i++) { + const bar = barsOnNegativeSide[i]; + const newLength = bar.length * barScaleFactor; + newPositions.set(bar.seriesId, { valuePos: currentPosBelow, length: newLength }); + if (i < barsOnNegativeSide.length - 1) { + const nextBar = barsOnNegativeSide[i + 1]; + const originalGap = nextBar.valuePos - (bar.valuePos + bar.length); + currentPosBelow = currentPosBelow + newLength + originalGap; + } + } + } else { + // Stack from baseline rightward (increasing valuePos) for positive bars + let currentPos = baselinePx; + for (let i = 0; i < barsOnPositiveSide.length; i++) { + const bar = barsOnPositiveSide[i]; + const newLength = bar.length * barScaleFactor; + newPositions.set(bar.seriesId, { valuePos: currentPos, length: newLength }); + if (i < barsOnPositiveSide.length - 1) { + const nextBar = barsOnPositiveSide[i + 1]; + const originalGap = nextBar.valuePos - (bar.valuePos + bar.length); + currentPos = currentPos + newLength + originalGap; + } + } + // Stack from baseline leftward (decreasing valuePos) for negative bars + let currentPosLeft = baselinePx; + for (let i = barsOnNegativeSide.length - 1; i >= 0; i--) { + const bar = barsOnNegativeSide[i]; + const newLength = bar.length * barScaleFactor; + const newValuePos = currentPosLeft - newLength; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: newLength }); + if (i > 0) { + const nextBar = barsOnNegativeSide[i - 1]; + const originalGap = bar.valuePos - (nextBar.valuePos + nextBar.length); + currentPosLeft = newValuePos - originalGap; + } + } + } + + updatedBars = bars.map((bar) => { + const newPos = newPositions.get(bar.seriesId); + if (!newPos) return bar; + return { ...bar, length: newPos.length, valuePos: newPos.valuePos }; + }); + + const newMinValuePos = Math.min(...updatedBars.map((bar) => bar.valuePos)); + const newMaxValuePos = Math.max(...updatedBars.map((bar) => bar.valuePos + bar.length)); + + updatedBounds = { + x: layout === 'vertical' ? indexPos : newMinValuePos, + y: layout === 'vertical' ? newMinValuePos : indexPos, + width: layout === 'vertical' ? thickness : newMaxValuePos - newMinValuePos, + height: layout === 'vertical' ? newMaxValuePos - newMinValuePos : thickness, + }; + } + + return { bars: updatedBars, stackBounds: updatedBounds }; +} + +/** + * Applies border-radius flags to a sorted stack of bars. + * + * Faces at the outer edges of the stack remain rounded; faces where two bars + * touch internally are squared. When `stackGap` is non-zero every face keeps + * its rounded corner because all bars are visually separated. + * + * @param bars - Bars with `roundTop`/`roundBottom` flags and position data + * @param layout - The layout of the chart + * @param stackGap - Pixel gap between adjacent bars (non-zero ⇒ all faces stay rounded) + * @returns New array of bars with corrected `roundTop`/`roundBottom` flags + */ +function applyBorderRadiusLogic( + bars: BarData[], + layout: CartesianChartLayout, + stackGap: number | undefined, +): BarData[] { + if (bars.length === 0) return bars; + + // Sort from "lower coordinate" face to "higher coordinate" face along the value axis: + // Vertical → descending valuePos (largest Y first = closest to baseline) + // Horizontal → ascending valuePos (smallest X first = closest to baseline) + const sortedBars = + layout === 'vertical' + ? [...bars].sort((a, b) => b.valuePos - a.valuePos) + : [...bars].sort((a, b) => a.valuePos - b.valuePos); + + return sortedBars.map((a, index) => { + const barBefore = index > 0 ? sortedBars[index - 1] : null; + const barAfter = index < sortedBars.length - 1 ? sortedBars[index + 1] : null; + + // shouldRoundLower: face with the smaller coordinate (top in vertical, left in horizontal) + const shouldRoundLower = + (layout === 'vertical' ? index === sortedBars.length - 1 : index === 0) || + Boolean(a.shouldApplyGap && stackGap) || + (!a.shouldApplyGap && + barAfter !== null && + barAfter.valuePos + barAfter.length !== a.valuePos); + + // shouldRoundHigher: face with the larger coordinate (bottom in vertical, right in horizontal) + const shouldRoundHigher = + (layout === 'vertical' ? index === 0 : index === sortedBars.length - 1) || + Boolean(a.shouldApplyGap && stackGap) || + (!a.shouldApplyGap && barBefore !== null && barBefore.valuePos !== a.valuePos + a.length); + + return { + ...a, + roundTop: Boolean( + a.roundTop && (layout === 'vertical' ? shouldRoundLower : shouldRoundHigher), + ), + roundBottom: Boolean( + a.roundBottom && (layout === 'vertical' ? shouldRoundHigher : shouldRoundLower), + ), + }; + }); +} + +/** + * Threshold for treating a position as touching the baseline. + * Positions within this distance are considered at the baseline for rounding purposes. + */ +export const EPSILON = 1e-4; + +/** + * Computes and clamps the value-axis baseline position in pixels. + * + * When `baseline` (data space) is omitted, the baseline is chosen heuristically from the scale domain: + * - If the full domain is positive, use domain min. + * - If the full domain is negative, use domain max. + * - If the domain crosses zero, use `0`. + * When `baseline` is set, that value is used as the data-space baseline instead. + * + * @param valueScale - Scale for the value axis + * @param stackRect - Bounding rect of the stack in pixels + * @param layout - Chart layout + * @param baseline - Optional value-axis baseline in data space + */ +export function getBaselinePx( + valueScale: ChartScaleFunction, + stackRect: Rect, + layout: CartesianChartLayout, + baseline?: number, +): number { + const [domainMin, domainMax] = valueScale.domain(); + const baselineInData = baseline ?? (domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0); + const baselinePos = valueScale(baselineInData); + + if (layout === 'vertical') { + return Math.max( + stackRect.y, + Math.min(baselinePos ?? stackRect.y + stackRect.height, stackRect.y + stackRect.height), + ); + } + + return Math.max(stackRect.x, Math.min(baselinePos ?? stackRect.x, stackRect.x + stackRect.width)); +} + +type SeriesGradientEntry = + | { + seriesId: string; + gradient: GradientDefinition; + scale: SerializableScale | ChartScaleFunction; + stops: GradientStop[]; + } + | undefined; + +function getStackBoundsForLayout( + layout: CartesianChartLayout, + indexPos: number, + thickness: number, + minValuePos: number, + stackSize: number, +): Rect { + if (layout === 'vertical') { + return { x: indexPos, y: minValuePos, width: thickness, height: stackSize }; + } + return { x: minValuePos, y: indexPos, width: stackSize, height: thickness }; +} + +function getStackSizeForLayout(layout: CartesianChartLayout, stackRect: Rect): number { + return layout === 'vertical' ? stackRect.height : stackRect.width; +} + +/** + * Computes the positioned bar entries and bounding rect for a single stack at one category index. + * + * This is the pure computation extracted from `BarStack`'s `useMemo` so it can be tested + * independently and reused across contexts. + * + * @param params.series - Series configs for this stack + * @param params.seriesData - Stacked data for each series, keyed by series id + * @param params.categoryIndex - Index of the category being rendered + * @param params.indexPos - Pixel position along the categorical axis + * @param params.thickness - Bar thickness in pixels + * @param params.valueScale - Scale function for the value axis + * @param params.seriesGradients - Precomputed gradient configs per series (undefined entries are skipped) + * @param params.roundBaseline - Whether to round the face touching the baseline + * @param params.layout - The layout of the chart + * @param params.baseline - Value-axis baseline in data space + * @param params.baselinePx - Pixel position of the value-axis baseline on the value axis + * @param params.stackGap - Gap between adjacent bars in pixels + * @param params.barMinSize - Minimum individual bar size in pixels + * @param params.stackMinSize - Minimum total stack size in pixels + * @param params.defaultFill - Fallback fill color when a series has no color or gradient + * @returns Positioned bar entries and the stack's bounding rect + */ +export function getBars(params: { + series: BarSeries[]; + seriesData: Record; + categoryIndex: number; + categoryValue: number; + indexPos: number; + thickness: number; + valueScale: ChartScaleFunction; + seriesGradients: SeriesGradientEntry[]; + roundBaseline?: boolean; + layout: CartesianChartLayout; + baseline?: number; + baselinePx: number; + stackGap?: number; + barMinSize?: number; + stackMinSize?: number; + defaultFill: string; + borderRadius?: number; + defaultFillOpacity?: number; + defaultStroke?: string; + defaultStrokeWidth?: number; + defaultBarComponent?: BarComponent; +}) { + const { + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline = 0, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + defaultFill, + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + } = params; + + let allBars: BarData[] = []; + + series.forEach((s) => { + const data = seriesData[s.id]; + if (!data) return; + + const value = data[categoryIndex]; + if (value === null || value === undefined) return; + + const originalData = s.data; + const originalValue = originalData?.[categoryIndex]; + const shouldApplyGap = !Array.isArray(originalValue); + + const [bottom, top] = [...value].sort((a, b) => a - b); + + const edgeBottom = valueScale(bottom) ?? baselinePx; + const edgeTop = valueScale(top) ?? baselinePx; + + const roundTop = + roundBaseline || + (Math.abs(top - baseline) >= EPSILON && Math.abs(edgeTop - baselinePx) >= EPSILON); + const roundBottom = + roundBaseline || + (Math.abs(bottom - baseline) >= EPSILON && Math.abs(edgeBottom - baselinePx) >= EPSILON); + + const length = Math.abs(edgeBottom - edgeTop); + const valuePos = Math.min(edgeBottom, edgeTop); + + if (length <= 0) return; + + let barFill = s.color || defaultFill; + + const seriesGradientConfig = seriesGradients.find((g) => g?.seriesId === s.id); + if (seriesGradientConfig && originalValue !== null && originalValue !== undefined) { + const axis = seriesGradientConfig.gradient.axis ?? 'y'; + + let evalValue: number; + if (axis === 'x') { + evalValue = + layout === 'vertical' + ? categoryIndex + : Array.isArray(originalValue) + ? originalValue[1] + : originalValue; + } else { + evalValue = + layout === 'vertical' + ? Array.isArray(originalValue) + ? originalValue[1] + : originalValue + : categoryIndex; + } + + const evaluatedColor = evaluateGradientAtValue( + seriesGradientConfig.stops, + evalValue, + seriesGradientConfig.scale, + ); + if (evaluatedColor) { + barFill = evaluatedColor; + } + } + + allBars.push({ + seriesId: s.id, + valuePos, + length, + dataValue: value, + fill: barFill, + roundTop, + roundBottom, + shouldApplyGap, + BarComponent: s.BarComponent, + x: 0, + y: 0, + width: 0, + height: 0, + origin: 0, + }); + }); + + // Apply proportional gap distribution to maintain total stack length + if (stackGap && allBars.length > 1) { + allBars = applyStackGap(allBars, stackGap, layout, baseline, baselinePx); + } + + // Apply barMinSize constraints + if (barMinSize) { + allBars = applyBarMinSize(allBars, barMinSize, baseline, baselinePx, layout); + } + + allBars = applyBorderRadiusLogic(allBars, layout, stackGap); + + // Apply stackMinSize constraints + if (stackMinSize && allBars.length > 0) { + const minValuePos = Math.min(...allBars.map((bar) => bar.valuePos)); + const maxValuePos = Math.max(...allBars.map((bar) => bar.valuePos + bar.length)); + const stackSize = maxValuePos - minValuePos; + const stackBounds = getStackBoundsForLayout( + layout, + indexPos, + thickness, + minValuePos, + stackSize, + ); + + const result = applyStackMinSize( + allBars, + stackMinSize, + stackSize, + stackBounds, + layout, + indexPos, + thickness, + baseline, + baselinePx, + ); + allBars = result.bars; + + // Reapply border radius logic only if we actually scaled + const newStackSize = getStackSizeForLayout(layout, result.stackBounds); + if (newStackSize < stackMinSize) { + allBars = applyBorderRadiusLogic(allBars, layout, stackGap); + } + } + + const initialBarMinSizes = getInitialBarMinSizes(allBars, barMinSize, stackMinSize); + const barOrigins = getBarOrigins( + allBars, + initialBarMinSizes, + stackGap ?? 0, + baseline, + baselinePx, + layout, + ); + + return allBars.map((bar, i) => ({ + ...bar, + x: layout === 'vertical' ? indexPos : bar.valuePos, + y: layout === 'vertical' ? bar.valuePos : indexPos, + width: layout === 'vertical' ? thickness : bar.length, + height: layout === 'vertical' ? bar.length : thickness, + dataX: layout === 'vertical' ? categoryValue : bar.dataValue, + dataY: layout === 'vertical' ? bar.dataValue : categoryValue, + origin: barOrigins[i], + borderRadius, + fillOpacity: defaultFillOpacity, + stroke: defaultStroke, + strokeWidth: defaultStrokeWidth, + minSize: initialBarMinSizes[i], + BarComponent: bar.BarComponent || defaultBarComponent, + })); +} diff --git a/packages/mobile-visualization/src/chart/utils/chart.ts b/packages/mobile-visualization/src/chart/utils/chart.ts index 3531aee9c7..af03e28fe1 100644 --- a/packages/mobile-visualization/src/chart/utils/chart.ts +++ b/packages/mobile-visualization/src/chart/utils/chart.ts @@ -2,10 +2,27 @@ import { isSharedValue } from 'react-native-reanimated'; import type { AnimatedProp } from '@shopify/react-native-skia'; import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import { type CartesianAxisConfigProps, defaultAxisId } from './axis'; +import type { CartesianChartLayout } from './context'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Shape variants available for legend items. + */ +export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill'; + +/** + * Shape for legend items. Can be a preset variant or a custom ReactNode. + */ +export type LegendShape = LegendShapeVariant | React.ReactNode; + +/** + * Position of the legend relative to the chart. + */ +export type LegendPosition = 'top' | 'bottom' | 'left' | 'right'; + export type AxisBounds = { min: number; max: number; @@ -48,9 +65,16 @@ export type Series = { * Takes precedence over color except for scrubber beacon labels. */ gradient?: GradientDefinition; + /** + * Id of the x-axis this series uses. + * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Id of the y-axis this series uses. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** @@ -59,6 +83,12 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; }; /** @@ -92,15 +122,34 @@ export const getChartDomain = ( }; /** - * Creates a composite stack key that includes both stack ID and y-axis ID. - * This ensures series with different y-scales don't get stacked together. + * Creates a composite stack key that includes stack ID and axis IDs. + * This ensures series with different scales don't get stacked together. */ const createStackKey = (series: Series): string | undefined => { if (series.stackId === undefined) return undefined; - // Include y-axis ID to prevent cross-scale stacking + // Include axis IDs to prevent cross-scale stacking + const xAxisId = series.xAxisId || 'default'; const yAxisId = series.yAxisId || 'default'; - return `${series.stackId}:${yAxisId}`; + return `${series.stackId}:${xAxisId}:${yAxisId}`; +}; + +/** + * Get the baseline for a series on the value axis for a series (stacking and plain numeric points). + * @returns The baseline for the series on the value axis, or `0` if none. + */ +const getValueAxisBaselineForSeries = ( + layout: CartesianChartLayout, + series: Series, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], +): number => { + if (layout === 'horizontal') { + const seriesAxisId = series.xAxisId ?? defaultAxisId; + return xAxisConfigs.find((a) => a.id === seriesAxisId)?.baseline ?? 0; + } + const seriesAxisId = series.yAxisId ?? defaultAxisId; + return yAxisConfigs.find((a) => a.id === seriesAxisId)?.baseline ?? 0; }; /** @@ -108,16 +157,38 @@ const createStackKey = (series: Series): string | undefined => { * Returns a map of series ID to transformed [baseline, value] tuples. * * @param series - Array of series with potential stack properties + * @param layout - When set with axis configs, value-axis baselines are resolved for stacking * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( series: Series[], + layout: CartesianChartLayout, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], ): Map> => { const stackedDataMap = new Map>(); const numericStackGroups = new Map(); const individualSeries: typeof series = []; + const normalizeSeriesData = (seriesItem: Series): Array<[number, number] | null> | undefined => { + if (!seriesItem.data) return; + + const baseline = getValueAxisBaselineForSeries(layout, seriesItem, xAxisConfigs, yAxisConfigs); + + return seriesItem.data.map((val) => { + if (val === null) return null; + + if (Array.isArray(val)) { + return val as [number, number]; + } + + if (typeof val === 'number') return [baseline, val]; + + return null; + }); + }; + series.forEach((s) => { const stackKey = createStackKey(s); const hasTupleData = s.data?.some((val) => Array.isArray(val)); @@ -133,37 +204,37 @@ export const getStackedSeriesData = ( }); individualSeries.forEach((s) => { - if (!s.data) return; - - const normalizedData: Array<[number, number] | null> = s.data.map((val) => { - if (val === null) return null; - - if (Array.isArray(val)) { - return val as [number, number]; - } - - if (typeof val === 'number') { - return [0, val]; - } - - return null; - }); - + const normalizedData = normalizeSeriesData(s); + if (!normalizedData) return; stackedDataMap.set(s.id, normalizedData); }); - numericStackGroups.forEach((groupSeries, stackKey) => { + numericStackGroups.forEach((groupSeries) => { + // A lone series with stackId should still behave like a non-stacked series. + if (groupSeries.length < 2) { + groupSeries.forEach((singleSeries) => { + const normalizedData = normalizeSeriesData(singleSeries); + if (!normalizedData) return; + stackedDataMap.set(singleSeries.id, normalizedData); + }); + return; + } + const maxLength = Math.max(...groupSeries.map((s) => s.data?.length || 0)); if (maxLength === 0) return; + const first = groupSeries[0]; + const groupBaseline = getValueAxisBaselineForSeries(layout, first, xAxisConfigs, yAxisConfigs); + const dataset: Array> = new Array(maxLength) .fill(undefined) .map((_, i) => { const row: Record = {}; for (const s of groupSeries) { const val = s.data?.[i]; - const num = typeof val === 'number' ? val : 0; + // Stack around baseline by translating values into baseline-relative deltas. + const num = typeof val === 'number' ? val - groupBaseline : 0; row[s.id] = num; } return row; @@ -178,8 +249,8 @@ export const getStackedSeriesData = ( stackedSeries.forEach((layer, layerIndex) => { const seriesId = keys[layerIndex]; const stackedData: Array<[number, number] | null> = layer.map(([bottom, top]) => [ - bottom, - top, + bottom + groupBaseline, + top + groupBaseline, ]); stackedDataMap.set(seriesId, stackedData); }); @@ -206,7 +277,7 @@ export const getLineData = ( if (Array.isArray(firstNonNull)) { return data.map((d) => { if (d === null) return null; - if (Array.isArray(d)) return d.at(-1) ?? null; + if (Array.isArray(d)) return d[d.length - 1] ?? null; return d as number; }); } @@ -222,6 +293,9 @@ export const getLineData = ( */ export const getChartRange = ( series: Series[], + layout: CartesianChartLayout, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], min?: number, max?: number, ): Partial => { @@ -253,11 +327,11 @@ export const getChartRange = ( if (hasStacks) { // Get stacked data using the shared function - const stackedDataMap = getStackedSeriesData(series); + const stackedDataMap = getStackedSeriesData(series, layout, xAxisConfigs, yAxisConfigs); // Find the extreme values from the stacked data - let stackedMax = 0; - let stackedMin = 0; + let stackedMax = -Infinity; + let stackedMin = Infinity; stackedDataMap.forEach((stackedData) => { stackedData.forEach((point) => { @@ -270,8 +344,8 @@ export const getChartRange = ( }); // Don't add padding - let D3's nice() function handle axis padding - if (range.min === undefined) range.min = Math.min(0, stackedMin); - if (range.max === undefined) range.max = Math.max(0, stackedMax); + if (range.min === undefined) range.min = stackedMin === Infinity ? 0 : stackedMin; + if (range.max === undefined) range.max = stackedMax === -Infinity ? 0 : stackedMax; } else { // No stacking, calculate range from raw values const allValues: number[] = []; @@ -308,13 +382,27 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultVerticalLayoutChartInset: ChartInset = { top: 32, left: 16, bottom: 16, right: 16, }; +export const defaultHorizontalLayoutChartInset: ChartInset = { + top: 16, + left: 16, + bottom: 16, + right: 48, +}; + +/** + * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts or. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + * `defaultHorizontalLayoutChartInset` for horizontal layout charts. + */ +export const defaultChartInset: ChartInset = defaultVerticalLayoutChartInset; + /** * Normalize padding to include all sides with a value. * @param padding - The padding to get. diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts index d6372574be..dbc1bdf92a 100644 --- a/packages/mobile-visualization/src/chart/utils/context.ts +++ b/packages/mobile-visualization/src/chart/utils/context.ts @@ -3,15 +3,28 @@ import type { SharedValue } from 'react-native-reanimated'; import type { Rect } from '@coinbase/cds-common/types'; import type { SkTypefaceFontProvider } from '@shopify/react-native-skia'; -import type { AxisConfig } from './axis'; +import type { CartesianAxisConfig } from './axis'; import type { Series } from './chart'; import type { ChartScaleFunction, SerializableScale } from './scale'; +/** + * Chart layout for Cartesian charts. + * Describes the direction bars/areas grow. + * - 'vertical': Bars grow vertically (up/down). X is category axis, Y is value axis. + * - 'horizontal': Bars grow horizontally (left/right). Y is category axis, X is value axis. + */ +export type CartesianChartLayout = 'horizontal' | 'vertical'; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ export type CartesianChartContextValue = { + /** + * Chart layout - describes the direction bars/areas grow. + * @default 'vertical' + */ + layout: CartesianChartLayout; /** * The series data for the chart. */ @@ -49,27 +62,30 @@ export type CartesianChartContextValue = { */ fontProvider: SkTypefaceFontProvider; /** - * Get x-axis configuration. + * Get x-axis configuration by ID. + * @param id - The axis ID. Defaults to defaultAxisId. */ - getXAxis: () => AxisConfig | undefined; + getXAxis: (id?: string) => CartesianAxisConfig | undefined; /** * Get y-axis configuration by ID. * @param id - The axis ID. Defaults to defaultAxisId. */ - getYAxis: (id?: string) => AxisConfig | undefined; + getYAxis: (id?: string) => CartesianAxisConfig | undefined; /** - * Get x-axis scale function. + * Get x-axis scale function by ID. + * @param id - The axis ID. Defaults to defaultAxisId. */ - getXScale: () => ChartScaleFunction | undefined; + getXScale: (id?: string) => ChartScaleFunction | undefined; /** * Get y-axis scale function by ID. * @param id - The axis ID. Defaults to defaultAxisId. */ getYScale: (id?: string) => ChartScaleFunction | undefined; /** - * Get x-axis serializable scale function that can be used in worklets. + * Get x-axis serializable scale function by ID that can be used in worklets. + * @param id - The axis ID. Defaults to defaultAxisId. */ - getXSerializableScale: () => SerializableScale | undefined; + getXSerializableScale: (id?: string) => SerializableScale | undefined; /** * Get y-axis serializable scale function by ID that can be used in worklets. * @param id - The axis ID. Defaults to defaultAxisId. diff --git a/packages/mobile-visualization/src/chart/utils/gradient.ts b/packages/mobile-visualization/src/chart/utils/gradient.ts index 101028d71d..33f3b95130 100644 --- a/packages/mobile-visualization/src/chart/utils/gradient.ts +++ b/packages/mobile-visualization/src/chart/utils/gradient.ts @@ -1,6 +1,7 @@ import { Skia } from '@shopify/react-native-skia'; import type { AxisBounds } from './chart'; +import type { CartesianChartLayout } from './context'; import { applySerializableScale, type ChartScaleFunction, @@ -30,7 +31,7 @@ export type GradientStop = { export type GradientDefinition = { /** * Axis that the gradient maps to. - * @default 'y' + * @default 'y' for vertical layout, 'x' for horizontal layout */ axis?: 'x' | 'y'; /** @@ -40,6 +41,16 @@ export type GradientDefinition = { stops: GradientStop[] | ((domain: AxisBounds) => GradientStop[]); }; +/** + * Resolves the axis used for gradient processing. + */ +export const getGradientAxis = ( + gradient: Pick, + layout: CartesianChartLayout, +): 'x' | 'y' => { + return gradient.axis ?? (layout === 'horizontal' ? 'x' : 'y'); +}; + /** * Resolves gradient stops, handling both static arrays and function forms. * When stops is a function, calls it with the domain bounds. @@ -131,9 +142,10 @@ export const getColorWithOpacity = (color1: string, opacity: number): string => * Processes a GradientDefinition into a renderable GradientConfig. * Supports both numeric scales (linear, log) and categorical scales (band). * - * @param gradient - GradientDefinition configuration (required) - * @param xScale - X-axis scale (required) - * @param yScale - Y-axis scale (required) + * @param gradient - GradientDefinition configuration + * @param xScale - X-axis scale + * @param yScale - Y-axis scale + * @param layout - Chart layout * @returns GradientConfig or null if gradient processing fails * * @example @@ -158,11 +170,13 @@ export const getGradientConfig = ( gradient: GradientDefinition, xScale: ChartScaleFunction, yScale: ChartScaleFunction, + layout: CartesianChartLayout, ): GradientStop[] | undefined => { if (!gradient) return; // Get the scale based on axis - const scale = gradient.axis === 'x' ? xScale : yScale; + const axis = getGradientAxis(gradient, layout); + const scale = axis === 'x' ? xScale : yScale; if (!scale) return; // Extract domain from scale @@ -316,7 +330,8 @@ export const getBaseline = (axisBounds: AxisBounds, baseline: number = 0): numbe * @param fill - The color to use for the gradient * @param peakOpacity - Opacity at the peak of the gradient * @param baselineOpacity - Opacity at the baseline - * @returns A gradient definition with y-axis stops in ascending order + * @param axis - The axis the gradient maps to ('y' for vertical, 'x' for horizontal layout) + * @returns A gradient definition with stops in ascending order */ export const createGradient = ( axisBounds: AxisBounds, @@ -324,6 +339,7 @@ export const createGradient = ( fill: string, peakOpacity: number, baselineOpacity: number, + axis: 'x' | 'y' = 'y', ): GradientDefinition => { const { min, max } = axisBounds; @@ -332,7 +348,7 @@ export const createGradient = ( if (lowerBound < baselineValue && baselineValue < upperBound) { return { - axis: 'y', + axis, stops: [ { offset: lowerBound, color: fill, opacity: peakOpacity }, { offset: baselineValue, color: fill, opacity: baselineOpacity }, @@ -344,7 +360,7 @@ export const createGradient = ( const peakValue = Math.abs(min - baselineValue) > Math.abs(max - baselineValue) ? min : max; return { - axis: 'y', + axis, stops: [ { offset: peakValue, color: fill, opacity: peakOpacity }, { offset: baselineValue, color: fill, opacity: baselineOpacity }, diff --git a/packages/mobile-visualization/src/chart/utils/path.ts b/packages/mobile-visualization/src/chart/utils/path.ts index dd3330e925..a12983bbc4 100644 --- a/packages/mobile-visualization/src/chart/utils/path.ts +++ b/packages/mobile-visualization/src/chart/utils/path.ts @@ -1,10 +1,12 @@ import { area as d3Area, curveBumpX, + curveBumpY, curveCatmullRom, curveLinear, curveLinearClosed, curveMonotoneX, + curveMonotoneY, curveNatural, curveStep, curveStepAfter, @@ -12,8 +14,19 @@ import { line as d3Line, } from 'd3-shape'; -import { projectPoint, projectPoints } from './point'; +import type { CartesianChartLayout } from './context'; +import { getPointOnScale, projectPoints } from './point'; import { type ChartScaleFunction, isCategoricalScale } from './scale'; +import type { Transition } from './transition'; + +/** + * Default enter transition for path-based components (Line, Area). + * `{ type: 'timing', duration: 500 }` + */ +export const defaultPathEnterTransition: Transition = { + type: 'timing', + duration: 500, +}; export type ChartPathCurveType = | 'bump' @@ -30,14 +43,20 @@ export type ChartPathCurveType = * Get the d3 curve function for a path. * See https://d3js.org/d3-shape/curve * @param curve - The curve type. Defaults to 'linear'. + * @param layout - The chart layout. Defaults to 'vertical'. * @returns The d3 curve function. */ -export const getPathCurveFunction = (curve: ChartPathCurveType = 'linear') => { +export const getPathCurveFunction = ( + curve: ChartPathCurveType = 'linear', + layout: CartesianChartLayout = 'vertical', +) => { switch (curve) { case 'catmullRom': return curveCatmullRom; - case 'monotone': // When we support layout="vertical" this should dynamically switch to curveMonotoneY - return curveMonotoneX; + case 'monotone': + // For vertical layout, X is the independent axis (category/index), so use MonotoneX. + // For horizontal layout, Y is the independent axis (category/index), so use MonotoneY. + return layout !== 'horizontal' ? curveMonotoneX : curveMonotoneY; case 'natural': return curveNatural; case 'step': @@ -46,8 +65,10 @@ export const getPathCurveFunction = (curve: ChartPathCurveType = 'linear') => { return curveStepBefore; case 'stepAfter': return curveStepAfter; - case 'bump': // When we support layout="vertical" this should dynamically switch to curveBumpY - return curveBumpX; + case 'bump': + // For vertical layout, X is the independent axis (category/index), so use BumpX. + // For horizontal layout, Y is the independent axis (category/index), so use BumpY. + return layout !== 'horizontal' ? curveBumpX : curveBumpY; case 'linearClosed': return curveLinearClosed; case 'linear': @@ -71,27 +92,35 @@ export const getLinePath = ({ xScale, yScale, xData, + yData, connectNulls = false, + layout = 'vertical', }: { data: (number | null | { x: number; y: number })[]; curve?: ChartPathCurveType; xScale: ChartScaleFunction; yScale: ChartScaleFunction; xData?: number[]; + yData?: number[]; /** * When true, null values are skipped and the line connects across gaps. * When false, null values create gaps in the line. * @default false */ connectNulls?: boolean; + /** + * Chart layout. + * @default 'vertical' + */ + layout?: CartesianChartLayout; }): string => { if (data.length === 0) { return ''; } - const curveFunction = getPathCurveFunction(curve); + const curveFunction = getPathCurveFunction(curve, layout); - const dataPoints = projectPoints({ data, xScale, yScale, xData }); + const dataPoints = projectPoints({ data, xScale, yScale, xData, yData, layout }); // When connectNulls is true, filter out null values before rendering // When false, use defined() to create gaps in the line @@ -134,28 +163,39 @@ export const getAreaPath = ({ xScale, yScale, xData, + yData, connectNulls = false, + layout = 'vertical', }: { data: (number | null)[] | Array<[number, number] | null>; xScale: ChartScaleFunction; yScale: ChartScaleFunction; curve: ChartPathCurveType; xData?: number[]; + yData?: number[]; /** * When true, null values are skipped and the area connects across gaps. * When false, null values create gaps in the area. * @default false */ connectNulls?: boolean; + /** + * Chart layout. + * @default 'vertical' + */ + layout?: CartesianChartLayout; }): string => { if (data.length === 0) { return ''; } - const curveFunction = getPathCurveFunction(curve); + const curveFunction = getPathCurveFunction(curve, layout); + const categoryAxisIsX = layout !== 'horizontal'; - const yDomain = yScale.domain(); - const yMin = Math.min(...yDomain); + // Determine baseline from the value scale. + const valueScale = categoryAxisIsX ? yScale : xScale; + const domain = valueScale.domain(); + const min = Math.min(...domain); const normalizedData: Array<[number, number] | null> = data.map((item, index) => { if (item === null) { @@ -170,7 +210,7 @@ export const getAreaPath = ({ } if (typeof item === 'number') { - return [yMin, item]; + return [min, item]; } return null; @@ -178,37 +218,26 @@ export const getAreaPath = ({ const dataPoints = normalizedData.map((range, index) => { if (range === null) { - return { - x: 0, - low: null, - high: null, - isValid: false, - }; + return { x: 0, y: 0, low: null, high: null, isValid: false }; } - let xValue: number = index; - if (!isCategoricalScale(xScale) && xData && xData[index] !== undefined) { - xValue = xData[index]; + // Determine the position along the independent (index/category) axis. + let indexValue: number = index; + const indexScale = categoryAxisIsX ? xScale : yScale; + const indexData = categoryAxisIsX ? xData : yData; + if (!isCategoricalScale(indexScale) && indexData && indexData[index] !== undefined) { + indexValue = indexData[index]; } - const xPoint = projectPoint({ x: xValue, y: 0, xScale, yScale }); - const lowPoint = projectPoint({ - x: xValue, - y: range[0], - xScale, - yScale, - }); - const highPoint = projectPoint({ - x: xValue, - y: range[1], - xScale, - yScale, - }); + const position = getPointOnScale(indexValue, indexScale); + const low = getPointOnScale(range[0], valueScale); + const high = getPointOnScale(range[1], valueScale); return { - x: xPoint.x, - low: lowPoint.y, - high: highPoint.y, + x: categoryAxisIsX ? position : 0, + y: !categoryAxisIsX ? position : 0, + low, + high, isValid: true, }; }); @@ -219,15 +248,27 @@ export const getAreaPath = ({ const areaGenerator = d3Area<{ x: number; + y: number; low: number | null; high: number | null; isValid: boolean; - }>() - .x((d) => d.x) - .y0((d) => d.low ?? 0) // Bottom boundary (low values), fallback to 0 - .y1((d) => d.high ?? 0) // Top boundary (high values), fallback to 0 + }>(); + + if (categoryAxisIsX) { + areaGenerator + .x((d) => d.x) + .y0((d) => d.low ?? 0) + .y1((d) => d.high ?? 0); + } else { + areaGenerator + .y((d) => d.y) + .x0((d) => d.low ?? 0) + .x1((d) => d.high ?? 0); + } + + areaGenerator .curve(curveFunction) - .defined((d) => connectNulls || (d.isValid && d.low != null && d.high != null)); // Only draw where both values exist + .defined((d) => connectNulls || (d.isValid && d.low != null && d.high != null)); const result = areaGenerator(filteredPoints); return result ?? ''; @@ -268,29 +309,30 @@ export const getBarPath = ( radius: number, roundTop: boolean, roundBottom: boolean, + layout: CartesianChartLayout = 'vertical', ): string => { + const isVerticalLayout = layout === 'vertical'; const roundBothSides = roundTop && roundBottom; const r = Math.min(radius, width / 2, roundBothSides ? height / 2 : height); - const topR = roundTop ? r : 0; - const bottomR = roundBottom ? r : 0; - - // Build path with selective rounding - let path = `M ${x + (roundTop ? r : 0)} ${y}`; - path += ` L ${x + width - topR} ${y}`; - path += ` A ${topR} ${topR} 0 0 1 ${x + width} ${y + topR}`; + const rTL = isVerticalLayout ? (roundTop ? r : 0) : roundBottom ? r : 0; + const rTR = isVerticalLayout ? (roundTop ? r : 0) : roundTop ? r : 0; + const rBR = isVerticalLayout ? (roundBottom ? r : 0) : roundTop ? r : 0; + const rBL = isVerticalLayout ? (roundBottom ? r : 0) : roundBottom ? r : 0; - path += ` L ${x + width} ${y + height - bottomR}`; - - path += ` A ${bottomR} ${bottomR} 0 0 1 ${x + width - bottomR} ${y + height}`; - - path += ` L ${x + bottomR} ${y + height}`; + // Build path with selective rounding + let path = `M ${x + rTL} ${y}`; + path += ` L ${x + width - rTR} ${y}`; + path += ` A ${rTR} ${rTR} 0 0 1 ${x + width} ${y + rTR}`; - path += ` A ${bottomR} ${bottomR} 0 0 1 ${x} ${y + height - bottomR}`; + path += ` L ${x + width} ${y + height - rBR}`; + path += ` A ${rBR} ${rBR} 0 0 1 ${x + width - rBR} ${y + height}`; - path += ` L ${x} ${y + topR}`; + path += ` L ${x + rBL} ${y + height}`; + path += ` A ${rBL} ${rBL} 0 0 1 ${x} ${y + height - rBL}`; - path += ` A ${topR} ${topR} 0 0 1 ${x + topR} ${y}`; + path += ` L ${x} ${y + rTL}`; + path += ` A ${rTL} ${rTL} 0 0 1 ${x + rTL} ${y}`; path += ' Z'; return path; diff --git a/packages/mobile-visualization/src/chart/utils/point.ts b/packages/mobile-visualization/src/chart/utils/point.ts index 22dc0720ab..263346be7c 100644 --- a/packages/mobile-visualization/src/chart/utils/point.ts +++ b/packages/mobile-visualization/src/chart/utils/point.ts @@ -1,5 +1,6 @@ import type { TextHorizontalAlignment, TextVerticalAlignment } from '../text'; +import type { CartesianChartLayout } from './context'; import { applyBandScale, applySerializableScale, @@ -7,7 +8,6 @@ import { type ChartScaleFunction, isCategoricalScale, isLogScale, - isNumericScale, type PointAnchor, type SerializableBandScale, type SerializableScale, @@ -190,12 +190,18 @@ export const projectPoints = ({ yScale, xData, yData, + layout = 'vertical', }: { data: (number | null | { x: number; y: number })[]; xData?: number[]; yData?: number[]; xScale: ChartScaleFunction; yScale: ChartScaleFunction; + /** + * Chart layout. + * @default 'vertical' + */ + layout?: CartesianChartLayout; }): Array<{ x: number; y: number } | null> => { if (data.length === 0) { return []; @@ -215,39 +221,30 @@ export const projectPoints = ({ }); } - // For scales with axis data, determine the correct x value - let xValue: number = index; + // Determine values/scales based on role (index vs value) and layout. + const categoryAxisIsX = layout !== 'horizontal'; + const indexScale = categoryAxisIsX ? xScale : yScale; + const indexData = categoryAxisIsX ? xData : yData; - // For band scales, always use the index - if (!isCategoricalScale(xScale)) { - // For numeric scales with axis data, use the axis data values instead of indices - if (xData && Array.isArray(xData) && xData.length > 0) { - // Check if it's numeric data - if (typeof xData[0] === 'number') { - const numericXData = xData as number[]; - xValue = numericXData[index] ?? index; + // 1. Calculate position along the index axis (categorical or numeric domain). + let indexValue: number = index; + if (!isCategoricalScale(indexScale)) { + if (indexData && Array.isArray(indexData) && indexData.length > 0) { + if (typeof indexData[0] === 'number') { + indexValue = indexData[index] ?? index; } } } - let yValue: number = value as number; - if ( - isNumericScale(yScale) && - yData && - Array.isArray(yData) && - yData.length > 0 && - typeof yData[0] === 'number' && - typeof value === 'number' - ) { - yValue = value as number; + // 2. Calculate position along the value axis (measured magnitude). + const valueAsNumber = value as number; + + // 3. Project final coordinates based on layout. + if (categoryAxisIsX) { + return projectPoint({ x: indexValue, y: valueAsNumber, xScale, yScale }); } - return projectPoint({ - x: xValue, - y: yValue, - xScale, - yScale, - }); + return projectPoint({ x: valueAsNumber, y: indexValue, xScale, yScale }); }); }; diff --git a/packages/mobile-visualization/src/chart/utils/scrubber.ts b/packages/mobile-visualization/src/chart/utils/scrubber.ts index 732d1753d1..f27d7ecba3 100644 --- a/packages/mobile-visualization/src/chart/utils/scrubber.ts +++ b/packages/mobile-visualization/src/chart/utils/scrubber.ts @@ -15,24 +15,30 @@ export type LabelDimensions = { /** * Determines which side (left/right) to place scrubber labels based on available space. - * Prefers right side, switches to left when labels would overflow. + * Honors the preferred side when there's enough space, otherwise switches to the opposite side. */ export const getLabelPosition = ( beaconX: number, maxLabelWidth: number, drawingArea: Rect, xOffset: number = 16, + preferredSide: ScrubberLabelPosition = 'right', ): ScrubberLabelPosition => { 'worklet'; // any regular functions in ui thread must be marked with 'worklet' if (drawingArea.width <= 0 || drawingArea.height <= 0) { - return 'right'; + return preferredSide; } - const availableRightSpace = drawingArea.x + drawingArea.width - beaconX; const requiredSpace = maxLabelWidth + xOffset; - return requiredSpace <= availableRightSpace ? 'right' : 'left'; + if (preferredSide === 'right') { + const availableSpace = drawingArea.x + drawingArea.width - beaconX; + return requiredSpace <= availableSpace ? 'right' : 'left'; + } + + const availableSpace = beaconX - drawingArea.x; + return requiredSpace <= availableSpace ? 'left' : 'right'; }; type LabelWithPosition = { diff --git a/packages/mobile-visualization/src/chart/utils/transition.ts b/packages/mobile-visualization/src/chart/utils/transition.ts index 5824668c10..fdf6555881 100644 --- a/packages/mobile-visualization/src/chart/utils/transition.ts +++ b/packages/mobile-visualization/src/chart/utils/transition.ts @@ -4,6 +4,7 @@ import { type SharedValue, useAnimatedReaction, useSharedValue, + withDelay, withSpring, type WithSpringConfig, withTiming, @@ -25,12 +26,23 @@ import { interpolatePath } from 'd3-interpolate-path'; * // Timing animation * { type: 'timing', duration: 500, easing: Easing.inOut(Easing.ease) } */ -export type Transition = +export type Transition = ( | ({ type: 'timing' } & WithTimingConfig) - | ({ type: 'spring' } & WithSpringConfig); + | ({ type: 'spring' } & WithSpringConfig) +) & { + /** + * Delay in milliseconds (ms) before the animation starts. + * + * @example + * // Wait 2 seconds before animating + * { type: 'timing', duration: 500, delay: 2000 } + */ + delay?: number; +}; /** - * Default transition configuration used across all chart components. + * Default update transition used across all chart components. + * `{ type: 'spring', stiffness: 900, damping: 120 }` */ export const defaultTransition: Transition = { type: 'spring', @@ -38,6 +50,15 @@ export const defaultTransition: Transition = { damping: 120, }; +/** + * Instant transition that completes immediately with no animation. + * Used when a transition is set to `null`. + */ +export const instantTransition: Transition = { + type: 'timing', + duration: 0, +}; + /** * Duration in milliseconds for accessory elements to fade in. */ @@ -49,45 +70,32 @@ export const accessoryFadeTransitionDuration = 150; export const accessoryFadeTransitionDelay = 350; /** - * Custom hook that uses d3-interpolate-path for more robust path interpolation. - * then use Skia's native interpolation in the worklet. - * - * @param progress - Shared value between 0 and 1 - * @param fromPath - Starting path as SVG string - * @param toPath - Ending path as SVG string - * @returns Interpolated SkPath as a shared value + * Default enter transition for accessory elements (Point, Scrubber beacons). + * `{ type: 'timing', duration: 150, delay: 350 }` */ -export const useD3PathInterpolation = ( - progress: SharedValue, - fromPath: string, - toPath: string, -): SharedValue => { - // Pre-compute intermediate paths on JS thread using d3-interpolate-path - const { fromSkiaPath, i0, i1, toSkiaPath } = useMemo(() => { - const pathInterpolator = interpolatePath(fromPath, toPath); - const d = 1e-3; - - return { - fromSkiaPath: Skia.Path.MakeFromSVGString(fromPath) ?? Skia.Path.Make(), - i0: Skia.Path.MakeFromSVGString(pathInterpolator(d)) ?? Skia.Path.Make(), - i1: Skia.Path.MakeFromSVGString(pathInterpolator(1 - d)) ?? Skia.Path.Make(), - toSkiaPath: Skia.Path.MakeFromSVGString(toPath) ?? Skia.Path.Make(), - }; - }, [fromPath, toPath]); - - const result = useSharedValue(fromSkiaPath); +export const defaultAccessoryEnterTransition: Transition = { + type: 'timing', + duration: accessoryFadeTransitionDuration, + delay: accessoryFadeTransitionDelay, +}; - useAnimatedReaction( - () => progress.value, - (t) => { - 'worklet'; - result.value = i1.interpolate(i0, t) ?? toSkiaPath; - notifyChange(result); - }, - [fromSkiaPath, i0, i1, toSkiaPath], - ); +// Avoid exact endpoint samples, which can intermittently produce non-interpolatable +// path pairs for SkPath.interpolate on complex morphs. +// See https://github.com/wcandillon/can-it-be-done-in-react-native/blob/db8d6ee7024e37e8f8d2cb237c0b953b5fc766fe/season5/src/Headspace/Play.tsx +const pathInterpolationEpsilon = 1e-3; - return result; +/** + * Resolves a transition value based on the animation state and a default. + * @note Passing in null will disable an animation. + * @note Passing in undefined will use the provided default. + */ +export const getTransition = ( + value: Transition | null | undefined, + animate: boolean, + defaultValue: Transition, +): Transition | null => { + if (!animate || value === null) return null; + return value ?? defaultValue; }; // Interpolator and useInterpolator are brought over from non exported code in @shopify/react-native-skia @@ -145,20 +153,34 @@ export const useInterpolator = ( * // Timing animation * progress.value = buildTransition(1, { type: 'timing', duration: 500 }); */ -export const buildTransition = (targetValue: number, transition: Transition): number => { +export const buildTransition = (targetValue: number, transition: Transition | null): number => { 'worklet'; + + if (transition === null) return targetValue; + + const delayMs = transition.delay; + + let animation: number; switch (transition.type) { case 'timing': { - return withTiming(targetValue, transition); + animation = withTiming(targetValue, transition); + break; } case 'spring': { - return withSpring(targetValue, transition); + animation = withSpring(targetValue, transition); + break; } default: { - // Fallback to default transition config - return withSpring(targetValue, defaultTransition); + animation = withSpring(targetValue, defaultTransition); + break; } } + + if (delayMs && delayMs > 0) { + return withDelay(delayMs, animation); + } + + return animation; }; /** @@ -166,15 +188,16 @@ export const buildTransition = (targetValue: number, transition: Transition): nu * * @param currentPath - Current target path to animate to * @param initialPath - Initial path for enter animation. When provided, the first animation will go from initialPath to currentPath. - * @param transition - Transition configuration + * @param transitions - Transition configuration for enter and update animations * @returns Animated SkPath as a shared value * * @example * // Simple path transition * const path = usePathTransition({ * currentPath: d ?? '', - * animate: shouldAnimate, - * transition: { type: 'timing', duration: 3000 } + * transitions: { + * update: { type: 'timing', duration: 3000 }, + * }, * }); * * @example @@ -182,13 +205,16 @@ export const buildTransition = (targetValue: number, transition: Transition): nu * const path = usePathTransition({ * currentPath: targetPath, * initialPath: baselinePath, - * animate: true, - * transition: { type: 'timing', duration: 300 } + * transitions: { + * enter: { type: 'tween', duration: 500 }, + * update: { type: 'spring', stiffness: 900, damping: 120 }, + * }, * }); */ export const usePathTransition = ({ currentPath, initialPath, + transitions, transition = defaultTransition, }: { /** @@ -202,31 +228,110 @@ export const usePathTransition = ({ */ initialPath?: string; /** - * Transition configuration + * Transition configuration for enter and update animations. + */ + transitions?: { + /** + * Transition for the initial enter animation (initialPath → currentPath). + * Only used when `initialPath` is provided. + * If not provided, falls back to `update`. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * @default defaultTransition + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. */ transition?: Transition; }): SharedValue => { - // Track the previous path - updated in useEffect AFTER render, - // so during render it naturally holds the "from" path value - const previousPathRef = useRef(initialPath ?? currentPath); + const transitionRef = useRef<{ + enter?: Transition | null; + update: Transition | null; + }>({ + enter: transitions?.enter, + update: transitions?.update !== undefined ? transitions.update : transition, + }); + transitionRef.current.enter = transitions?.enter; + transitionRef.current.update = + transitions?.update !== undefined ? transitions.update : transition; + + const targetPathRef = useRef(initialPath ?? currentPath); + const isFirstAnimation = useRef(!!initialPath); + const interpolatorRef = useRef<((t: number) => string) | null>(null); const progress = useSharedValue(0); - // During render: previousPathRef still has old value, currentPath is new - const fromPath = previousPathRef.current; - const toPath = currentPath; + const initialSkiaPath = + Skia.Path.MakeFromSVGString(initialPath ?? currentPath) ?? Skia.Path.Make(); + const normalizedStartShared = useSharedValue(initialSkiaPath); + const normalizedEndShared = useSharedValue(initialSkiaPath); + const fallbackPathShared = useSharedValue(initialSkiaPath); + const result = useSharedValue(initialSkiaPath); useEffect(() => { - const shouldAnimate = previousPathRef.current !== currentPath; + if (targetPathRef.current !== currentPath) { + let fromPath = targetPathRef.current; + if (interpolatorRef.current) { + const p = Math.min(Math.max(progress.value, 0), 1); + fromPath = interpolatorRef.current(p); + } + + targetPathRef.current = currentPath; + + const { enter, update } = transitionRef.current; + const activeTransition = isFirstAnimation.current && enter !== undefined ? enter : update; + + isFirstAnimation.current = false; - if (shouldAnimate) { - // Update ref for next path change (happens after this render) - previousPathRef.current = currentPath; + if (activeTransition === null) { + const targetPath = Skia.Path.MakeFromSVGString(currentPath) ?? Skia.Path.Make(); + interpolatorRef.current = null; + normalizedStartShared.value = targetPath; + normalizedEndShared.value = targetPath; + fallbackPathShared.value = targetPath; + progress.value = 1; + result.value = targetPath; + notifyChange(result); + return; + } + + const pathInterpolator = interpolatePath(fromPath, currentPath); + interpolatorRef.current = pathInterpolator; + + normalizedStartShared.value = + Skia.Path.MakeFromSVGString(pathInterpolator(pathInterpolationEpsilon)) ?? Skia.Path.Make(); + normalizedEndShared.value = + Skia.Path.MakeFromSVGString(pathInterpolator(1 - pathInterpolationEpsilon)) ?? + Skia.Path.Make(); + fallbackPathShared.value = Skia.Path.MakeFromSVGString(currentPath) ?? Skia.Path.Make(); - // Animate from old path to new path progress.value = 0; - progress.value = buildTransition(1, transition); + progress.value = buildTransition(1, activeTransition); } - }, [currentPath, transition, progress]); + }, [ + currentPath, + progress, + normalizedStartShared, + normalizedEndShared, + fallbackPathShared, + result, + ]); - return useD3PathInterpolation(progress, fromPath, toPath); + useAnimatedReaction( + () => ({ p: progress.value, to: fallbackPathShared.value }), + ({ p }) => { + 'worklet'; + result.value = + normalizedEndShared.value.interpolate(normalizedStartShared.value, p) ?? + fallbackPathShared.value; + notifyChange(result); + }, + [], + ); + + return result; }; diff --git a/packages/mobile-visualization/src/sparkline/Sparkline.tsx b/packages/mobile-visualization/src/sparkline/Sparkline.tsx index 06172e6ea5..66a3dec026 100644 --- a/packages/mobile-visualization/src/sparkline/Sparkline.tsx +++ b/packages/mobile-visualization/src/sparkline/Sparkline.tsx @@ -49,7 +49,8 @@ export type SparklineBaseProps = SharedProps & { export type SparklineProps = SparklineBaseProps; /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const Sparkline = memo( forwardRef( diff --git a/packages/mobile-visualization/src/sparkline/SparklineArea.tsx b/packages/mobile-visualization/src/sparkline/SparklineArea.tsx index 971cfb8f15..1a4119e342 100644 --- a/packages/mobile-visualization/src/sparkline/SparklineArea.tsx +++ b/packages/mobile-visualization/src/sparkline/SparklineArea.tsx @@ -8,7 +8,8 @@ export type SparklineAreaBaseProps = { }; /** - * @deprecated Use AreaChart instead. + * @deprecated Use AreaChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineArea = memo( forwardRef( diff --git a/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx b/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx index 9c3784a290..1c07139634 100644 --- a/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx +++ b/packages/mobile-visualization/src/sparkline/SparklineGradient.tsx @@ -12,7 +12,8 @@ import type { SparklineBaseProps } from './Sparkline'; import { SparklineAreaPattern } from './SparklineAreaPattern'; /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineGradient = memo( forwardRef( diff --git a/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx b/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx index 96ba7c90fb..f5b0c4e639 100644 --- a/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx +++ b/packages/mobile-visualization/src/sparkline/__figma__/Sparkline.figma.tsx @@ -9,8 +9,8 @@ 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 { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath';", + "import { Sparkline } from '@coinbase/cds-mobile-visualization'", + "import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'", ], example: function Example() { const data = [20, 30, 5, 45, 0]; diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx index 88c6de4b42..5ab1de8746 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx +++ b/packages/mobile-visualization/src/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-visualization'", + "import { SparklineInteractive } from '@coinbase/cds-mobile-visualization'", ], props: { compact: figma.boolean('compact'), diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx index 00fbc0236f..de9a8c7ef9 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx @@ -521,8 +521,21 @@ const SparklineInteractiveHeaderScreen = () => { const trailing = useMemo(() => { return ( - - + + ); }, []); diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx index 4983ff82dc..9d5ca57d31 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx @@ -514,7 +514,8 @@ function SparklineInteractiveWithGeneric({ } /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineInteractive = memo( SparklineInteractiveWithGeneric, diff --git a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx b/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx index 044ae28e00..f1100bce79 100644 --- a/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx +++ b/packages/mobile-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx @@ -7,7 +7,7 @@ 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-visualization'"], props: { compact: figma.boolean('compact'), disableScrubbing: figma.boolean('scrubbing', { diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index e7348d3e8c..938fc1766b 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,7 +8,394 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 8.66.0 ((4/16/2026, 01:57 PM PST)) + +This is an artificial version bump with no new change. + +## 8.65.0 (4/16/2026 PST) + +#### 🚀 Updates + +- Add customization to text for ModalHeader. [[#613](https://github.com/coinbase/cds/pull/613)] + +## 8.64.5 ((4/16/2026, 06:50 AM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Undo test refactors from #568. [[#611](https://github.com/coinbase/cds/pull/611)] + +## 8.64.4 ((4/10/2026, 01:20 PM PST)) + +This is an artificial version bump with no new change. + +## 8.64.3 (4/8/2026 PST) + +#### 🐞 Fixes + +- Fix: Stepper animation with react-spring ^10.0.1. [[#603](https://github.com/coinbase/cds/pull/603)] + +## 8.64.2 (4/8/2026 PST) + +#### 🐞 Fixes + +- Fix: Mobile Cell testID missing in iOS. [[#568](https://github.com/coinbase/cds/pull/568)] + +## 8.64.1 (4/7/2026 PST) + +#### 🐞 Fixes + +- Chore: Add styles APIs to Tour and TourStep components. [[#592](https://github.com/coinbase/cds/pull/592)] + +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 ((4/1/2026, 03:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.62.1 ((4/1/2026, 12:25 PM PST)) + +This is an artificial version bump with no new change. + +## 8.62.0 (3/30/2026 PST) + +#### 🚀 Updates + +- Add ComponentConfigProvider. [[#507](https://github.com/coinbase/cds/pull/507)] + +## 8.61.0 (3/30/2026 PST) + +#### 🚀 Updates + +- Feat: support Button and IconButton size customization. [[#565](https://github.com/coinbase/cds/pull/565)] + +#### 📘 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 + +- Support controlSize on Checkbox and Radio. [[#546](https://github.com/coinbase/cds/pull/546)] + +## 8.58.0 (3/25/2026 PST) + +#### 🚀 Updates + +- Feat: support font prop on inputs. [[#545](https://github.com/coinbase/cds/pull/545)] +- Feat: support borderRadius on SearchInput. [[#545](https://github.com/coinbase/cds/pull/545)] + +## 8.57.1 ((3/24/2026, 01:14 PM PST)) + +This is an artificial version bump with no new change. + +## 8.57.0 (3/24/2026 PST) + +#### 🚀 Updates + +- Feat: support focusedBorderWidth on TextInput. [[#537](https://github.com/coinbase/cds/pull/537)] + +## 8.56.1 ((3/24/2026, 08:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.56.0 (3/23/2026 PST) + +#### 🚀 Updates + +- Support modal subcomponent props. [[#534](https://github.com/coinbase/cds/pull/534)] + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + +## 8.55.1 ((3/22/2026, 01:43 PM PST)) + +This is an artificial version bump with no new change. + +## 8.55.0 (3/19/2026 PST) + +#### 🚀 Updates + +- Add `disableSafeAreaPaddingBottom` prop to drawer. [[#522](https://github.com/coinbase/cds/pull/522)] + +#### 🐞 Fixes + +- Fix padding collapsing on tray with handle bar inside. [[#522](https://github.com/coinbase/cds/pull/522)] + +## 8.54.0 (3/18/2026 PST) + +#### 🚀 Updates + +- Added Calendar component and included new Calendar in DatePicker. [[#139](https://github.com/coinbase/cds/pull/139)] + +#### 🐞 Fixes + +- Removed react-native-date-picker dependency. [[#139](https://github.com/coinbase/cds/pull/139)] + +## 8.53.1 (3/17/2026 PST) + +#### 🐞 Fixes + +- Fix: update RemoteImageGroup excess bg color. [[#512](https://github.com/coinbase/cds/pull/512)] + +## 8.53.0 (3/16/2026 PST) + +#### 🚀 Updates + +- Feat: update Checkbox borderRadius to match design. [[#509](https://github.com/coinbase/cds/pull/509)] + +## 8.52.2 (3/11/2026 PST) + +#### 🐞 Fixes + +- Configure control borderWidth and controlColor. [[#457](https://github.com/coinbase/cds/pull/457)] + +## 8.52.1 ((3/11/2026, 09:52 AM PST)) + +This is an artificial version bump with no new change. + +## 8.52.0 (3/10/2026 PST) + +#### 🚀 Updates + +- A11y improvements to Fallback, Spinner, and LottieStatusAnimation. [[#388](https://github.com/coinbase/cds/pull/388)] +- Simplify the ProgressBar component implementation. [[#388](https://github.com/coinbase/cds/pull/388)] + +## 8.51.0 ((3/9/2026, 06:39 AM PST)) + +This is an artificial version bump with no new change. + +## 8.50.0 (3/6/2026 PST) + +#### 🚀 Updates + +- Feat: iconSize customization for IconButton. [[#474](https://github.com/coinbase/cds/pull/474)] + +## 8.49.2 (3/6/2026 PST) + +#### 🐞 Fixes + +- Feat: improve deprecation notice in ListCell. [[#411](https://github.com/coinbase/cds/pull/411)] + +## 8.49.1 (3/5/2026 PST) + +#### 🐞 Fixes + +- Fix: spread tabs props at end for Tabs. [[#472](https://github.com/coinbase/cds/pull/472)] + +## 8.49.0 (2/26/2026 PST) + +#### 🚀 Updates + +- Add styles props to Tab components. [[#438](https://github.com/coinbase/cds/pull/438)] + +## 8.48.3 ((2/25/2026, 08:36 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.2 (2/25/2026 PST) + +#### 🐞 Fixes + +- Deprecate useStatusBarHeight hook. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 (2/24/2026 PST) + +#### 🚀 Updates + +- Add start/end icon/node support to Tag. [[#421](https://github.com/coinbase/cds/pull/421)] + +## 8.47.4 (2/23/2026 PST) + +#### 🐞 Fixes + +- Fix: set paddingStart on Input for compact label. [[#423](https://github.com/coinbase/cds/pull/423)] + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.2 (2/19/2026 PST) + +#### 🐞 Fixes + +- Fix mobile CardRoot style forwarding logic. [[#405](https://github.com/coinbase/cds/pull/405)] + +## 8.47.1 (2/19/2026 PST) + +#### 🐞 Fixes + +- Fix Tray title spacing and overflow. [[#414](https://github.com/coinbase/cds/pull/414)] + +## 8.47.0 (2/19/2026 PST) + +#### 🚀 Updates + +- Feat: enable Button text customization via font props. [[#408](https://github.com/coinbase/cds/pull/408)] + +## 8.46.1 (2/12/2026 PST) + +#### 🐞 Fixes + +- Fix: (DX-5052) use previous active step value for calculating remaining steps to animate to for a completed stepper. [[#397](https://github.com/coinbase/cds/pull/397)] [DX-5052] + +## 8.46.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] + +## 8.45.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add reduce motion support for Tray. [[#386](https://github.com/coinbase/cds/pull/386)] + +## 8.44.2 (2/10/2026 PST) + +#### 🐞 Fixes + +- Update styles jsdocs for tray. [[#385](https://github.com/coinbase/cds/pull/385)] + +## 8.44.1 ((2/10/2026, 12:05 PM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Update jsdocs for styles props. [[#384](https://github.com/coinbase/cds/pull/384)] + +## 8.44.0 (2/9/2026 PST) + +#### 🚀 Updates + +- Add new tray design. [[#349](https://github.com/coinbase/cds/pull/349)] + +## 8.43.2 ((2/9/2026, 09:05 AM PST)) + +This is an artificial version bump with no new change. + +## 8.43.1 (2/6/2026 PST) + +#### 🐞 Fixes + +- Update chip prop export. [[#328](https://github.com/coinbase/cds/pull/328)] + +## 8.43.0 (2/6/2026 PST) + +#### 🚀 Updates + +- Carousel autoplay. [[#361](https://github.com/coinbase/cds/pull/361)] + +## 8.42.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Added MediaCard, MessagingCard, and alpha DataCard. [[#329](https://github.com/coinbase/cds/pull/329)] +- Updated ContentCard. [[#329](https://github.com/coinbase/cds/pull/329)] + +#### 📘 Misc + +- Update storybook ExampleScreen. [[#366](https://github.com/coinbase/cds/pull/366)] + +## 8.41.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Add align prop to Select and Combobox. [[#348](https://github.com/coinbase/cds/pull/348)] + +## 8.40.2 (2/2/2026 PST) + +#### 🐞 Fixes + +- Fix: carousel block scrolling on y axis. [[#358](https://github.com/coinbase/cds/pull/358)] [DX-5096] + +## 8.40.1 (1/30/2026 PST) + +#### 🐞 Fixes + +- Add Math.round to ProgressCircle accessibilityValue to prevent precision crash. [[#354](https://github.com/eccentricdz/cds/pull/354)] [HNWI-766] + +#### 📘 Misc + +- Add descriptive names for generic types. [[#341](https://github.com/coinbase/cds/pull/341)] [DX-5037] + +## 8.40.0 ((1/28/2026, 11:12 AM PST)) + +This is an artificial version bump with no new change. + +## 8.39.1 (1/28/2026 PST) + +#### 🐞 Fixes + +- Fix padding on Tab components. [[#330](https://github.com/coinbase/cds/pull/330)] + +## 8.39.0 (1/27/2026 PST) + +#### 🚀 Updates + +- Support Carousel looping. [[#327](https://github.com/coinbase/cds/pull/327)] + +## 8.38.7 (1/26/2026 PST) + +#### 🐞 Fixes + +- Add optional `elevation` prop to Control components (Switch, Checkbox, Radio). [[#325](https://github.com/coinbase/cds/pull/325)] + +## 8.38.6 (1/23/2026 PST) + +#### 🐞 Fixes + +- Chore: align version with web package. + +## 8.38.5 (1/23/2026 PST) + +#### 🐞 Fixes + +- Update ARIA labels used for Select and Combobox. [[#250](https://github.com/coinbase/cds/pull/250)] + +## 8.38.4 ((1/22/2026, 01:55 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.3 ((1/22/2026, 01:42 PM PST)) + +This is an artificial version bump with no new change. + +## 8.38.2 ((1/22/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + +## 8.38.1 (1/15/2026 PST) + +#### 🐞 Fixes + +- Support TextInput labelNode on compact and inside labelVariant. [[#293](https://github.com/coinbase/cds/pull/293)] + +#### 📘 Misc + +- Internal: code connect file lint fixes. [[#311](https://github.com/coinbase/cds/pull/311)] #### 📘 Misc diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 7da55777ed..db91730b29 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -6,9 +6,12 @@ Components for React Native. Add the relative path to the CDS icon font to your react-native.config.js. If your project lives in the monorepo this lives in the root `react-native.config.js` file. There is an example for CDS playground in there. -You will need to run `npx react-native link` to link the assets for Android and iOS and then run build, `npx react-native run-ios` or `npx react-native run-android` for them to be available. +In this monorepo, run the `mobile-app` targets from the repo root: -### Outside mono/repo +- `yarn nx run mobile-app:go` for Expo Go development +- `yarn nx run mobile-app:launch:ios-debug` or `yarn nx run mobile-app:launch:android-debug` for local debug launch + +### Outside monorepo - Install package with `yarn add @coinbase/cds-mobile`. - Update `react-native.config.js` to include icon font in assets, i.e. `assets: ['./node_modules/@coinbase/cds-mobile/icons/font']`. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 0bc88523c3..0b71eabbd0 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.38.0", + "version": "8.66.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", @@ -187,7 +187,6 @@ "lottie-react-native": "^6.7.0", "react": "^18.3.1", "react-native": "^0.74.5", - "react-native-date-picker": "^4.4.2", "react-native-gesture-handler": "^2.16.2", "react-native-inappbrowser-reborn": "^3.7.0", "react-native-linear-gradient": "^2.8.3", @@ -207,7 +206,8 @@ "@react-spring/native": "^9.7.4", "fuse.js": "^7.1.0", "lodash": "^4.17.21", - "type-fest": "^2.19.0" + "type-fest": "^2.19.0", + "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.28.0", @@ -223,7 +223,6 @@ "eslint-plugin-reanimated": "^2.0.1", "lottie-react-native": "6.7.0", "react-native-accessibility-engine": "^3.2.0", - "react-native-date-picker": "4.4.2", "react-native-gesture-handler": "2.16.2", "react-native-inappbrowser-reborn": "3.7.0", "react-native-linear-gradient": "2.8.3", diff --git a/packages/mobile/src/accordion/Accordion.tsx b/packages/mobile/src/accordion/Accordion.tsx index 5a8e6b9f82..00d0f8b770 100644 --- a/packages/mobile/src/accordion/Accordion.tsx +++ b/packages/mobile/src/accordion/Accordion.tsx @@ -7,33 +7,27 @@ import { import type { SharedProps } from '@coinbase/cds-common/types'; import { join } from '@coinbase/cds-common/utils/join'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Divider, VStack } from '../layout'; export type AccordionBaseProps = SharedProps & AccordionProviderProps; export type AccordionProps = AccordionBaseProps & Pick; -export const Accordion = memo( - ({ - activeKey, - children, - defaultActiveKey, - onChange, - setActiveKey, - testID, - style, - }: AccordionProps) => { - return ( - - - {join(Children.toArray(children), )} - - - ); - }, -); +export const Accordion = memo((_props: AccordionProps) => { + const mergedProps = useComponentConfig('Accordion', _props); + const { activeKey, children, defaultActiveKey, onChange, setActiveKey, testID, style } = + mergedProps; + return ( + + + {join(Children.toArray(children), )} + + + ); +}); diff --git a/packages/mobile/src/accordion/__figma__/Accordion.figma.tsx b/packages/mobile/src/accordion/__figma__/Accordion.figma.tsx index 388595706d..3a378d754b 100644 --- a/packages/mobile/src/accordion/__figma__/Accordion.figma.tsx +++ b/packages/mobile/src/accordion/__figma__/Accordion.figma.tsx @@ -9,8 +9,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=148%3A2954', { imports: [ - "import { Accordion } from '@coinbase/cds-mobile/accordion/Accordion';", - "import { AccordionItem } from '@coinbase/cds-mobile/accordion/AccordionItem';", + "import { Accordion } from '@coinbase/cds-mobile/accordion/Accordion'", + "import { AccordionItem } from '@coinbase/cds-mobile/accordion/AccordionItem'", ], props: { subtitle: figma.boolean('show subtitle', { diff --git a/packages/mobile/src/alpha/__figma__/Select.figma.tsx b/packages/mobile/src/alpha/__figma__/Select.figma.tsx new file mode 100644 index 0000000000..98e5bb2353 --- /dev/null +++ b/packages/mobile/src/alpha/__figma__/Select.figma.tsx @@ -0,0 +1,57 @@ +import { figma } from '@figma/code-connect'; + +import { Select } from '../select/Select'; + +const selectOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'orange', label: 'Orange', description: 'Citrus' }, +]; + +figma.connect( + Select, + 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=71762-14938', + { + imports: ["import { Select } from '@coinbase/cds-mobile/alpha/select/Select'"], + props: { + type: figma.enum('type', { + 'single select': 'single', + 'multi-select': 'multi', + }), + disabled: figma.boolean('disabled'), + compact: figma.boolean('compact'), + label: figma.boolean('show label', { + true: figma.boolean('show info icon') + ? ` + ${figma.string('label string')} + + + + ` + : figma.string('label string'), + false: undefined, + }), + start: figma.boolean('show start', { + true: figma.instance('start'), + false: undefined, + }), + helperText: figma.boolean('show helper text', { + true: figma.string('helper text'), + false: undefined, + }), + placeholder: figma.string('placeholderText'), + variant: figma.enum('state', { + default: undefined, + positive: 'positive', + negative: 'negative', + }), + value: figma.enum('type', { + 'single select': 'Item 1', + 'multi-select': ['Item 1', 'Item 2'], + }), + }, + example: ({ type, value, ...props }) => ( + { ); }; +const SingleAlignExample = () => { + const [singleValue, setSingleValue] = useState('1'); + + return ( + + + + + + ); +}; + +const MultiAlignExample = () => { + const { value, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + + + + + + ); +}; + const NoLabelExample = () => { const [value, setValue] = useState('1'); @@ -480,11 +612,11 @@ const MixedDefaultAndCustomComponentOptions = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onPress }) => { return ( - + - + ); }; @@ -533,11 +665,11 @@ const CustomOptionComponent = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onPress }) => { return ( - + - + ); }; @@ -762,6 +894,23 @@ const MultiSelectCustomSelectAllOptionExample = () => { ); }; +const MultiSelectLongLabelOptionsExample = () => { + const { value, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + - {isTrayVisible && ( - - {({ handleClose }) => ( - - )} - - )} - - ); - }, - }, -); diff --git a/packages/mobile/src/controls/__figma__/SelectOption.figma.tsx b/packages/mobile/src/controls/__figma__/SelectOption.figma.tsx index 9c25617763..f1347dd1b7 100644 --- a/packages/mobile/src/controls/__figma__/SelectOption.figma.tsx +++ b/packages/mobile/src/controls/__figma__/SelectOption.figma.tsx @@ -7,7 +7,7 @@ figma.connect( SelectOption, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=244-11050&m=dev', { - imports: ["import { SelectOption } from '@coinbase/cds-mobile/controls/SelectOption';"], + imports: ["import { SelectOption } from '@coinbase/cds-mobile/controls/SelectOption'"], props: { disabled: figma.boolean('disabled'), compact: figma.boolean('compact'), diff --git a/packages/mobile/src/controls/__figma__/Switch.figma.tsx b/packages/mobile/src/controls/__figma__/Switch.figma.tsx index 346c7a94f5..a176fc4a49 100644 --- a/packages/mobile/src/controls/__figma__/Switch.figma.tsx +++ b/packages/mobile/src/controls/__figma__/Switch.figma.tsx @@ -6,7 +6,7 @@ figma.connect( Switch, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=155%3A9924', { - imports: ["import { Switch } from '@coinbase/cds-mobile/controls/Switch';"], + imports: ["import { Switch } from '@coinbase/cds-mobile/controls/Switch'"], props: { children: figma.boolean('show label', { true: figma.string('↳ label'), diff --git a/packages/mobile/src/controls/__figma__/TextInput.figma.tsx b/packages/mobile/src/controls/__figma__/TextInput.figma.tsx index 0e1f8900a9..daef72dbd0 100644 --- a/packages/mobile/src/controls/__figma__/TextInput.figma.tsx +++ b/packages/mobile/src/controls/__figma__/TextInput.figma.tsx @@ -8,7 +8,7 @@ figma.connect( TextInput, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=252%3A16679', { - imports: ["import { TextInput } from '@coinbase/cds-mobile/controls/TextInput';"], + imports: ["import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'"], props: { align: figma.boolean('right align text', { true: 'end', @@ -60,7 +60,7 @@ figma.connect( TextInput, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=252%3A16679', { - imports: ["import { TextInput } from '@coinbase/cds-mobile/controls/TextInput';"], + imports: ["import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'"], variant: { 'show end': true, '↳ show suffix': true }, props: { align: figma.boolean('right align text', { diff --git a/packages/mobile/src/controls/__stories__/Checkbox.stories.tsx b/packages/mobile/src/controls/__stories__/Checkbox.stories.tsx index 44f374997d..b9e13412a0 100644 --- a/packages/mobile/src/controls/__stories__/Checkbox.stories.tsx +++ b/packages/mobile/src/controls/__stories__/Checkbox.stories.tsx @@ -39,8 +39,17 @@ const CheckboxScreen = () => { Checked and disabled + + + Read Only + + + + + + This checkbox has a multi-line label. The checkbox and label should align at the top. The label is super duper long and it keeps going on forever. This checkbox has a multi-line @@ -131,6 +140,18 @@ const CheckboxScreen = () => { Style props indeterminate + + + setChecked((s) => !s)}> + Default (100) + + setChecked((s) => !s)}> + Border width 200 + + setChecked((s) => !s)}> + Border width 500 + + ); }; diff --git a/packages/mobile/src/controls/__stories__/RadioGroup.stories.tsx b/packages/mobile/src/controls/__stories__/RadioGroup.stories.tsx index 77601b40ae..2d22488630 100644 --- a/packages/mobile/src/controls/__stories__/RadioGroup.stories.tsx +++ b/packages/mobile/src/controls/__stories__/RadioGroup.stories.tsx @@ -29,8 +29,17 @@ const RadioGroupScreen = () => { Disabled Selected Disabled + + + Read Only + + + + + + This radio has a multi-line label. The radio and label should align at the top. The label is super duper long and it keeps going on forever. This radio has a multi-line label. @@ -128,6 +137,32 @@ const RadioGroupScreen = () => { ); }} + + + {() => { + const toggleChecked = () => setChecked((prevChecked) => !prevChecked); + const smallRadioTheme = { + ...defaultTheme, + controlSize: { + ...defaultTheme.controlSize, + radioSize: 18, + }, + }; + + return ( + + + Default radio (20px, borderWidth 100) + + + + Smaller radio (18px, borderWidth 200) + + + + ); + }} + ); }; diff --git a/packages/mobile/src/controls/__stories__/SearchInput.stories.tsx b/packages/mobile/src/controls/__stories__/SearchInput.stories.tsx index 37e1f51506..4f612e91c4 100644 --- a/packages/mobile/src/controls/__stories__/SearchInput.stories.tsx +++ b/packages/mobile/src/controls/__stories__/SearchInput.stories.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useRef, useState } from 'react'; import type { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { VStack } from '../../layout/VStack'; import { Text } from '../../typography/Text'; import { InputIconButton } from '../InputIconButton'; import { SearchInput } from '../SearchInput'; @@ -16,6 +17,31 @@ const Compact = () => { return ; }; +const BorderlessVariants = () => { + const [defaultBorderlessText, setDefaultBorderlessText] = useState(''); + const [focusBorderText, setFocusBorderText] = useState(''); + + return ( + + + + + ); +}; + const HideStartIcon = () => { const [text, setText] = useState(''); return ; @@ -163,6 +189,9 @@ const SearchInputScreen = () => { + + + diff --git a/packages/mobile/src/controls/__stories__/Switch.stories.tsx b/packages/mobile/src/controls/__stories__/Switch.stories.tsx index 10d874e428..544bd209f5 100644 --- a/packages/mobile/src/controls/__stories__/Switch.stories.tsx +++ b/packages/mobile/src/controls/__stories__/Switch.stories.tsx @@ -7,6 +7,7 @@ import { Switch } from '../Switch'; const SwitchScreen = () => { const [isChecked, setIsChecked] = useState(false); const [isChecked2, setIsChecked2] = useState(true); + const [isChecked3, setIsChecked3] = useState(false); return ( @@ -57,6 +58,16 @@ const SwitchScreen = () => { ); }} + + {() => { + const toggleChecked = () => setIsChecked3((prevChecked) => !prevChecked); + return ( + + Elevation + + ); + }} + ); }; diff --git a/packages/mobile/src/controls/__stories__/TextInput.stories.tsx b/packages/mobile/src/controls/__stories__/TextInput.stories.tsx index 78f757ba41..32fe0b8e0c 100644 --- a/packages/mobile/src/controls/__stories__/TextInput.stories.tsx +++ b/packages/mobile/src/controls/__stories__/TextInput.stories.tsx @@ -233,6 +233,20 @@ const InputScreen = () => { placeholder="ex. Bitcoin" /> + + } + /> + } + /> + { - - + + + @@ -455,19 +481,62 @@ const InputScreen = () => { variant="negative" /> - + + Display name - + } placeholder="Satoshi Nakamoto" /> + + Amount + + * + + + } + placeholder="0.00" + suffix="USD" + /> + + Search + + } + placeholder="Search..." + start={} + /> + + Bio + + (optional) + + + } + labelVariant="inside" + placeholder="Tell us about yourself" + /> + Notes} + labelVariant="inside" + placeholder="Add a note" + start={} + /> ); diff --git a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx index 90e1369450..ecd13f3a29 100644 --- a/packages/mobile/src/controls/__tests__/Checkbox.test.tsx +++ b/packages/mobile/src/controls/__tests__/Checkbox.test.tsx @@ -48,6 +48,21 @@ describe('Checkbox', () => { expect(screen.getByTestId('mock-checkbox')).toBeAccessible(); }); + it('applies controlSize to checkbox container', () => { + render( + + + Checked + + , + ); + + expect(screen.getByTestId('test-checkbox')).toHaveStyle({ + width: 60, + height: 60, + }); + }); + it('renders a minus icon when indeterminate', () => { render( diff --git a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx index f03d66f8cc..08644fd326 100644 --- a/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx +++ b/packages/mobile/src/controls/__tests__/RadioGroup.test.tsx @@ -1,4 +1,5 @@ import { Pressable } from 'react-native'; +import { Circle } from 'react-native-svg'; import { fireEvent, render, screen } from '@testing-library/react-native'; import { Text } from '../../typography/Text'; @@ -158,4 +159,41 @@ describe('Radio', () => { borderColor: 'rgb(9,133,81)', // This corresponds to bgPositive in defaultTheme }); }); + + it('applies controlSize to radio container', () => { + render( + + + Radio + + , + ); + + expect(screen.getByTestId('test-radio')).toHaveStyle({ + width: 60, + height: 60, + }); + }); + + it('defaults dotSize to two thirds of controlSize and supports explicit dotSize', () => { + const { rerender } = render( + + + Radio + + , + ); + + expect(screen.UNSAFE_getByType(Circle).props.r).toBe(20); + + rerender( + + + Radio + + , + ); + + expect(screen.UNSAFE_getByType(Circle).props.r).toBe(15); + }); }); diff --git a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx index f917f64c2f..52a21d2094 100644 --- a/packages/mobile/src/controls/__tests__/SearchInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/SearchInput.test.tsx @@ -1,5 +1,8 @@ +import { Animated, StyleSheet } from 'react-native'; +import { focusedInputBorderWidth } from '@coinbase/cds-common/tokens/input'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { defaultTheme } from '../../themes/defaultTheme'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { InputIconButton } from '../InputIconButton'; import { SearchInput } from '../SearchInput'; @@ -8,6 +11,14 @@ const TEST_ID = 'search'; const ROLE = 'search'; describe('Search', () => { + const getFocusedBorderOverlayStyle = () => { + const focusedBorderOverlay = screen + .UNSAFE_getAllByType(Animated.View) + .find((view) => StyleSheet.flatten(view.props.style)?.position === 'absolute'); + + return focusedBorderOverlay ? StyleSheet.flatten(focusedBorderOverlay.props.style) : undefined; + }; + let SearchComponent: React.ReactElement; const onClearSpy = jest.fn(); const onChangeTextSpy = jest.fn(); @@ -52,6 +63,60 @@ describe('Search', () => { expect(screen.getByRole('search').props.value).toBe('value'); }); + it('passes font to the text input', () => { + render( + + + , + ); + + const flattenedStyle = StyleSheet.flatten(screen.getByRole('search').props.style); + expect(flattenedStyle).toEqual( + expect.objectContaining({ + fontSize: defaultTheme.fontSize.label1, + minHeight: defaultTheme.lineHeight.label1, + fontWeight: defaultTheme.fontWeight.label1, + }), + ); + }); + + it('keeps focused border width at 0 by default when bordered is false', () => { + render( + + + , + ); + + fireEvent(screen.getByTestId(TEST_ID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toEqual(expect.objectContaining({ borderWidth: 0 })); + }); + + it('applies focusedBorderWidth when bordered is false', () => { + render( + + + , + ); + + fireEvent(screen.getByTestId(TEST_ID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toEqual( + expect.objectContaining({ borderWidth: focusedInputBorderWidth }), + ); + }); + it('renders a backArrow icon button at the start node', () => { render( diff --git a/packages/mobile/src/controls/__tests__/TextInput.test.tsx b/packages/mobile/src/controls/__tests__/TextInput.test.tsx index 8ca8a4688a..28d582204a 100644 --- a/packages/mobile/src/controls/__tests__/TextInput.test.tsx +++ b/packages/mobile/src/controls/__tests__/TextInput.test.tsx @@ -1,10 +1,21 @@ +import { Animated, StyleSheet } from 'react-native'; +import { focusedInputBorderWidth } from '@coinbase/cds-common/tokens/input'; import { fireEvent, render, screen } from '@testing-library/react-native'; +import { defaultTheme } from '../../themes/defaultTheme'; import { Text } from '../../typography/Text'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { TextInput } from '../TextInput'; describe('TextInput', () => { + const getFocusedBorderOverlayStyle = () => { + const focusedBorderOverlay = screen + .UNSAFE_getAllByType(Animated.View) + .find((view) => StyleSheet.flatten(view.props.style)?.position === 'absolute'); + + return focusedBorderOverlay ? StyleSheet.flatten(focusedBorderOverlay.props.style) : undefined; + }; + it('passes a11y', () => { const testID = 'textinput-id'; render( @@ -59,6 +70,24 @@ describe('TextInput', () => { expect(screen.getByTestId(testID).props.value).toBe(value); }); + it('passes font to native input', () => { + const testID = 'textinput-id'; + render( + + + , + ); + + const flattenedStyle = StyleSheet.flatten(screen.getByTestId(testID).props.style); + expect(flattenedStyle).toEqual( + expect.objectContaining({ + fontSize: defaultTheme.fontSize.label1, + minHeight: defaultTheme.lineHeight.label1, + fontWeight: defaultTheme.fontWeight.label1, + }), + ); + }); + it('renders a label', () => { const testID = 'label-testid'; const labelText = 'Example label'; @@ -268,6 +297,45 @@ describe('TextInput', () => { expect(onBlur).toHaveBeenCalledTimes(1); }); + it('keeps focused border width at 0 by default when bordered is false', () => { + const testID = 'input-testid'; + render( + + + , + ); + + fireEvent(screen.getByTestId(testID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toEqual(expect.objectContaining({ borderWidth: 0 })); + }); + + it('applies focusedBorderWidth when bordered is false', () => { + const testID = 'input-testid'; + render( + + + , + ); + + fireEvent(screen.getByTestId(testID), 'focus'); + const focusedBorderOverlayStyle = getFocusedBorderOverlayStyle(); + expect(focusedBorderOverlayStyle).toEqual( + expect.objectContaining({ borderWidth: focusedInputBorderWidth }), + ); + }); + it('renders label outside by default', () => { const labelTestID = 'label-test'; render( @@ -329,6 +397,156 @@ describe('TextInput', () => { expect(screen.getByText('Compact Label')).toBeTruthy(); }); + it('renders labelNode without compact', () => { + const labelTestID = 'custom-label'; + render( + + Custom Label Node} + placeholder="Enter text" + /> + , + ); + + const customLabel = screen.getByTestId(labelTestID); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Label Node'); + }); + + it('labelNode takes precedence over label without compact', () => { + const labelTestID = 'custom-label'; + render( + + Custom Label Node} + placeholder="Enter text" + /> + , + ); + + const customLabel = screen.getByTestId(labelTestID); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Label Node'); + expect(screen.queryByText('Regular Label')).toBeFalsy(); + }); + + it('renders labelNode when compact is true', () => { + const startTestID = 'start-test'; + const labelTestID = 'custom-label'; + render( + + Custom Label Node} + testIDMap={{ + start: startTestID, + }} + /> + , + ); + + const startNode = screen.getByTestId(startTestID); + const customLabel = screen.getByTestId(labelTestID); + expect(startNode).toBeTruthy(); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Label Node'); + }); + + it('renders labelNode with labelVariant inside', () => { + const labelTestID = 'custom-label'; + render( + + Custom Inside Label} + labelVariant="inside" + placeholder="Enter text" + /> + , + ); + + const customLabel = screen.getByTestId(labelTestID); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Inside Label'); + }); + + it('labelNode takes precedence over label with labelVariant inside', () => { + const labelTestID = 'custom-label'; + render( + + Custom Inside Label} + labelVariant="inside" + placeholder="Enter text" + /> + , + ); + + const customLabel = screen.getByTestId(labelTestID); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Inside Label'); + expect(screen.queryByText('Regular Label')).toBeFalsy(); + }); + + it('renders labelNode with labelVariant inside and start content', () => { + const labelTestID = 'custom-label'; + const startTestID = 'start-content'; + render( + + Custom Inside Label} + labelVariant="inside" + placeholder="Enter text" + start={Start} + /> + , + ); + + const customLabel = screen.getByTestId(labelTestID); + const startContent = screen.getByTestId(startTestID); + expect(customLabel).toBeTruthy(); + expect(startContent).toBeTruthy(); + }); + + it('labelNode takes precedence over label when compact is true', () => { + const startTestID = 'start-test'; + const labelTestID = 'custom-label'; + render( + + Custom Label Node} + testIDMap={{ + start: startTestID, + }} + /> + , + ); + + const startNode = screen.getByTestId(startTestID); + const customLabel = screen.getByTestId(labelTestID); + expect(startNode).toBeTruthy(); + expect(customLabel).toBeTruthy(); + expect(customLabel).toHaveTextContent('Custom Label Node'); + expect(screen.queryByText('Regular Label')).toBeFalsy(); + }); + it('positions label correctly with inside variant and start content', () => { render( diff --git a/packages/mobile/src/core/componentConfig.ts b/packages/mobile/src/core/componentConfig.ts new file mode 100644 index 0000000000..1b3abba7d9 --- /dev/null +++ b/packages/mobile/src/core/componentConfig.ts @@ -0,0 +1,167 @@ +import type { AccordionBaseProps } from '../accordion/Accordion'; +import type { SelectBaseProps } from '../alpha'; +import type { ComboboxBaseProps } from '../alpha/combobox/Combobox'; +import type { SelectChipBaseProps } from '../alpha/select-chip/SelectChip'; +import type { TabbedChipsBaseProps } from '../alpha/tabbed-chips/TabbedChips'; +import type { BannerBaseProps } from '../banner/Banner'; +import type { AvatarButtonBaseProps } from '../buttons/AvatarButton'; +import type { ButtonBaseProps } from '../buttons/Button'; +import type { ButtonGroupBaseProps } from '../buttons/ButtonGroup'; +import type { IconButtonBaseProps } from '../buttons/IconButton'; +import type { IconCounterButtonBaseProps } from '../buttons/IconCounterButton'; +import type { SlideButtonBaseProps } from '../buttons/SlideButton'; +import type { CardBaseProps } from '../cards/Card'; +import type { CardBodyBaseProps } from '../cards/CardBody'; +import type { CardFooterBaseProps } from '../cards/CardFooter'; +import type { LikeButtonBaseProps } from '../cards/LikeButton'; +import type { CarouselBaseProps } from '../carousel/Carousel'; +import type { CellBaseProps } from '../cells/Cell'; +import type { ListCellBaseProps } from '../cells/ListCell'; +import type { ListCellFallbackBaseProps } from '../cells/ListCellFallback'; +import type { ChipBaseProps, InputChipBaseProps } from '../chips/ChipProps'; +import type { MediaChipBaseProps } from '../chips/MediaChip'; +import type { CoachmarkBaseProps } from '../coachmark/Coachmark'; +import type { CollapsibleBaseProps } from '../collapsible/Collapsible'; +import type { CheckboxBaseProps } from '../controls/Checkbox'; +import type { CheckboxCellBaseProps } from '../controls/CheckboxCell'; +import type { ControlBaseProps } from '../controls/Control'; +import type { ControlGroupBaseProps } from '../controls/ControlGroup'; +import type { InputStackBaseProps } from '../controls/InputStack'; +import type { RadioBaseProps } from '../controls/Radio'; +import type { RadioCellBaseProps } from '../controls/RadioCell'; +import type { SearchInputBaseProps } from '../controls/SearchInput'; +import type { SelectOptionBaseProps } from '../controls/SelectOption'; +import type { SwitchBaseProps } from '../controls/Switch'; +import type { TextInputBaseProps } from '../controls/TextInput'; +import type { CalendarBaseProps } from '../dates/Calendar'; +import type { DateInputBaseProps } from '../dates/DateInput'; +import type { DatePickerBaseProps } from '../dates/DatePicker'; +import type { DotCountBaseProps } from '../dots/DotCount'; +import type { DotStatusColorBaseProps } from '../dots/DotStatusColor'; +import type { DotSymbolBaseProps } from '../dots/DotSymbol'; +import type { IconBaseProps } from '../icons/Icon'; +import type { DividerBaseProps } from '../layout/Divider'; +import type { FallbackBaseProps } from '../layout/Fallback'; +import type { AvatarBaseProps } from '../media/Avatar'; +import type { RemoteImageBaseProps } from '../media/RemoteImage'; +import type { RemoteImageGroupBaseProps } from '../media/RemoteImageGroup'; +import type { BrowserBarBaseProps } from '../navigation/BrowserBar'; +import type { NavigationTitleBaseProps } from '../navigation/NavigationTitle'; +import type { NavigationTitleSelectBaseProps } from '../navigation/NavigationTitleSelect'; +import type { NavigationBarBaseProps } from '../navigation/TopNavBar'; +import type { RollingNumberBaseProps } from '../numbers/RollingNumber/RollingNumber'; +import type { NumpadBaseProps } from '../numpad/Numpad'; +import type { AlertBaseProps } from '../overlays/Alert'; +import type { DrawerBaseProps } from '../overlays/drawer/Drawer'; +import type { ModalBaseProps } from '../overlays/modal/Modal'; +import type { ModalBodyBaseProps } from '../overlays/modal/ModalBody'; +import type { ModalFooterBaseProps } from '../overlays/modal/ModalFooter'; +import type { ModalHeaderBaseProps } from '../overlays/modal/ModalHeader'; +import type { OverlayBaseProps } from '../overlays/overlay/Overlay'; +import type { ToastBaseProps } from '../overlays/Toast'; +import type { TooltipBaseProps } from '../overlays/tooltip/Tooltip'; +import type { TrayBaseProps } from '../overlays/tray/Tray'; +import type { PageFooterBaseProps } from '../page/PageFooter'; +import type { PageHeaderBaseProps } from '../page/PageHeader'; +import type { StepperBaseProps } from '../stepper/Stepper'; +import type { SegmentedTabBaseProps } from '../tabs/SegmentedTab'; +import type { SegmentedTabsBaseProps } from '../tabs/SegmentedTabs'; +import type { TabsBaseProps } from '../tabs/Tabs'; +import type { TagBaseProps } from '../tag/Tag'; +import type { TourBaseProps } from '../tour/Tour'; +import type { LinkBaseProps } from '../typography/Link'; +import type { ProgressBaseProps } from '../visualizations/ProgressBar'; +import type { ProgressBarWithFixedLabelsBaseProps } from '../visualizations/ProgressBarWithFixedLabels'; +import type { ProgressBarWithFloatLabelBaseProps } from '../visualizations/ProgressBarWithFloatLabel'; +import type { ProgressCircleBaseProps } from '../visualizations/ProgressCircle'; + +/** + * Config resolver that supports either static partial props object + * or a function that receives component props and returns partial props. + */ +export type ConfigResolver

    = Partial

    | ((props: P) => Partial

    ); + +/** + * Component config for customization of default ComponentBaseProps. + * + * @note components that aren't listed here are either primitives or sub-components with limited customization opportunities. + */ +export type ComponentConfig = { + Accordion?: ConfigResolver; + Alert?: ConfigResolver; + TabbedChips?: ConfigResolver; + Avatar?: ConfigResolver; + AvatarButton?: ConfigResolver; + Banner?: ConfigResolver; + BrowserBar?: ConfigResolver; + Button?: ConfigResolver; + ButtonGroup?: ConfigResolver; + Card?: ConfigResolver; + CardBody?: ConfigResolver; + CardFooter?: ConfigResolver; + Carousel?: ConfigResolver; + Cell?: ConfigResolver; + Chip?: ConfigResolver; + Checkbox?: ConfigResolver>; + CheckboxCell?: ConfigResolver>; + Coachmark?: ConfigResolver; + Collapsible?: ConfigResolver; + Combobox?: ConfigResolver; + Calendar?: ConfigResolver; + Control?: ConfigResolver>; + ControlGroup?: ConfigResolver; + DateInput?: ConfigResolver; + DatePicker?: ConfigResolver; + Divider?: ConfigResolver; + DotCount?: ConfigResolver; + DotStatusColor?: ConfigResolver; + DotSymbol?: ConfigResolver; + Drawer?: ConfigResolver; + Fallback?: ConfigResolver; + Icon?: ConfigResolver; + IconButton?: ConfigResolver; + IconCounterButton?: ConfigResolver; + InputChip?: ConfigResolver; + InputStack?: ConfigResolver; + LikeButton?: ConfigResolver; + Link?: ConfigResolver; + ListCell?: ConfigResolver; + ListCellFallback?: ConfigResolver; + MediaChip?: ConfigResolver; + Modal?: ConfigResolver; + ModalBody?: ConfigResolver; + ModalFooter?: ConfigResolver; + ModalHeader?: ConfigResolver; + NavigationTitle?: ConfigResolver; + NavigationTitleSelect?: ConfigResolver; + Numpad?: ConfigResolver; + Overlay?: ConfigResolver; + PageFooter?: ConfigResolver; + PageHeader?: ConfigResolver; + ProgressBar?: ConfigResolver; + ProgressBarWithFixedLabels?: ConfigResolver; + ProgressBarWithFloatLabel?: ConfigResolver; + ProgressCircle?: ConfigResolver; + Radio?: ConfigResolver>; + RadioCell?: ConfigResolver>; + RemoteImage?: ConfigResolver; + RemoteImageGroup?: ConfigResolver; + RollingNumber?: ConfigResolver; + SearchInput?: ConfigResolver; + SegmentedTab?: ConfigResolver; + SegmentedTabs?: ConfigResolver; + Select?: ConfigResolver; + SelectChip?: ConfigResolver; + SelectOption?: ConfigResolver; + SlideButton?: ConfigResolver; + Stepper?: ConfigResolver; + Switch?: ConfigResolver>; + Tabs?: ConfigResolver; + Tag?: ConfigResolver; + TextInput?: ConfigResolver; + Toast?: ConfigResolver; + TopNavBar?: ConfigResolver; + Tour?: ConfigResolver; + Tray?: ConfigResolver; + Tooltip?: ConfigResolver; +}; diff --git a/packages/mobile/src/dates/Calendar.tsx b/packages/mobile/src/dates/Calendar.tsx new file mode 100644 index 0000000000..20d104a97b --- /dev/null +++ b/packages/mobile/src/dates/Calendar.tsx @@ -0,0 +1,542 @@ +import { + forwardRef, + memo, + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { + type StyleProp, + StyleSheet, + type TextStyle, + type View, + type ViewStyle, +} from 'react-native'; +import { generateCalendarMonth } from '@coinbase/cds-common/dates/generateCalendarMonth'; +import { getMidnightDate } from '@coinbase/cds-common/dates/getMidnightDate'; +import { getTimesFromDatesAndRanges } from '@coinbase/cds-common/dates/getTimesFromDatesAndRanges'; +import { useLocale } from '@coinbase/cds-common/system/LocaleProvider'; +import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; +import type { SharedProps } from '@coinbase/cds-common/types'; + +import { useA11y } from '../hooks/useA11y'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { useScreenReaderStatus } from '../hooks/useScreenReaderStatus'; +import { Icon } from '../icons/Icon'; +import { Box, type BoxBaseProps } from '../layout/Box'; +import { HStack } from '../layout/HStack'; +import { VStack, type VStackProps } from '../layout/VStack'; +import { Tooltip } from '../overlays/tooltip/Tooltip'; +import { Pressable, type PressableBaseProps } from '../system/Pressable'; +import { Text } from '../typography/Text'; + +const CALENDAR_DAY_DIMENSION = 40; + +// These could be dynamically generated, but our Calendar and DatePicker aren't localized so there's no point +const DAYS_OF_WEEK = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +const styles = StyleSheet.create({ + pressable: { + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + }, +}); + +export type CalendarPressableBaseProps = PressableBaseProps & { + borderRadius?: number; + width?: number; + height?: number; + background?: 'transparent' | 'bg' | 'bgPrimary'; +}; + +const CalendarPressable = memo( + forwardRef( + ({ background = 'transparent', borderRadius = 1000, children, ...props }, ref) => { + return ( + + {children} + + ); + }, + ), +); + +CalendarPressable.displayName = 'CalendarPressable'; + +export type CalendarDayProps = { + /** Date of this CalendarDay. */ + date: Date; + /** Callback function fired when pressing this CalendarDay. */ + onPress?: (date: Date) => void; + /** Toggle active styles. */ + active?: boolean; + /** Disables user interaction. */ + disabled?: boolean; + /** Toggle highlighted styles. */ + highlighted?: boolean; + /** Toggle today's date styles. */ + isToday?: boolean; + /** Toggle current month styles. */ + isCurrentMonth?: boolean; + /** Tooltip content shown when hovering or focusing a disabled Calendar Day. */ + disabledError?: string; + /** Accessibility hint for the current day when it is not disabled. */ + todayAccessibilityHint?: string; + /** Accessibility hint announced for highlighted dates. */ + highlightedDateAccessibilityHint?: string; + /** Custom style for the date cell pressable wrapper */ + style?: StyleProp; +}; + +const getDayAccessibilityLabel = (date: Date, locale = 'en-US') => + `${date.toLocaleDateString(locale, { + weekday: 'long', + day: 'numeric', + })} ${date.toLocaleDateString(locale, { + month: 'long', + year: 'numeric', + })}`; + +const CalendarDay = memo( + forwardRef( + ( + { + date, + active, + disabled, + highlighted, + isToday, + isCurrentMonth, + onPress, + disabledError, + todayAccessibilityHint, + highlightedDateAccessibilityHint, + style, + }, + ref, + ) => { + const { locale } = useLocale(); + const handlePress = useCallback(() => onPress?.(date), [date, onPress]); + const accessibilityLabel = useMemo( + () => getDayAccessibilityLabel(date, locale), + [date, locale], + ); + const accessibilityState = useMemo( + () => ({ disabled: !!disabled, selected: !!active }), + [disabled, active], + ); + + // Period between phrases gives screen readers a clear pause (e.g. "Today. Date unavailable"). + const accessibilityHint = useMemo(() => { + const hints = [ + isToday ? todayAccessibilityHint : undefined, + highlighted ? highlightedDateAccessibilityHint : undefined, + disabled ? disabledError : undefined, + ] + .filter(Boolean) + .join('. '); + return hints || undefined; + }, [ + disabled, + highlighted, + isToday, + todayAccessibilityHint, + highlightedDateAccessibilityHint, + disabledError, + ]); + + const isScreenReaderEnabled = useScreenReaderStatus(); + + // Expose disabled to the tooltip's accessibilityState so screen readers on both platforms + // announce the day button as disabled. We only set disabled when a screen reader is active: + // on some platforms a11y disabled is equivalent to the top-level disabled prop, so always + // setting it would block tooltip interactivity for users not using SRs. + const tooltipAccessibilityState = useMemo( + () => ({ disabled: isScreenReaderEnabled }), + [isScreenReaderEnabled], + ); + + if (!isCurrentMonth) { + return ( + + ); + } + + const dayButton = ( + + + {date.getDate()} + + + ); + + if (disabled) { + return ( + + {dayButton} + + ); + } + + return dayButton; + }, + ), +); + +CalendarDay.displayName = 'CalendarDay'; + +export type CalendarRefHandle = { + /** Sets accessibility focus on the selected date, seed date, or today. */ + focusInitialDate: () => void; +}; + +export type CalendarBaseProps = SharedProps & + Omit & { + /** Currently selected Calendar date. Date used to generate the Calendar month. Will be rendered with active styles. */ + selectedDate?: Date | null; + /** Date used to generate the Calendar month when there is no value for the `selectedDate` prop, defaults to today. */ + seedDate?: Date; + /** Callback function fired when pressing a Calendar date. */ + onPressDate?: (date: Date) => void; + /** Disables user interaction. */ + disabled?: boolean; + /** Hides the Calendar next and previous month arrows. This probably only makes sense to be used when `minDate` and `maxDate` are set to the first and last days of the same month. */ + hideControls?: boolean; + /** Array of disabled dates, and date tuples for date ranges. Make sure to set `disabledDateError` as well. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. */ + disabledDates?: (Date | [Date, Date])[]; + /** Array of highlighted dates, and date tuples for date ranges. A number is created for every individual date within a tuple range, so do not abuse this with massive ranges. */ + highlightedDates?: (Date | [Date, Date])[]; + /** Minimum date allowed to be selected, inclusive. Dates before the `minDate` are disabled. All navigation to months before the `minDate` is disabled. */ + minDate?: Date; + /** Maximum date allowed to be selected, inclusive. Dates after the `maxDate` are disabled. All navigation to months after the `maxDate` is disabled. */ + maxDate?: Date; + /** + * Tooltip content shown when hovering or focusing a disabled date, including dates before the `minDate` or after the `maxDate`. + * @default 'Date unavailable' + */ + disabledDateError?: string; + /** + * Accessibility label describing the Calendar next month arrow. + * @default 'Go to next month' + */ + nextArrowAccessibilityLabel?: string; + /** + * Accessibility label describing the Calendar previous month arrow. + * @default 'Go to previous month' + */ + previousArrowAccessibilityLabel?: string; + /** + * Accessibility hint for the current day when it is not disabled. Omit or leave default for non-localized usage. + * @default 'Today' + */ + todayAccessibilityHint?: string; + /** + * Accessibility hint announced for highlighted dates. Applied to all highlighted dates. + * @default 'Highlighted' + */ + highlightedDateAccessibilityHint?: string; + }; + +export type CalendarProps = CalendarBaseProps & + Omit & { + /** Custom styles for individual elements of the Calendar component. */ + styles?: { + /** Root container element */ + root?: StyleProp; + /** Header row containing month label and navigation arrows */ + header?: StyleProp; + /** Month and year title text element */ + title?: StyleProp; + /** Navigation controls element */ + navigation?: StyleProp; + /** Container for the days-of-week header and the date grid */ + content?: StyleProp; + /** Individual date cell element, basic ViewStyle applied to the pressable wrapper */ + day?: StyleProp; + }; + }; + +export const Calendar = memo( + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('Calendar', _props); + const { + selectedDate, + seedDate, + onPressDate, + disabled, + hideControls, + disabledDates, + highlightedDates, + minDate, + maxDate, + disabledDateError = 'Date unavailable', + nextArrowAccessibilityLabel = 'Go to next month', + previousArrowAccessibilityLabel = 'Go to previous month', + todayAccessibilityHint = 'Today', + highlightedDateAccessibilityHint = 'Highlighted', + style, + styles, + ...props + } = mergedProps; + const { setA11yFocus, announceForA11y } = useA11y(); + const today = useMemo(() => getMidnightDate(new Date()), []); + const todayTime = useMemo(() => today.getTime(), [today]); + + // Determine default calendar seed date: use whichever comes first between maxDate and today + const defaultSeedDate = useMemo(() => { + if (selectedDate) { + return selectedDate; + } + if (seedDate) { + return seedDate; + } + if (maxDate) { + const maxDateTime = getMidnightDate(maxDate).getTime(); + const todayTime = today.getTime(); + return maxDateTime < todayTime ? maxDate : today; + } + return today; + }, [selectedDate, seedDate, maxDate, today]); + + const [calendarSeedDate, setCalendarSeedDate] = useState(defaultSeedDate); + + const initialFocusRef = useRef(null); + const calendarMonth = useMemo( + () => generateCalendarMonth(calendarSeedDate), + [calendarSeedDate], + ); + + const selectedTime = useMemo( + () => (selectedDate ? getMidnightDate(selectedDate).getTime() : null), + [selectedDate], + ); + + const disabledTimes = useMemo( + () => new Set(getTimesFromDatesAndRanges(disabledDates || [])), + [disabledDates], + ); + + const focusTargetTime = useMemo( + () => selectedTime || (seedDate ? getMidnightDate(seedDate).getTime() : null) || todayTime, + [selectedTime, seedDate, todayTime], + ); + + useImperativeHandle( + ref, + () => ({ + focusInitialDate: () => { + if (disabled || !initialFocusRef.current) { + return; + } + setA11yFocus(initialFocusRef); + }, + }), + [disabled, setA11yFocus], + ); + + const minTime = useMemo(() => minDate && getMidnightDate(minDate).getTime(), [minDate]); + + const maxTime = useMemo(() => maxDate && getMidnightDate(maxDate).getTime(), [maxDate]); + + const highlightedTimes = useMemo( + () => new Set(getTimesFromDatesAndRanges(highlightedDates || [])), + [highlightedDates], + ); + + const handleGoNextMonth = useCallback(() => { + setCalendarSeedDate((s) => { + const next = new Date(s.getFullYear(), s.getMonth() + 1, 1); + announceForA11y(next.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })); + return next; + }); + }, [setCalendarSeedDate, announceForA11y]); + + const handleGoPreviousMonth = useCallback(() => { + setCalendarSeedDate((s) => { + const prev = new Date(s.getFullYear(), s.getMonth() - 1, 1); + announceForA11y(prev.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })); + return prev; + }); + }, [setCalendarSeedDate, announceForA11y]); + + const disableGoNextMonth = useMemo(() => { + if (disabled) { + return true; + } + const firstDateOfNextMonth = new Date( + calendarSeedDate.getFullYear(), + calendarSeedDate.getMonth() + 1, + 1, + ); + return maxTime ? maxTime < firstDateOfNextMonth.getTime() : false; + }, [maxTime, calendarSeedDate, disabled]); + + const disableGoPreviousMonth = useMemo(() => { + if (disabled) { + return true; + } + const lastDateOfPreviousMonth = new Date( + calendarSeedDate.getFullYear(), + calendarSeedDate.getMonth(), + 0, + ); + return minTime ? minTime > lastDateOfPreviousMonth.getTime() : false; + }, [minTime, calendarSeedDate, disabled]); + + // Split calendar month into weeks + const calendarWeeks = useMemo(() => { + const weeks: [string, Date[]][] = []; + for (let i = 0; i < calendarMonth.length; i += DAYS_OF_WEEK.length) { + const weekDates = calendarMonth.slice(i, i + DAYS_OF_WEEK.length); + weeks.push([`week-${calendarMonth[i].getTime()}`, weekDates]); + } + return weeks; + }, [calendarMonth]); + + const monthYearLabel = useMemo( + () => + calendarSeedDate.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }), + [calendarSeedDate], + ); + + const previousArrowAccessibilityState = useMemo( + () => ({ disabled: !!disableGoPreviousMonth }), + [disableGoPreviousMonth], + ); + const nextArrowAccessibilityState = useMemo( + () => ({ disabled: !!disableGoNextMonth }), + [disableGoNextMonth], + ); + + return ( + + + + {monthYearLabel} + + {!hideControls && ( + + + + + + + + + )} + + + + + {DAYS_OF_WEEK.map((day) => ( + + + {day.charAt(0)} + + + ))} + + {calendarWeeks.map(([weekId, week]) => ( + + {week.map((date) => { + const time = date.getTime(); + return ( + maxTime) || + disabledTimes.has(time) + } + disabledError={disabledDateError} + highlighted={highlightedTimes.has(time)} + highlightedDateAccessibilityHint={highlightedDateAccessibilityHint} + isCurrentMonth={date.getMonth() === calendarSeedDate.getMonth()} + isToday={time === todayTime} + onPress={onPressDate} + style={styles?.day} + todayAccessibilityHint={todayAccessibilityHint} + /> + ); + })} + + ))} + + + ); + }), +); + +Calendar.displayName = 'Calendar'; diff --git a/packages/mobile/src/dates/DateInput.tsx b/packages/mobile/src/dates/DateInput.tsx index dedd0aaf6c..59a9ed9a78 100644 --- a/packages/mobile/src/dates/DateInput.tsx +++ b/packages/mobile/src/dates/DateInput.tsx @@ -12,126 +12,130 @@ import { IntlDateFormat } from '@coinbase/cds-common/dates/IntlDateFormat'; import { type DateInputOptions, useDateInput } from '@coinbase/cds-common/dates/useDateInput'; import { useLocale } from '@coinbase/cds-common/system/LocaleProvider'; -import { TextInput, type TextInputProps } from '../controls/TextInput'; +import { TextInput, type TextInputBaseProps, type TextInputProps } from '../controls/TextInput'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { VStack } from '../layout/VStack'; -export type DateInputProps = { - /** Date format separator character, e.g. the / in "MM/DD/YYYY". Defaults to forward slash (/). */ - separator?: string; - style?: StyleProp; -} & Omit & - Omit; +export type DateInputBaseProps = Omit & + Omit & { + /** Date format separator character, e.g. the / in "MM/DD/YYYY". Defaults to forward slash (/). */ + separator?: string; + }; + +export type DateInputProps = DateInputBaseProps & + Omit & { + style?: StyleProp; + }; export const DateInput = memo( - forwardRef( - ( - { - date, - onChangeDate, - error, - onErrorDate, - required, - separator = '/', - minDate, - maxDate, - requiredError, - invalidDateError, - disabledDateError, - start, - end, - placeholder, - helperText, - variant, - onBlur, - onChange, - onEndEditing, - testIDMap, - style, - ...props - }, - ref, - ) => { - const hasTyped = useRef(Boolean(date)); - const { locale } = useLocale(); - const intlDateFormat = useMemo( - () => new IntlDateFormat({ locale, separator }), - [locale, separator], - ); + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('DateInput', _props); + const { + date, + onChangeDate, + error, + onErrorDate, + required, + separator = '/', + disabledDates, + minDate, + maxDate, + requiredError, + invalidDateError, + disabledDateError, + start, + end, + placeholder, + helperText, + variant, + onBlur, + onChange, + onEndEditing, + testIDMap, + style, + ...props + } = mergedProps; + const hasTyped = useRef(Boolean(date)); + const { locale } = useLocale(); + const intlDateFormat = useMemo( + () => new IntlDateFormat({ locale, separator }), + [locale, separator], + ); - const { - inputValue, - onChangeDateInput, - validateDateInput, - placeholder: defaultPlaceholder, - } = useDateInput({ - date, - onChangeDate, - error, - onErrorDate, - intlDateFormat, - required, - minDate, - maxDate, - requiredError, - invalidDateError, - disabledDateError, - }); + const { + inputValue, + onChangeDateInput, + validateDateInput, + placeholder: defaultPlaceholder, + } = useDateInput({ + date, + onChangeDate, + error, + onErrorDate, + intlDateFormat, + required, + disabledDates, + minDate, + maxDate, + requiredError, + invalidDateError, + disabledDateError, + }); - /** - * Be careful to preserve the correct event orders - * 1. Typing a date in a blank DateInput: onChange -> onChange -> ... -> onChangeDate -> onErrorDate - * 2. Typing a date in a DateInput that already had a date: onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate - */ + /** + * Be careful to preserve the correct event orders + * 1. Typing a date in a blank DateInput: onChange -> onChange -> ... -> onChangeDate -> onErrorDate + * 2. Typing a date in a DateInput that already had a date: onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate + */ - const handleBlur = useCallback( - (event: NativeSyntheticEvent) => { - onBlur?.(event); - if (!required || !hasTyped.current) return; - const error = validateDateInput(inputValue); - if (error) onErrorDate(error); - }, - [onBlur, required, validateDateInput, inputValue, onErrorDate], - ); + const handleBlur = useCallback( + (event: NativeSyntheticEvent) => { + onBlur?.(event); + if (!required || !hasTyped.current) return; + const error = validateDateInput(inputValue); + if (error) onErrorDate(error); + }, + [onBlur, required, validateDateInput, inputValue, onErrorDate], + ); - const handleEndEditing = useCallback( - (event: NativeSyntheticEvent) => { - onEndEditing?.(event); - if (!required || !hasTyped.current) return; - const error = validateDateInput(inputValue); - if (error) onErrorDate(error); - }, - [onEndEditing, required, validateDateInput, inputValue, onErrorDate], - ); + const handleEndEditing = useCallback( + (event: NativeSyntheticEvent) => { + onEndEditing?.(event); + if (!required || !hasTyped.current) return; + const error = validateDateInput(inputValue); + if (error) onErrorDate(error); + }, + [onEndEditing, required, validateDateInput, inputValue, onErrorDate], + ); - const handleChange = useCallback( - (event: NativeSyntheticEvent) => { - hasTyped.current = true; - onChange?.(event); - onChangeDateInput(event.nativeEvent.text); - }, - [onChange, onChangeDateInput], - ); + const handleChange = useCallback( + (event: NativeSyntheticEvent) => { + hasTyped.current = true; + onChange?.(event); + onChangeDateInput(event.nativeEvent.text); + }, + [onChange, onChangeDateInput], + ); - return ( - - - - ); - }, - ), + return ( + + + + ); + }), ); diff --git a/packages/mobile/src/dates/DatePicker.tsx b/packages/mobile/src/dates/DatePicker.tsx index 307727f9ee..ac073f2919 100644 --- a/packages/mobile/src/dates/DatePicker.tsx +++ b/packages/mobile/src/dates/DatePicker.tsx @@ -4,18 +4,35 @@ import { type StyleProp, type TextInput, type TextInputChangeEventData, + type TextStyle, type View, type ViewStyle, } from 'react-native'; -import NativeDatePicker from 'react-native-date-picker'; -import { type DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import type { DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import { Button } from '../buttons/Button'; import { InputIconButton } from '../controls/InputIconButton'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, VStack } from '../layout'; +import { Tray } from '../overlays/tray/Tray'; +import { StickyFooter } from '../sticky-footer/StickyFooter'; +import { Calendar, type CalendarBaseProps, type CalendarRefHandle } from './Calendar'; import { DateInput, type DateInputProps } from './DateInput'; -export type DatePickerProps = { +export type DatePickerBaseProps = Pick< + CalendarBaseProps, + | 'disabled' + | 'disabledDates' + | 'disabledDateError' + | 'highlightedDateAccessibilityHint' + | 'highlightedDates' + | 'maxDate' + | 'minDate' + | 'nextArrowAccessibilityLabel' + | 'previousArrowAccessibilityLabel' + | 'seedDate' +> & { /** Control the date value of the DatePicker. */ date: Date | null; /** Callback function fired when the date changes, e.g. when a valid date is selected or unselected. */ @@ -24,179 +41,277 @@ export type DatePickerProps = { error: DateInputValidationError | null; /** Callback function fired when validation finds an error, e.g. required input fields and impossible or disabled dates. Will always be called after `onChangeDate`. */ onErrorDate: (error: DateInputValidationError | null) => void; - /** Date that the react-native-date-picker keyboard control will open to when there is no value for the `date` prop, defaults to today. */ - seedDate?: Date; - /** Disables user interaction. */ - disabled?: boolean; - /** Minimum date allowed to be selected, inclusive. Dates before the `minDate` are disabled. All navigation to months before the `minDate` is disabled. */ - minDate?: Date; - /** Maximum date allowed to be selected, inclusive. Dates after the `maxDate` are disabled. All navigation to months after the `maxDate` is disabled. */ - maxDate?: Date; - /** - * Error text to display when a disabled date is selected with the DateInput, including dates before the `minDate` or after the `maxDate`. - * @default 'Date unavailable' - */ - disabledDateError?: string; - /** Callback function fired when the DateInput text value changes. Prefer to use `onChangeDate` instead. Will always be called before `onChangeDate`. This prop should only be used for edge cases, such as custom error handling. */ - onChange?: (event: NativeSyntheticEvent) => void; - /** Callback function fired when the react-native-date-picker keyboard control is opened. */ + /** Callback function fired when the picker is opened. */ onOpen?: () => void; - /** Callback function fired when the react-native-date-picker keyboard control is closed. Will always be called after `onCancel`, `onConfirm`, and `onChangeDate`. */ + /** Callback function fired when the picker is closed. Will always be called after `onCancel`, `onConfirm`, and `onChangeDate`. */ onClose?: () => void; - /** Callback function fired when the user selects a date using the react-native-date-picker keyboard control. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ + /** Callback function fired when the user selects a date using the picker. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ onConfirm?: () => void; - /** Callback function fired when the user closes the react-native-date-picker keyboard control without selecting a date. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ + /** Callback function fired when the user closes the picker without selecting a date. Interacting with the DateInput does not fire this callback. Will always be called before `onClose`. */ onCancel?: () => void; /** * Accessibility label describing the calendar IconButton, which opens the calendar when pressed. - * @default 'Open calendar' / 'Close calendar' + * @deprecated Use openCalendarAccessibilityLabel/closeCalendarAccessibilityLabel instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 */ calendarIconButtonAccessibilityLabel?: string; - dateInputStyle?: StyleProp; -} & Omit< - DateInputProps, - | 'date' - | 'separator' - | 'onChangeDate' - | 'disabledDates' - | 'minDate' - | 'maxDate' - | 'disabledDateError' - | 'style' ->; - -export const DatePicker = memo( - forwardRef( - ( - { - date, - onChangeDate, - error, - onErrorDate, - required, - disabled, - seedDate, - minDate, - maxDate, - requiredError = 'This field is required', - invalidDateError = 'Please enter a valid date', - disabledDateError = 'Date unavailable', - label, - accessibilityLabel, - accessibilityLabelledBy, - calendarIconButtonAccessibilityLabel, - dateInputStyle, - compact, - variant, - helperText, - width = '100%', - onOpen, - onClose, - onConfirm, - onCancel, - onChange, - ...props - }, - ref, - ) => { - const [showNativePicker, setShowNativePicker] = useState(false); - const dateInputRef = useRef(null); + /** + * Accessibility label for the calendar IconButton, which opens the calendar when pressed. + * @default 'Open calendar' + */ + openCalendarAccessibilityLabel?: string; + /** + * Accessibility label for the handle bar that closes the picker. + * @default 'Close calendar without selecting a date' + */ + closeCalendarAccessibilityLabel?: string; +}; - const today = useMemo(() => new Date(), []); +export type DatePickerProps = DatePickerBaseProps & + Omit< + DateInputProps, + | 'date' + | 'separator' + | 'onChangeDate' + | 'disabledDates' + | 'minDate' + | 'maxDate' + | 'disabledDateError' + | 'style' + > & { + /** Callback function fired when the DateInput text value changes. Prefer to use `onChangeDate` instead. Will always be called before `onChangeDate`. This prop should only be used for edge cases, such as custom error handling. */ + onChange?: (event: NativeSyntheticEvent) => void; + /** + * Custom style to apply to the DateInput. + * @deprecated Use `styles.dateInput` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ + dateInputStyle?: StyleProp; + /** + * Text to display on the confirm button. + * @default 'Confirm' + */ + confirmText?: string; + /** + * Accessibility hint for the confirm button. + */ + confirmButtonAccessibilityHint?: string; + /** Custom styles for the DateInput and Calendar subcomponents. */ + styles?: { + dateInput?: DateInputProps['style']; + calendar?: StyleProp; + calendarHeader?: StyleProp; + calendarTitle?: StyleProp; + calendarNavigation?: StyleProp; + calendarContent?: StyleProp; + calendarDay?: StyleProp; + }; + }; - /** - * Be careful to preserve the correct event orders - * 1. Selecting a date with the native picker: onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose - * 2. Closing the native picker without selecting a date: onOpen -> onCancel -> onClose - * 3. Typing a date in a blank DateInput: onChange -> onChange -> ... -> onChangeDate -> onErrorDate - * 4. Typing a date in a DateInput that already had a date: onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate - */ +export const DatePicker = memo( + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('DatePicker', _props); + const { + date, + styles, + highlightedDates, + highlightedDateAccessibilityHint, + nextArrowAccessibilityLabel, + previousArrowAccessibilityLabel, + disabledDates, + onChangeDate, + error, + onErrorDate, + required, + disabled, + seedDate, + minDate, + maxDate, + requiredError = 'This field is required', + invalidDateError = 'Please enter a valid date', + disabledDateError = 'Date unavailable', + label, + accessibilityHint = 'Enter date or select from calendar using the calendar button.', + accessibilityLabel, + accessibilityLabelledBy, + calendarIconButtonAccessibilityLabel, + openCalendarAccessibilityLabel = 'Open calendar', + closeCalendarAccessibilityLabel = 'Close calendar without selecting a date', + dateInputStyle, + compact, + variant, + confirmText = 'Confirm', + confirmButtonAccessibilityHint, + helperText, + width = '100%', + onOpen, + onClose, + onConfirm, + onCancel, + onChange, + ...props + } = mergedProps; + const [showPicker, setShowPicker] = useState(false); + const [calendarSelectedDate, setCalendarSelectedDate] = useState(null); + const dateInputRef = useRef(null); + const calendarButtonRef = useRef(null); + const calendarRef = useRef(null); + const closedByConfirmRef = useRef(false); - const handleOpenNativePicker = useCallback(() => { - onOpen?.(); - setShowNativePicker(true); - }, [onOpen]); + /** + * Be careful to preserve the correct event orders + * 1. Selecting a date with the picker: onOpen -> onConfirm -> onChangeDate -> onErrorDate -> onClose + * 2. Closing the picker without selecting a date: onOpen -> onCancel -> onClose + * 3. Typing a date in a blank DateInput: onChange -> onChange -> ... -> onChangeDate -> onErrorDate + * 4. Typing a date in a DateInput that already had a date: onChange -> onChangeDate -> onChange -> onChange -> ... -> onChangeDate -> onErrorDate + */ - const handleCloseNativePicker = useCallback(() => { - onClose?.(); - setShowNativePicker(false); - }, [onClose]); + const handleOpenPicker = useCallback(() => { + onOpen?.(); + setCalendarSelectedDate(date); // Initialize with current date + setShowPicker(true); + }, [onOpen, date]); - const handleConfirmNativePicker = useCallback( - (date: Date) => { - onConfirm?.(); - onChangeDate(date); - if (error && error.type !== 'custom') onErrorDate(null); - handleCloseNativePicker(); - dateInputRef.current?.focus(); - }, - [onChangeDate, onConfirm, error, onErrorDate, handleCloseNativePicker], - ); + const handleConfirmPicker = useCallback( + (selectedDate: Date) => { + closedByConfirmRef.current = true; + onConfirm?.(); + onChangeDate(selectedDate); + if (error && error.type !== 'custom') { + onErrorDate(null); + } + }, + [onChangeDate, onConfirm, error, onErrorDate], + ); - const handleCancelNativePicker = useCallback(() => { + const handleTrayCloseComplete = useCallback(() => { + if (!closedByConfirmRef.current) { onCancel?.(); - handleCloseNativePicker(); - }, [onCancel, handleCloseNativePicker]); + setCalendarSelectedDate(null); + } + onClose?.(); + setShowPicker(false); + closedByConfirmRef.current = false; + }, [onCancel, onClose]); - const dateInputCalendarButton = useMemo( - () => ( - - - - ), - [handleOpenNativePicker, showNativePicker, calendarIconButtonAccessibilityLabel], - ); + const handleCalendarDatePress = useCallback((selectedDate: Date) => { + // Update local state, user must press confirm button + setCalendarSelectedDate(selectedDate); + }, []); + + const handleModalShow = useCallback(() => { + calendarRef.current?.focusInitialDate(); + }, []); - return ( - - ( + + - {showNativePicker && ( - + ), + [ + handleOpenPicker, + openCalendarAccessibilityLabel, + calendarIconButtonAccessibilityLabel, + disabled, + ], + ); + + return ( + + + {showPicker && ( + ( + + + + )} + handleBarAccessibilityLabel={closeCalendarAccessibilityLabel} + handleBarVariant="inside" + onCloseComplete={handleTrayCloseComplete} + onOpenComplete={handleModalShow} + > + - )} - - ); - }, - ), + + )} + + ); + }), ); + +DatePicker.displayName = 'DatePicker'; diff --git a/packages/mobile/src/dates/__figma__/DatePicker.figma.tsx b/packages/mobile/src/dates/__figma__/DatePicker.figma.tsx index 9b104aead3..1b74267cf2 100644 --- a/packages/mobile/src/dates/__figma__/DatePicker.figma.tsx +++ b/packages/mobile/src/dates/__figma__/DatePicker.figma.tsx @@ -9,7 +9,7 @@ figma.connect( DatePicker, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14743-53206&m=dev', { - imports: ["import { DatePicker } from '@coinbase/cds-mobile/dates/DatePicker';"], + imports: ["import { DatePicker } from '@coinbase/cds-mobile/dates/DatePicker'"], props: { disabled: figma.boolean('disabled'), compact: figma.boolean('compact'), diff --git a/packages/mobile/src/dates/__stories__/Calendar.stories.tsx b/packages/mobile/src/dates/__stories__/Calendar.stories.tsx new file mode 100644 index 0000000000..0488fd6bff --- /dev/null +++ b/packages/mobile/src/dates/__stories__/Calendar.stories.tsx @@ -0,0 +1,382 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useLocale } from '@coinbase/cds-common/system/LocaleProvider'; + +import { Accordion, AccordionItem } from '../../accordion'; +import { Button } from '../../buttons/Button'; +import { Chip } from '../../chips'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; +import { Icon } from '../../icons'; +import { Box } from '../../layout'; +import { AnimatedCaret } from '../../motion/AnimatedCaret'; +import { Tray } from '../../overlays/tray/Tray'; +import { Calendar, type CalendarRefHandle } from '../Calendar'; + +const today = new Date(new Date().setHours(0, 0, 0, 0)); +const nextMonth15th = new Date(today.getFullYear(), today.getMonth() + 1, 15); +const lastMonth15th = new Date(today.getFullYear(), today.getMonth() - 1, 15); +const nextWeek = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7); +const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1); +const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); +const twoDaysAgo = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 2); + +// Generate all weekend date ranges for a wide range (10 years before and after) +const getWeekendDates = (centerDate: Date): [Date, Date][] => { + const weekends: [Date, Date][] = []; + + // Cover 10 years before and after to ensure all weekends are disabled + const startDate = new Date(centerDate.getFullYear() - 10, 0, 1); + const endDate = new Date(centerDate.getFullYear() + 10, 11, 31); + + // Find the first Saturday in the range + const currentDate = new Date(startDate); + const dayOfWeek = currentDate.getDay(); + const daysUntilSaturday = dayOfWeek === 6 ? 0 : (6 - dayOfWeek + 7) % 7; + currentDate.setDate(currentDate.getDate() + daysUntilSaturday); + + // Iterate through weekends, jumping 7 days at a time + while (currentDate <= endDate) { + const saturday = new Date(currentDate); + const sunday = new Date(currentDate); + sunday.setDate(sunday.getDate() + 1); + + // Add the weekend as a date range tuple + weekends.push([saturday, sunday]); + + // Jump to next Saturday (7 days later) + currentDate.setDate(currentDate.getDate() + 7); + } + + return weekends; +}; + +// Compute weekends once at module level +const disabledWeekend = getWeekendDates(today); + +const DATE_ACCORDION_ITEM_KEY = 'date'; + +const formatDateLabel = (date: Date | null, locale: string): string => { + if (!date) { + return 'Select date'; + } + return date.toLocaleDateString(locale, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +}; + +type CalendarTrayTriggerProps = { + formattedLabel: string; + onOpen: () => void; + showPicker: boolean; +}; + +const CalendarTrayExample = memo(function CalendarTrayExample({ + renderTrigger, +}: { + renderTrigger: (props: CalendarTrayTriggerProps) => React.ReactNode; +}) { + const { locale } = useLocale(); + const [date, setDate] = useState(null); + const [showPicker, setShowPicker] = useState(false); + const [calendarSelectedDate, setCalendarSelectedDate] = useState(null); + const calendarRef = useRef(null); + + const handleOpenPicker = useCallback(() => { + setCalendarSelectedDate(date); + setShowPicker(true); + }, [date]); + + const handleClosePicker = useCallback(() => { + setShowPicker(false); + }, []); + + const handleCancelPicker = useCallback(() => { + setCalendarSelectedDate(null); + handleClosePicker(); + }, [handleClosePicker]); + + const handleCalendarDatePress = useCallback((selectedDate: Date) => { + setCalendarSelectedDate(selectedDate); + }, []); + + const handleModalShow = useCallback(() => { + calendarRef.current?.focusInitialDate(); + }, []); + + const handleConfirmCalendar = useCallback(() => { + if (calendarSelectedDate) { + setDate(calendarSelectedDate); + handleClosePicker(); + } + }, [calendarSelectedDate, handleClosePicker]); + + const trayFooter = useMemo( + () => ( + + + + ), + [calendarSelectedDate, handleConfirmCalendar], + ); + + const formattedLabel = formatDateLabel(date, locale); + + const triggerProps = useMemo( + () => ({ + formattedLabel, + onOpen: handleOpenPicker, + showPicker, + }), + [formattedLabel, handleOpenPicker, showPicker], + ); + + return ( + <> + {renderTrigger(triggerProps)} + {showPicker && ( + + + + )} + + ); +}); + +const CalendarChipWithTrayExample = () => { + const renderTrigger = useCallback( + ({ formattedLabel, onOpen, showPicker }: CalendarTrayTriggerProps) => ( + + } + onPress={onOpen} + > + {formattedLabel} + + + ), + [], + ); + return ; +}; + +const CalendarChipWithTrayButtonExample = () => { + const renderTrigger = useCallback( + ({ formattedLabel, onOpen }: CalendarTrayTriggerProps) => ( + + ), + [], + ); + return ; +}; + +const CalendarAccordionExample = () => { + const { locale } = useLocale(); + const [date, setDate] = useState(null); + const [activeKey, setActiveKey] = useState(null); + const expanded = activeKey === DATE_ACCORDION_ITEM_KEY; + const calendarRef = useRef(null); + + const handleDatePress = useCallback((selectedDate: Date) => { + setDate(selectedDate); + setActiveKey(null); + }, []); + + useEffect(() => { + if (expanded) { + const id = requestAnimationFrame(() => { + calendarRef.current?.focusInitialDate(); + }); + return () => cancelAnimationFrame(id); + } + }, [expanded]); + + return ( + + } + subtitle={formatDateLabel(date, locale)} + title="Date" + > + {expanded ? ( + + ) : null} + + + ); +}; + +const CalendarScreen = () => { + const [basicDate, setBasicDate] = useState(today); + const [noSelectionDate, setNoSelectionDate] = useState(null); + const [seedDateDate, setSeedDateDate] = useState(null); + const [minMaxDate, setMinMaxDate] = useState(today); + const [futureDatesDate, setFutureDatesDate] = useState(null); + const [highlightedDate, setHighlightedDate] = useState(today); + const [disabledDatesDate, setDisabledDatesDate] = useState(null); + const [rangeDate, setRangeDate] = useState(today); + const [hiddenControlsDate, setHiddenControlsDate] = useState(today); + + const highlightedRange: [Date, Date] = [yesterday, nextWeek]; + const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); + const lastDayOfMonth = new Date(today.getFullYear(), today.getMonth() + 1, 0); + + const { color } = useTheme(); + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default CalendarScreen; diff --git a/packages/mobile/src/dates/__stories__/DateInput.stories.tsx b/packages/mobile/src/dates/__stories__/DateInput.stories.tsx index 15ca5c4edb..c9858542fa 100644 --- a/packages/mobile/src/dates/__stories__/DateInput.stories.tsx +++ b/packages/mobile/src/dates/__stories__/DateInput.stories.tsx @@ -7,9 +7,11 @@ import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons'; import { HStack } from '../../layout'; import { Group } from '../../layout/Group'; +import { VStack } from '../../layout/VStack'; import { Tooltip } from '../../overlays'; import { ThemeProvider } from '../../system/ThemeProvider'; import { defaultTheme } from '../../themes/defaultTheme'; +import { Text } from '../../typography/Text'; import { DateInput } from '../DateInput'; const today = new Date(new Date(2024, 7, 18).setHours(0, 0, 0, 0)); @@ -42,12 +44,12 @@ export const Examples = () => { + Date of birth - + } @@ -60,6 +62,8 @@ export const Examples = () => { start={} /> + + @@ -67,4 +71,62 @@ export const Examples = () => { ); }; +export const CustomLabel = () => { + const [date, setDate] = useState(null); + const [error, setError] = useState(null); + const props = { date, onChangeDate: setDate, error, onErrorDate: setError }; + return ( + + + + {/* Default with tooltip */} + + Date of birth + + + + + } + /> + {/* Compact with required indicator */} + + Start date + + * + + + } + /> + {/* Inside variant with optional indicator */} + + End date + + (optional) + + + } + labelVariant="inside" + /> + + + + ); +}; + export default Examples; diff --git a/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx b/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx index 7e3dd09d85..d6201c9e23 100644 --- a/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx +++ b/packages/mobile/src/dates/__stories__/DatePicker.stories.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import type { DimensionValue } from '@coinbase/cds-common'; import { type DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; import { InputLabel } from '../../controls/InputLabel'; @@ -7,37 +6,37 @@ import { TextInput } from '../../controls/TextInput'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Icon } from '../../icons'; import { HStack } from '../../layout'; +import { VStack } from '../../layout/VStack'; import { Tooltip } from '../../overlays/tooltip/Tooltip'; -import { DatePicker } from '../DatePicker'; +import { Text } from '../../typography/Text'; +import { DatePicker, type DatePickerProps } from '../DatePicker'; -const today = new Date(new Date(2024, 7, 18).setHours(0, 0, 0, 0)); +const today = new Date(new Date().setHours(0, 0, 0, 0)); const nextMonth15th = new Date(today.getFullYear(), today.getMonth() + 1, 15); -const lastMonth15th = new Date(today.getFullYear(), today.getMonth() - 1, 15); +const tomorrow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1); const exampleProps = { - maxDate: nextMonth15th, - minDate: lastMonth15th, invalidDateError: 'Please enter a valid date', disabledDateError: 'Date unavailable', requiredError: 'This field is required', }; -const ExampleDatePicker = (props: { - labelNode?: React.ReactNode; - required?: boolean; - calendarIconButtonAccessibilityLabel?: string; - label?: string; - width?: DimensionValue; -}) => { - const [date, setDate] = useState(null); +const ExampleDatePicker = ({ + date, + ...props +}: { date?: Date | null } & Omit< + DatePickerProps, + 'date' | 'error' | 'onChangeDate' | 'onErrorDate' +>) => { + const [dateValue, setDateValue] = useState(date ?? null); const [error, setError] = useState(null); return ( ); @@ -49,20 +48,20 @@ export const FullExample = () => { @@ -71,8 +70,8 @@ export const FullExample = () => { @@ -81,45 +80,119 @@ export const FullExample = () => { + Birthdate - + } + openCalendarAccessibilityLabel="Birthdate calendar" /> + + + + + + + + + + + ); +}; + +export const CustomLabel = () => { + return ( + + + + {/* Default with tooltip */} + + Date of birth + + + + + } + openCalendarAccessibilityLabel="Date of birth calendar" + /> + {/* Compact with required indicator */} + + Start date + + * + + + } + openCalendarAccessibilityLabel="Start date calendar" + /> + {/* Inside variant with optional indicator */} + + End date + + (optional) + + + } + labelVariant="inside" + openCalendarAccessibilityLabel="End date calendar" + /> + + ); }; diff --git a/packages/mobile/src/dates/__tests__/Calendar.test.tsx b/packages/mobile/src/dates/__tests__/Calendar.test.tsx new file mode 100644 index 0000000000..f0ee9fb76b --- /dev/null +++ b/packages/mobile/src/dates/__tests__/Calendar.test.tsx @@ -0,0 +1,377 @@ +import { fireEvent, render, screen } from '@testing-library/react-native'; + +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import type { CalendarProps } from '../Calendar'; +import { Calendar } from '../Calendar'; + +const testID = 'test-calendar'; +const CalendarExample = (props: Partial) => ( + + + +); + +describe('Calendar', () => { + it('passes accessibility', async () => { + // Use specific date range to ensure all dates are enabled + const seedDate = new Date(2024, 6, 15); + const minDate = new Date(2024, 6, 1); + const maxDate = new Date(2024, 6, 31); + + render(); + + expect(screen.getByTestId(testID)).toBeAccessible({ + // Disable 'disabled-state-required' since it's flagging passing disabled + // to Interactable and unclear if we're lacking a11y affordances here. + customViolationHandler: (violations) => { + return violations.filter( + (v) => + v.problem !== "This component has a disabled state but it isn't exposed to the user", + ); + }, + }); + }); + + it('renders current month by default', () => { + render(); + + const today = new Date(); + const monthYear = today.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); + + expect(screen.getByText(monthYear)).toBeTruthy(); + }); + + it('renders with seedDate', () => { + const seedDate = new Date(2024, 0, 15); // January 15, 2024 + render(); + + expect(screen.getByText('January 2024')).toBeTruthy(); + }); + + it('renders with selectedDate', () => { + const selectedDate = new Date(2024, 5, 20); // June 20, 2024 + render(); + + expect(screen.getByText('June 2024')).toBeTruthy(); + }); + + it('hides controls when hideControls is true', () => { + render( + , + ); + + expect(screen.queryByLabelText('Next month')).toBeNull(); + expect(screen.queryByLabelText('Previous month')).toBeNull(); + }); + + it('renders navigation controls with correct accessibility labels', () => { + render( + , + ); + + expect(screen.getByLabelText('Next month')).toBeTruthy(); + expect(screen.getByLabelText('Previous month')).toBeTruthy(); + }); + + it('renders days of the week', () => { + render(); + + // Check for first letter of each day + const sLetters = screen.getAllByText('S'); + 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(); + }); + + it('handles disabled state correctly', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Navigation arrows should be disabled + const prevArrow = screen.getByLabelText('Go to previous month'); + const nextArrow = screen.getByLabelText('Go to next month'); + + expect(prevArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + expect(nextArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + + expect(prevArrow).toBeDisabled(); + expect(nextArrow).toBeDisabled(); + + // Calendar container should have reduced opacity + const calendar = screen.getByTestId(testID); + expect(calendar).toHaveStyle({ opacity: 0.5 }); // accessibleOpacityDisabled value + }); + + it('does not call onPressDate when date buttons are disabled', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 1); + const maxDate = new Date(2024, 6, 10); // Only first 10 days are enabled + + render( + , + ); + + // Disabled dates are rendered as disabled buttons (wrapped in Tooltip). Each disabled day has two buttons (trigger + pressable) with the same label; count unique labels. + const allButtons = screen.getAllByRole('button'); + const disabledDateButtons = allButtons.filter( + (button) => + button.props.accessibilityLabel?.includes('July') && + button.props.accessibilityLabel?.includes('2024') && + button.props.accessibilityState?.disabled === true, + ); + const uniqueDisabledDateLabels = new Set( + disabledDateButtons.map((button) => button.props.accessibilityLabel), + ); + + // Dates after July 10 should be disabled (21 days) + expect(uniqueDisabledDateLabels.size).toBe(21); + }); + + it('calls onPressDate when a date is pressed', () => { + const mockOnPressDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Find and press July 15 - match label with both day and month/year + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + expect(mockOnPressDate).toHaveBeenCalledTimes(1); + expect(mockOnPressDate).toHaveBeenCalledWith(expect.any(Date)); + + const calledDate = mockOnPressDate.mock.calls[0][0]; + expect(calledDate.getDate()).toBe(15); + expect(calledDate.getMonth()).toBe(6); // July (0-indexed) + expect(calledDate.getFullYear()).toBe(2024); + }); + + it('navigates to next month when next arrow is pressed', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + expect(screen.getByText('July 2024')).toBeTruthy(); + + const nextArrow = screen.getByLabelText('Go to next month'); + fireEvent.press(nextArrow); + + expect(screen.getByText('August 2024')).toBeTruthy(); + }); + + it('navigates to previous month when previous arrow is pressed', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + expect(screen.getByText('July 2024')).toBeTruthy(); + + const prevArrow = screen.getByLabelText('Go to previous month'); + fireEvent.press(prevArrow); + + expect(screen.getByText('June 2024')).toBeTruthy(); + }); + + it('disables next arrow when maxDate is in current month', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const maxDate = new Date(2024, 6, 31); // July 31, 2024 + + render(); + + const nextArrow = screen.getByLabelText('Go to next month'); + expect(nextArrow).toBeDisabled(); + expect(nextArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + }); + + it('disables previous arrow when minDate is in current month', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 1); // July 1, 2024 + + render(); + + const prevArrow = screen.getByLabelText('Go to previous month'); + expect(prevArrow).toBeDisabled(); + expect(prevArrow).toHaveProp('accessibilityState', expect.objectContaining({ disabled: true })); + }); + + it('selected date has correct accessibility state', () => { + const selectedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + const selectedButton = screen.getByLabelText(/15.*July.*2024/); + expect(selectedButton).toHaveProp( + 'accessibilityState', + expect.objectContaining({ selected: true }), + ); + }); + + it('date buttons have detailed accessibility labels', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Check that date labels include weekday, day, month, and year + const july15Button = screen.getByLabelText(/15.*July.*2024/); + expect(july15Button).toBeTruthy(); + + // The label should include day of week, date, month and year + expect(july15Button.props.accessibilityLabel).toMatch(/15/); + expect(july15Button.props.accessibilityLabel).toMatch(/July/); + expect(july15Button.props.accessibilityLabel).toMatch(/2024/); + }); + + it('month and year header has accessibilityRole header', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + const headerText = screen.getByText('July 2024'); + expect(headerText).toHaveProp('accessibilityRole', 'header'); + }); + + 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); + }); + + it('respects minDate and disables dates before it', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const minDate = new Date(2024, 6, 10); // July 10, 2024 + + render(); + + const allButtons = screen.getAllByRole('button'); + const disabledDateButtons = allButtons.filter( + (button) => + button.props.accessibilityLabel?.includes('July') && + button.props.accessibilityLabel?.includes('2024') && + button.props.accessibilityState?.disabled === true, + ); + const uniqueDisabledDateLabels = new Set( + disabledDateButtons.map((button) => button.props.accessibilityLabel), + ); + + // Dates before July 10 should be disabled (9 days) + expect(uniqueDisabledDateLabels.size).toBe(9); + }); + + it('respects maxDate and disables dates after it', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const maxDate = new Date(2024, 6, 20); // July 20, 2024 + + render(); + + const allButtons = screen.getAllByRole('button'); + const disabledDateButtons = allButtons.filter( + (button) => + button.props.accessibilityLabel?.includes('July') && + button.props.accessibilityLabel?.includes('2024') && + button.props.accessibilityState?.disabled === true, + ); + const uniqueDisabledDateLabels = new Set( + disabledDateButtons.map((button) => button.props.accessibilityLabel), + ); + + // Dates after July 20 should be disabled (July 21-31 = 11 days) + expect(uniqueDisabledDateLabels.size).toBe(11); + }); + + it('respects disabledDates prop', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + + render(); + + const allButtons = screen.getAllByRole('button'); + const disabledDateButtons = allButtons.filter( + (button) => + button.props.accessibilityLabel?.includes('July') && + button.props.accessibilityLabel?.includes('2024') && + button.props.accessibilityState?.disabled === true, + ); + const uniqueDisabledDateLabels = new Set( + disabledDateButtons.map((button) => button.props.accessibilityLabel), + ); + + // July has 31 days, 2 are disabled (July 10 and July 20) + expect(uniqueDisabledDateLabels.size).toBe(2); + }); + + it('respects disabledDates with date ranges', () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates: [Date, Date][] = [[new Date(2024, 6, 10), new Date(2024, 6, 20)]]; + + render(); + + const allButtons = screen.getAllByRole('button'); + const disabledDateButtons = allButtons.filter( + (button) => + button.props.accessibilityLabel?.includes('July') && + button.props.accessibilityLabel?.includes('2024') && + button.props.accessibilityState?.disabled === true, + ); + const uniqueDisabledDateLabels = new Set( + disabledDateButtons.map((button) => button.props.accessibilityLabel), + ); + + // July 10-20 inclusive should be disabled (11 days) + expect(uniqueDisabledDateLabels.size).toBe(11); + }); + + it('renders today with correct accessibility hint', () => { + const today = new Date(); + const todayDateString = `${today.toLocaleDateString('en-US', { + weekday: 'long', + day: 'numeric', + })} ${today.toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + })}`; + + render(); + + const todayButton = screen.getByA11yHint('Today'); + expect(todayButton).toHaveProp('accessibilityRole', 'button'); + expect(todayButton).toHaveProp('accessibilityLabel', todayDateString); + }); + + it('applies custom styles when styles prop is provided', () => { + const rootBackgroundColor = '#abcdef'; + render( + , + ); + + const calendar = screen.getByTestId(testID); + expect(calendar).toHaveStyle({ backgroundColor: rootBackgroundColor }); + }); +}); diff --git a/packages/mobile/src/dates/__tests__/DatePicker.test.tsx b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx new file mode 100644 index 0000000000..40a311fac6 --- /dev/null +++ b/packages/mobile/src/dates/__tests__/DatePicker.test.tsx @@ -0,0 +1,607 @@ +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; + +import { DefaultThemeProvider, SAFE_AREA_METRICS } from '../../utils/testHelpers'; +import type { DatePickerProps } from '../DatePicker'; +import { DatePicker } from '../DatePicker'; + +const testID = 'test-datepicker'; + +const DatePickerExample = (props: Partial) => { + return ( + + + + + + ); +}; + +describe('DatePicker', () => { + it('passes accessibility', () => { + render( + , + ); + + expect(screen.getByTestId(testID)).toBeAccessible(); + }); + + it('renders DateInput with calendar button', () => { + render(); + + // Calendar button should be present + const calendarButton = screen.getByLabelText('Open calendar'); + expect(calendarButton).toBeTruthy(); + }); + + it('renders with custom calendar button accessibility label', () => { + render( + , + ); + + expect(screen.getByLabelText('Custom calendar label')).toBeTruthy(); + }); + + it('displays the selected date in DateInput', () => { + const selectedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // DateInput should show the formatted date + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('opens calendar tray when calendar button is pressed', async () => { + const mockOnOpen = jest.fn(); + render(); + + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + // Calendar should be visible + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + }); + + it('closes calendar when handle bar is pressed', async () => { + const mockOnCancel = jest.fn(); + const mockOnClose = jest.fn(); + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Confirm')).toBeTruthy(); + }); + + // Close calendar via handle bar using testID + const handleBar = screen.getByTestId('handleBar'); + fireEvent(handleBar, 'accessibilityAction', { nativeEvent: { actionName: 'activate' } }); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // onCancel should be called before onClose + expect(mockOnCancel.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('renders custom handle bar accessibility label', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByLabelText('Custom close label')).toBeTruthy(); + }); + }); + + it('displays confirm button with custom text', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('Done')).toBeTruthy(); + }); + }); + + it('confirm button is disabled when no date is selected', async () => { + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); + }); + }); + + it('confirm button has custom accessibility hint', async () => { + render( + , + ); + + // Open calendar + 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'); + }); + }); + + it('confirm button is enabled after selecting a date from calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm button should now be enabled + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).not.toBeDisabled(); + }); + + it('calls correct callbacks in order when confirming date selection', async () => { + const mockOnOpen = jest.fn(); + const mockOnConfirm = jest.fn(); + const mockOnChangeDate = jest.fn(); + const mockOnClose = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + fireEvent.press(confirmButton); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnChangeDate).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // Verify callback order: onOpen -> onConfirm -> onChangeDate -> onClose + expect(mockOnChangeDate).toHaveBeenCalledWith(expect.any(Date)); + expect(mockOnOpen.mock.invocationCallOrder[0]).toBeLessThan( + mockOnConfirm.mock.invocationCallOrder[0], + ); + expect(mockOnConfirm.mock.invocationCallOrder[0]).toBeLessThan( + mockOnChangeDate.mock.invocationCallOrder[0], + ); + expect(mockOnChangeDate.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('calls correct callbacks in order when canceling date selection', async () => { + const mockOnOpen = jest.fn(); + const mockOnCancel = jest.fn(); + const mockOnClose = jest.fn(); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + expect(mockOnOpen).toHaveBeenCalledTimes(1); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); + }); + + // Close calendar using testID + const handleBar = screen.getByTestId('handleBar'); + fireEvent(handleBar, 'accessibilityAction', { nativeEvent: { actionName: 'activate' } }); + + // Wait for animations to complete and callbacks to be called + await waitFor(() => { + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + await waitFor(() => { + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + // Verify callback order: onOpen -> onCancel -> onClose + expect(mockOnOpen.mock.invocationCallOrder[0]).toBeLessThan( + mockOnCancel.mock.invocationCallOrder[0], + ); + expect(mockOnCancel.mock.invocationCallOrder[0]).toBeLessThan( + mockOnClose.mock.invocationCallOrder[0], + ); + }); + + it('initializes calendar with current date when opening', async () => { + const currentDate = new Date(2024, 5, 20); // June 20, 2024 + render(); + + // Open calendar + 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(); + }); + }); + + it('passes disabled state to DateInput and Calendar', () => { + render(); + + // Calendar button should be disabled + const calendarButton = screen.getByLabelText('Open calendar'); + expect(calendarButton).toBeDisabled(); + }); + + it('passes minDate to DateInput and Calendar', async () => { + const minDate = new Date(2024, 6, 10); // July 10, 2024 + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Previous month arrow should be disabled since minDate is in current month + const prevArrow = screen.getByLabelText('Go to previous month'); + expect(prevArrow).toBeDisabled(); + }); + + it('passes maxDate to DateInput and Calendar', async () => { + const maxDate = new Date(2024, 6, 20); // July 20, 2024 + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Next month arrow should be disabled since maxDate is in current month + const nextArrow = screen.getByLabelText('Go to next month'); + expect(nextArrow).toBeDisabled(); + }); + + it('passes disabledDates to DateInput and Calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const disabledDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Check that the calendar is rendered (specific dates being disabled is tested in Calendar.test.tsx) + const allButtons = screen.getAllByRole('button'); + expect(allButtons.length).toBeGreaterThan(0); + }); + + it('passes custom error messages to DateInput', () => { + render( + , + ); + + // DateInput should receive these error messages + // (Detailed error testing is in DateInput tests) + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('renders with custom accessibility properties', () => { + render( + , + ); + + const input = screen.getByTestId(testID); + expect(input).toHaveProp('accessibilityHint', 'Custom hint'); + expect(input).toHaveProp('accessibilityLabel', 'Custom label'); + }); + + it('renders DateInput with compact variant', () => { + render(); + + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('renders DateInput with variant prop', () => { + render(); + + const input = screen.getByTestId(testID); + expect(input).toBeTruthy(); + }); + + it('passes helperText to DateInput', () => { + const helperText = 'Custom helper text'; + render(); + + expect(screen.getByText(helperText)).toBeTruthy(); + }); + + it('passes onChange callback to DateInput', () => { + const mockOnChange = jest.fn(); + render(); + + // onChange is passed through to DateInput + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('clears error when confirming valid date selection', async () => { + const mockOnChangeDate = jest.fn(); + const mockOnErrorDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const error = new DateInputValidationError('required', 'This field is required'); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + fireEvent.press(confirmButton); + + // Error should be cleared + expect(mockOnErrorDate).toHaveBeenCalledWith(null); + }); + + it('does not clear custom error when confirming date selection', async () => { + const mockOnChangeDate = jest.fn(); + const mockOnErrorDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const error = new DateInputValidationError('custom', 'Custom error message'); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Confirm selection + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + fireEvent.press(confirmButton); + + // Custom error should NOT be cleared + expect(mockOnErrorDate).not.toHaveBeenCalledWith(null); + expect(mockOnChangeDate).toHaveBeenCalled(); + }); + + it('passes highlighted dates to Calendar', async () => { + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + const highlightedDates = [new Date(2024, 6, 10), new Date(2024, 6, 20)]; + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + }); + + it('passes navigation accessibility labels to Calendar', async () => { + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByLabelText('Next month custom')).toBeTruthy(); + }); + expect(screen.getByLabelText('Previous month custom')).toBeTruthy(); + }); + + it('passes required prop to DateInput', () => { + render(); + + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('resets calendar selection when canceling', async () => { + const mockOnChangeDate = jest.fn(); + const seedDate = new Date(2024, 6, 15); // July 15, 2024 + + render(); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByText('July 2024')).toBeTruthy(); + }); + + // Select a date + const july15Button = screen.getByLabelText(/15.*July.*2024/); + fireEvent.press(july15Button); + + // Close without confirming + const handleBar = screen.getByLabelText('Close calendar without selecting a date'); + fireEvent.press(handleBar); + + // onChangeDate should not have been called + expect(mockOnChangeDate).not.toHaveBeenCalled(); + + // Open calendar again + fireEvent.press(calendarButton); + + await waitFor(() => { + // Confirm button should be disabled (selection was reset) + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); + }); + }); + + it('does not confirm when confirm button is disabled', async () => { + const mockOnConfirm = jest.fn(); + const mockOnChangeDate = jest.fn(); + + render( + , + ); + + // Open calendar + const calendarButton = screen.getByLabelText('Open calendar'); + fireEvent.press(calendarButton); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Confirm' })).toBeTruthy(); + }); + + // Try to press disabled confirm button + const confirmButton = screen.getByRole('button', { name: 'Confirm' }); + expect(confirmButton).toBeDisabled(); + + // Press it anyway (should not trigger callbacks) + fireEvent.press(confirmButton); + + // Callbacks should not be called + expect(mockOnConfirm).not.toHaveBeenCalled(); + expect(mockOnChangeDate).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/mobile/src/dots/DotCount.tsx b/packages/mobile/src/dots/DotCount.tsx index f17937b4e2..85f739082c 100644 --- a/packages/mobile/src/dots/DotCount.tsx +++ b/packages/mobile/src/dots/DotCount.tsx @@ -30,6 +30,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import type { DotPinStylesKey } from '../hooks/useDotPinStyles'; import { useDotPinStyles } from '../hooks/useDotPinStyles'; import { useTheme } from '../hooks/useTheme'; @@ -89,31 +90,21 @@ export type DotCountBaseProps = SharedProps & }; export type DotCountProps = DotCountBaseProps & { - /** - * Custom styles for the root element. - */ style?: StyleProp; - /** - * Custom styles for the component. - */ + /** Custom styles for individual elements of the DotCount component */ styles?: { - /** - * Custom styles for the root element. - */ + /** Root element */ root?: StyleProp; - /** - * Custom styles for the container element. - */ + /** Container element */ container?: StyleProp; - /** - * Custom styles for the text element. - */ + /** Text element */ text?: StyleProp; }; }; -export const DotCount = memo( - ({ +export const DotCount = memo((_props: DotCountProps) => { + const mergedProps = useComponentConfig('DotCount', _props); + const { children, pin, variant = 'negative', @@ -123,111 +114,110 @@ export const DotCount = memo( style, styles, ...props - }: DotCountProps) => { - const theme = useTheme(); - const [childrenSize, onChildrenLayout] = useDotsLayout(); - const transforms = useDotPinStyles( - childrenSize, - { width: dotCountSize + dotTextPaddingHorizontal, height: dotCountSize } as LayoutRectangle, - overlap, - ); - - const opacityAnimatedValue = useSharedValue(opacityEnter.fromValue); - const scaleAnimatedValue = useSharedValue(scaleEnter.fromValue); - const [shouldUnmount, setShouldUnmount] = useState(count === 0); - const [countInternal, setCountInternal] = useState(count); - const prevCount = usePreviousValue(count); - - const pinStyles: ViewStyle = useMemo(() => { - if (pin && transforms !== null) { - const [vertical, horizontal] = (pin as string).split('-'); - - return getTransform( - transforms[horizontal as DotPinStylesKey], - transforms[vertical as DotPinStylesKey], - ); + } = mergedProps; + const theme = useTheme(); + const [childrenSize, onChildrenLayout] = useDotsLayout(); + const transforms = useDotPinStyles( + childrenSize, + { width: dotCountSize + dotTextPaddingHorizontal, height: dotCountSize } as LayoutRectangle, + overlap, + ); + + const opacityAnimatedValue = useSharedValue(opacityEnter.fromValue); + const scaleAnimatedValue = useSharedValue(scaleEnter.fromValue); + const [shouldUnmount, setShouldUnmount] = useState(count === 0); + const [countInternal, setCountInternal] = useState(count); + const prevCount = usePreviousValue(count); + + const pinStyles: ViewStyle = useMemo(() => { + if (pin && transforms !== null) { + const [vertical, horizontal] = (pin as string).split('-'); + + return getTransform( + transforms[horizontal as DotPinStylesKey], + transforms[vertical as DotPinStylesKey], + ); + } + return {}; + }, [pin, transforms]); + + const containerStyles = useMemo(() => { + return [ + styleSheet.container, + { + borderColor: theme.color.bgSecondary, + backgroundColor: theme.color[variantColorMap[variant]], + }, + ]; + }, [theme.color, variant]); + + // avoid displaying 0 during animations and preserve exit animation + useEffect(() => { + if (count !== 0) { + setCountInternal(count); + } + }, [count]); + + useAnimatedReaction( + () => count, + (result) => { + // play enter animation + if ((prevCount === 0 || prevCount === undefined) && result > 0) { + runOnJS(setShouldUnmount)(false); + opacityAnimatedValue.value = withMotionTiming(opacityEnter); + scaleAnimatedValue.value = withMotionTiming(scaleEnter); } - return {}; - }, [pin, transforms]); - - const containerStyles = useMemo(() => { - return [ - styleSheet.container, - { - borderColor: theme.color.bgSecondary, - backgroundColor: theme.color[variantColorMap[variant]], - }, - ]; - }, [theme.color, variant]); - - // avoid displaying 0 during animations and preserve exit animation - useEffect(() => { - if (count !== 0) { - setCountInternal(count); + + // play exit animation + if (prevCount && prevCount > 0 && result === 0) { + opacityAnimatedValue.value = withMotionTiming(opacityExit, () => { + runOnJS(setShouldUnmount)(true); + }); + scaleAnimatedValue.value = withMotionTiming(scaleExit); } - }, [count]); - - useAnimatedReaction( - () => count, - (result) => { - // play enter animation - if ((prevCount === 0 || prevCount === undefined) && result > 0) { - runOnJS(setShouldUnmount)(false); - opacityAnimatedValue.value = withMotionTiming(opacityEnter); - scaleAnimatedValue.value = withMotionTiming(scaleEnter); - } - - // play exit animation - if (prevCount && prevCount > 0 && result === 0) { - opacityAnimatedValue.value = withMotionTiming(opacityExit, () => { - runOnJS(setShouldUnmount)(true); - }); - scaleAnimatedValue.value = withMotionTiming(scaleExit); - } - }, - [count, childrenSize], - ); - - const animatedStyles = useAnimatedStyle(() => { - return { - opacity: Number(opacityAnimatedValue.value), - transform: [{ scale: Number(scaleAnimatedValue.value) }], - }; - }); - - const dotCountContainerStyle = useMemo( - () => [containerStyles, animatedStyles, styles?.container], - [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 - const shouldShow = children !== undefined ? childrenSize !== null : true; - - return ( - - - {children} - - {!shouldUnmount && shouldShow && ( - - - - {parseDotCountMaxOverflow(countInternal, max)} - - - - )} + }, + [count, childrenSize], + ); + + const animatedStyles = useAnimatedStyle(() => { + return { + opacity: Number(opacityAnimatedValue.value), + transform: [{ scale: Number(scaleAnimatedValue.value) }], + }; + }); + + const dotCountContainerStyle = useMemo( + () => [containerStyles, animatedStyles, styles?.container], + [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 + const shouldShow = children !== undefined ? childrenSize !== null : true; + + return ( + + + {children} - ); - }, -); + {!shouldUnmount && shouldShow && ( + + + + {parseDotCountMaxOverflow(countInternal, max)} + + + + )} + + ); +}); const styleSheet = StyleSheet.create({ container: { diff --git a/packages/mobile/src/dots/DotStatusColor.tsx b/packages/mobile/src/dots/DotStatusColor.tsx index 76f68c5c08..40656e1bc3 100644 --- a/packages/mobile/src/dots/DotStatusColor.tsx +++ b/packages/mobile/src/dots/DotStatusColor.tsx @@ -11,6 +11,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import type { DotPinStylesKey } from '../hooks/useDotPinStyles'; import { useDotPinStyles } from '../hooks/useDotPinStyles'; import { useTheme } from '../hooks/useTheme'; @@ -45,48 +46,48 @@ export type DotStatusColorBaseProps = SharedProps & export type DotStatusColorProps = DotStatusColorBaseProps; -export const DotStatusColor = memo( - ({ variant, pin, size = 's', children, overlap, ...props }: DotStatusColorProps) => { - const theme = useTheme(); - const iconSize = theme.iconSize[size]; - const [childrenSize, onLayout] = useDotsLayout(); +export const DotStatusColor = memo((_props: DotStatusColorProps) => { + const mergedProps = useComponentConfig('DotStatusColor', _props); + const { variant, pin, size = 's', children, overlap, ...props } = mergedProps; + const theme = useTheme(); + const iconSize = theme.iconSize[size]; + const [childrenSize, onLayout] = useDotsLayout(); - const transforms = useDotPinStyles(childrenSize, iconSize, overlap); + const transforms = useDotPinStyles(childrenSize, iconSize, overlap); - const pinStyles = useMemo(() => { - if (pin && transforms !== null) { - const [vertical, horizontal] = (pin as string).split('-'); + const pinStyles = useMemo(() => { + if (pin && transforms !== null) { + const [vertical, horizontal] = (pin as string).split('-'); - return getTransform( - transforms[horizontal as DotPinStylesKey], - transforms[vertical as DotPinStylesKey], - ); - } - return {}; - }, [pin, transforms]); + return getTransform( + transforms[horizontal as DotPinStylesKey], + transforms[vertical as DotPinStylesKey], + ); + } + return {}; + }, [pin, transforms]); - const dotContentStyles: ViewStyle = useMemo(() => { - return { - borderRadius: theme.borderRadius[1000], - width: iconSize, - height: iconSize, - backgroundColor: theme.color[variantColorMap[variant]], - alignItems: 'center', - justifyContent: 'center', - ...pinStyles, - }; - }, [iconSize, theme.color, theme.borderRadius, pinStyles, variant]); + const dotContentStyles: ViewStyle = useMemo(() => { + return { + borderRadius: theme.borderRadius[1000], + width: iconSize, + height: iconSize, + backgroundColor: theme.color[variantColorMap[variant]], + alignItems: 'center', + justifyContent: 'center', + ...pinStyles, + }; + }, [iconSize, theme.color, theme.borderRadius, pinStyles, variant]); - // only check childrenSize when children is defined - const shouldShow = children !== undefined ? childrenSize !== null : true; + // only check childrenSize when children is defined + const shouldShow = children !== undefined ? childrenSize !== null : true; - return ( - - - {children} - - {shouldShow && } + return ( + + + {children} - ); - }, -); + {shouldShow && } + + ); +}); diff --git a/packages/mobile/src/dots/DotSymbol.tsx b/packages/mobile/src/dots/DotSymbol.tsx index 19bf9b7237..58681fea03 100644 --- a/packages/mobile/src/dots/DotSymbol.tsx +++ b/packages/mobile/src/dots/DotSymbol.tsx @@ -15,6 +15,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import type { DotPinStylesKey } from '../hooks/useDotPinStyles'; import { useDotPinStyles } from '../hooks/useDotPinStyles'; import { useTheme } from '../hooks/useTheme'; @@ -57,8 +58,9 @@ export type DotSymbolBaseProps = SharedProps & export type DotSymbolProps = DotSymbolBaseProps; -export const DotSymbol = memo( - ({ +export const DotSymbol = memo((_props: DotSymbolProps) => { + const mergedProps = useComponentConfig('DotSymbol', _props); + const { children, symbol, pin, @@ -74,86 +76,85 @@ export const DotSymbol = memo( iconStyle, imageStyle, ...props - }: DotSymbolProps) => { - const theme = useTheme(); - const iconSize = theme.iconSize[size]; - const [childrenSize, onChildrenLayout] = useDotsLayout(); - const dotIsIcon = iconName !== undefined; - const transforms = useDotPinStyles( - childrenSize, - dotIsIcon - ? // iconSize + border + spacing - iconSize + 4 + 4 - : iconSize, - overlap, - ); + } = mergedProps; + const theme = useTheme(); + const iconSize = theme.iconSize[size]; + const [childrenSize, onChildrenLayout] = useDotsLayout(); + const dotIsIcon = iconName !== undefined; + const transforms = useDotPinStyles( + childrenSize, + dotIsIcon + ? // iconSize + border + spacing + iconSize + 4 + 4 + : iconSize, + overlap, + ); - const pinStyles = useMemo(() => { - if (pin && transforms !== null) { - const [vertical, horizontal] = (pin as string).split('-'); + const pinStyles = useMemo(() => { + if (pin && transforms !== null) { + const [vertical, horizontal] = (pin as string).split('-'); - return getTransform( - transforms[horizontal as DotPinStylesKey], - transforms[vertical as DotPinStylesKey], - ); - } - return {}; - }, [pin, transforms]); + return getTransform( + transforms[horizontal as DotPinStylesKey], + transforms[vertical as DotPinStylesKey], + ); + } + return {}; + }, [pin, transforms]); - // TODO: These should be tokens, i don't know what the name - // of the token will be called though. No design direction yet - const imageBorderStyle = useMemo(() => { - return { - borderColor: theme.color[borderColor], - borderWidth: 1, - }; - }, [theme.color, borderColor]); + // TODO: These should be tokens, i don't know what the name + // of the token will be called though. No design direction yet + const imageBorderStyle = useMemo(() => { + return { + borderColor: theme.color[borderColor], + borderWidth: 1, + }; + }, [theme.color, borderColor]); - // TODO: These should be tokens, i don't know what the name - // of the token will be called though. No design direction yet - const iconBorderStyle = useMemo(() => { - return { - borderWidth: 2, - }; - }, []); + // TODO: These should be tokens, i don't know what the name + // of the token will be called though. No design direction yet + const iconBorderStyle = useMemo(() => { + return { + borderWidth: 2, + }; + }, []); - // only check childrenSize when children is defined - const shouldShow = children !== undefined ? childrenSize !== null : true; + // only check childrenSize when children is defined + const shouldShow = children !== undefined ? childrenSize !== null : true; - return ( - - - {children} - - {shouldShow && ( - - {source !== undefined && ( - - )} - {iconName !== undefined && ( - - - - )} - {symbol} - - )} + return ( + + + {children} - ); - }, -); + {shouldShow && ( + + {source !== undefined && ( + + )} + {iconName !== undefined && ( + + + + )} + {symbol} + + )} + + ); +}); diff --git a/packages/mobile/src/dots/__figma__/DotCount.figma.tsx b/packages/mobile/src/dots/__figma__/DotCount.figma.tsx index 363bf5cbb7..37cfa0be65 100644 --- a/packages/mobile/src/dots/__figma__/DotCount.figma.tsx +++ b/packages/mobile/src/dots/__figma__/DotCount.figma.tsx @@ -7,7 +7,7 @@ figma.connect( DotCount, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=155%3A11976', { - imports: ["import { DotCount } from '@coinbase/cds-mobile/dots/DotCount';"], + imports: ["import { DotCount } from '@coinbase/cds-mobile/dots/DotCount'"], props: { count: figma.enum('type', { 'single digit': 1, diff --git a/packages/mobile/src/dots/__figma__/DotStatusColor.figma.tsx b/packages/mobile/src/dots/__figma__/DotStatusColor.figma.tsx index 66469f98b2..12229e6a4a 100644 --- a/packages/mobile/src/dots/__figma__/DotStatusColor.figma.tsx +++ b/packages/mobile/src/dots/__figma__/DotStatusColor.figma.tsx @@ -7,7 +7,7 @@ figma.connect( DotStatusColor, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=155%3A11983', { - imports: ["import { DotStatusColor } from '@coinbase/cds-mobile/dots/DotStatusColor';"], + imports: ["import { DotStatusColor } from '@coinbase/cds-mobile/dots/DotStatusColor'"], props: { variant: figma.enum('variant', { positive: 'positive', diff --git a/packages/mobile/src/dots/__figma__/DotSymbol.figma.tsx b/packages/mobile/src/dots/__figma__/DotSymbol.figma.tsx index 0d0013c199..f46fb419bd 100644 --- a/packages/mobile/src/dots/__figma__/DotSymbol.figma.tsx +++ b/packages/mobile/src/dots/__figma__/DotSymbol.figma.tsx @@ -7,7 +7,7 @@ figma.connect( DotSymbol, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=155%3A12033', { - imports: ["import { DotSymbol } from'@coinbase/cds-mobile/dots/DotSymbol';"], + imports: ["import { DotSymbol } from'@coinbase/cds-mobile/dots/DotSymbol'"], props: { children: figma.enum('symbol size', { l: figma.instance('48 media'), diff --git a/packages/mobile/src/examples/ExampleScreen.tsx b/packages/mobile/src/examples/ExampleScreen.tsx index 628eb9253d..08482319aa 100644 --- a/packages/mobile/src/examples/ExampleScreen.tsx +++ b/packages/mobile/src/examples/ExampleScreen.tsx @@ -81,7 +81,6 @@ export const ExampleScreen = React.forwardRef {children} diff --git a/packages/mobile/src/gradients/LinearGradient.tsx b/packages/mobile/src/gradients/LinearGradient.tsx index 0d349a4074..fcc80a32b5 100644 --- a/packages/mobile/src/gradients/LinearGradient.tsx +++ b/packages/mobile/src/gradients/LinearGradient.tsx @@ -46,7 +46,8 @@ type LinearGradientProps = { */ colors: NonNullable[]; /** - * @deprecated This prop will be removed in a future version. Please use the elevated prop instead. + * @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. * @default true */ diff --git a/packages/mobile/src/hooks/useComponentConfig.ts b/packages/mobile/src/hooks/useComponentConfig.ts new file mode 100644 index 0000000000..225e4ad68d --- /dev/null +++ b/packages/mobile/src/hooks/useComponentConfig.ts @@ -0,0 +1,35 @@ +import { useStore } from 'zustand'; + +import type { ComponentConfig } from '../core/componentConfig'; +import { useComponentConfigStore } from '../system/ComponentConfigProvider'; +import { mergeComponentProps } from '../utils/mergeComponentProps'; + +/** + * Subscribes to the component config for a specific component via zustand selectors. + * Only triggers re-renders when the config for THIS component changes - other + * components' config changes are ignored. + * + * Raw config values are stored in the zustand store (not normalized to functions) + * so that Object.is reference comparisons work correctly and unchanged entries + * never cause re-renders. + * + * @param componentName - The component key in ComponentConfig (e.g., 'Button') + * @param localProps - The props passed directly to the component instance + * @returns Merged props with config defaults applied (local props take precedence) + */ +export const useComponentConfig = >( + componentName: K, + localProps: P, +): P => { + const store = useComponentConfigStore(); + + const rawConfig = useStore(store, (state) => state.components?.[componentName]); + + if (!rawConfig) return localProps; + + const resolvedConfig = + typeof rawConfig === 'function' + ? (rawConfig as (props: any) => Record)(localProps) + : rawConfig; + return mergeComponentProps(resolvedConfig, localProps) as P; +}; diff --git a/packages/mobile/src/hooks/useStatusBarHeight.ts b/packages/mobile/src/hooks/useStatusBarHeight.ts index 0b47f88dfa..c125b82c52 100644 --- a/packages/mobile/src/hooks/useStatusBarHeight.ts +++ b/packages/mobile/src/hooks/useStatusBarHeight.ts @@ -9,8 +9,19 @@ type StatusBarNativeModule = { } & NativeModule; /** - * StatusBar api returns weird incorrect values for iOS. - * This implementation is based off of the implementation identified in this article. https://blog.expo.dev/the-status-bar-manager-in-react-native-6226058ecba + * @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/ + * + * @example + * // Before (deprecated) + * const statusBarHeight = useStatusBarHeight(); + * + * // After (recommended) + * import { useSafeAreaInsets } from 'react-native-safe-area-context'; + * const insets = useSafeAreaInsets(); + * const statusBarHeight = insets.top; */ export const useStatusBarHeight = () => { const [statusBarHeight, setStatusBarHeight] = useState(); diff --git a/packages/mobile/src/icons/Icon.tsx b/packages/mobile/src/icons/Icon.tsx index 4c5508fa27..6a0e9517fa 100644 --- a/packages/mobile/src/icons/Icon.tsx +++ b/packages/mobile/src/icons/Icon.tsx @@ -19,6 +19,7 @@ import type { import { glyphMap } from '@coinbase/cds-icons/glyphMap'; import { isDevelopment } from '@coinbase/cds-utils'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout/Box'; @@ -53,8 +54,11 @@ export type IconBaseProps = SharedProps & }; export type IconProps = IconBaseProps & { + /** Custom styles for individual elements of the Icon component */ styles?: { + /** Outer Box wrapper element */ root?: StyleProp; + /** Inner icon glyph Text element */ icon?: StyleProp; }; }; @@ -65,27 +69,29 @@ const getIconSourceSize = (iconSize: number): IconSourcePixelSize => { return 24; }; -export const Icon = memo(function Icon({ - accessibilityLabel, - accessibilityHint, - animated = false, - color = 'fgPrimary', - dangerouslySetColor, - style, - styles, - fallback = null, - name, - size = 'm', - testID, - padding, - paddingX, - paddingY, - paddingTop, - paddingEnd, - paddingBottom, - paddingStart, - active, -}: IconProps) { +export const Icon = memo((_props: IconProps) => { + const mergedProps = useComponentConfig('Icon', _props); + const { + accessibilityLabel, + accessibilityHint, + animated = false, + color = 'fgPrimary', + dangerouslySetColor, + style, + styles, + fallback = null, + name, + size = 'm', + testID, + padding, + paddingX, + paddingY, + paddingTop, + paddingEnd, + paddingBottom, + paddingStart, + active, + } = mergedProps; const TextComponent = animated ? Animated.Text : Text; const theme = useTheme(); const { fontScale } = useWindowDimensions(); diff --git a/packages/mobile/src/icons/__figma__/Icon.figma.tsx b/packages/mobile/src/icons/__figma__/Icon.figma.tsx index 1f849c55df..012167d124 100644 --- a/packages/mobile/src/icons/__figma__/Icon.figma.tsx +++ b/packages/mobile/src/icons/__figma__/Icon.figma.tsx @@ -5,2547 +5,3340 @@ import { Icon } from '../Icon'; const props = { size: figma.enum('size', { - 'xs (12)': 'xs', - 's (16)': 's', - 'm (24)': 'm', - 'l (32)': 'l', + xs: 'xs', + s: 's', + m: 'm', + l: 'l', }), + active: figma.boolean('active'), }; -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11570', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16799', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11583', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14768', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11596', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16578', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11609', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16786', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11622', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13078', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11635', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15849', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11648', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14326', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11661', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13533', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11674', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-397', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11687', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-370', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11700', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-384', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11713', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-722', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11726', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12818', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11739', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14313', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11752', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13520', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11765', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13689', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11778', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-800', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11791', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13936', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11804', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-956', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11817', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56214-24', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11830', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13169', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11843', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12805', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11856', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12792', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11869', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12779', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11882', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12740', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11895', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12766', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11908', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-93', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11921', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12753', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11934', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15106', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11947', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15836', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11960', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15823', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11973', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15327', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11986', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1178', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11999', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-670', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12012', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16565', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12025', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13884', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12038', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12727', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12051', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-969', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12064', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1230', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12077', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-761', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12090', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11882', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12103', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14625', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12116', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15810', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12129', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-29', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12142', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-81', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12155', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-144', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12168', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=65584-1373', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12181', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-14', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12194', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-42', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12207', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56816-55', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12220', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-110', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12233', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-123', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12246', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=57839-24', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12259', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=77025-15', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12272', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16773', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12285', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16539', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12298', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16552', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12311', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15093', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12324', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15080', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12337', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=57839-38', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12350', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13507', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12363', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13065', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12376', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13052', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12389', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16526', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12402', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16513', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12415', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15796', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12428', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-162', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12441', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13494', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12454', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15067', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12467', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16760', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12480', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-774', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12493', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-618', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12506', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11869', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12519', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16500', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12532', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-28', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12545', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1113', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12558', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1073', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12571', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1086', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12584', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1099', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12597', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15054', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12610', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14612', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12623', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11856', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12883', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12714', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12701', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12688', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12675', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11843', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11960', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12220', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12194', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12207', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12181', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12168', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12155', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12142', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12129', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12116', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12103', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12090', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12077', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12064', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56411-1533', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12051', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12038', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12025', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11999', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12012', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11986', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16487', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14599', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14755', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14742', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14586', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14573', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14560', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14729', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14547', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14534', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14300', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14287', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=54347-25', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16474', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16461', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16448', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-71', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16435', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16422', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14521', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-657', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13039', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16409', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16396', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16383', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13871', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15314', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-527', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15783', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13676', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15770', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15757', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15340', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=42385-44010', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15744', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=42385-43970', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-2', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12662', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13481', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13663', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14274', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14261', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14378', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56404-11', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15731', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15301', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15718', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=54347-80', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-709', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=74076-4', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14508', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12649', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13026', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16747', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-631', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=59433-10', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-19', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14495', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16929', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13468', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15288', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-67', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11973', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15705', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16734', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15692', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=57839-11', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13650', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13455', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15679', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12636', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13637', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15666', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=33183-8983', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15653', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15640', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13442', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12623', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12636', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12610', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12597', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12649', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13624', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12662', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13429', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12675', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13416', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12688', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11947', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12701', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11830', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12714', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11817', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12727', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14248', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12740', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13013', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12753', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13156', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12766', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16370', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12779', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12584', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12792', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12571', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12805', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12558', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12818', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13858', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12831', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13611', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12844', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13403', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12857', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13390', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15627', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15614', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56855-1831', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-540', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-943', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-930', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16357', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=77025-2', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-344', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16344', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13377', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , }); figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12870', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12883', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15601', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12896', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13845', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12909', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12545', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12922', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12532', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12935', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12519', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12948', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12506', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12961', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16721', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12974', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13000', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14365', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-644', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13364', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13598', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13351', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13832', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-240', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16331', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=77025-644', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , }); figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12987', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13000', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14235', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13013', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13143', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13026', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13130', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13039', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13117', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13052', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-149', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13065', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12493', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13078', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12480', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13091', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56404-1568', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12857', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15041', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-292', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13585', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14482', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14716', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14469', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , +}); + +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14703', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], + props, + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13104', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14690', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13117', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14456', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13130', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14443', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13143', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14677', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13156', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15028', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13169', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16318', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13182', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13819', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13195', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14196', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13208', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14352', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13221', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14183', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13234', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14170', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13247', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15275', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13260', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15015', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13273', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11804', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13286', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13962', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13299', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15262', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13312', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16305', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13325', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-136', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13338', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13975', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13351', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-592', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13364', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=57767-11', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13377', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16877', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13390', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16851', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13403', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16812', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13416', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16825', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13429', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16838', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13442', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14144', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13455', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14157', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13468', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14339', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13481', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16903', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13494', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16708', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13507', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16695', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13520', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-84', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13533', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16682', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13546', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16291', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13559', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14430', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13572', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-826', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13585', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-787', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13598', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-436', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13611', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14131', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13624', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12974', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13637', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14118', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13650', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16278', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13663', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16265', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13676', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12467', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13689', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=64951-67534', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13702', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14105', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13715', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15002', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13728', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15588', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13741', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11934', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13754', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13104', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13767', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15249', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13780', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14989', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13793', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14976', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13806', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56413-10', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13819', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56413-23', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13832', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15575', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13845', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15236', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13858', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14963', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13871', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-488', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13884', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14950', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13897', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14417', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13910', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16252', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13923', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16239', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13936', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12454', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13949', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12441', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13962', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-41', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13975', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16669', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13988', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14404', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14001', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1165', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14014', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14937', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14027', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16656', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14040', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13338', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14053', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16226', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14066', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-201', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14079', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-696', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14092', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735-15', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14105', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16213', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14118', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-97', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14131', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-80', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14144', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15223', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14157', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13806', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14170', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13923', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14183', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-58', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14196', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16200', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14209', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-214', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14222', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-839', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14235', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14092', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14248', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12961', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14261', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13325', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14274', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13299', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14287', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13312', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14300', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13273', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14313', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13286', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14326', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15562', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14339', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13247', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14352', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13260', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14365', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16187', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14378', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15549', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14391', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-579', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14404', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14664', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14417', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13091', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14430', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12948', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14443', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12935', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14456', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16174', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14469', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=80181-17', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14482', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12909', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14495', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16161', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14508', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16148', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14521', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15536', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14534', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15523', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14547', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56411-1587', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14560', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12922', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14573', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16135', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14586', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11791', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14599', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11921', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14612', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15510', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14625', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11908', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14638', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11778', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14651', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14079', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14664', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16122', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14677', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16630', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14690', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16864', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14703', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56411-1552', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14716', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11765', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14729', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13572', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14742', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13234', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14755', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14924', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14768', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-15', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14781', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-852', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14794', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-813', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14807', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16109', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14820', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16942', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14833', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16955', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14846', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16968', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14859', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16096', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14872', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-266', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14885', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=54391-10', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14898', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-904', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14911', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1008', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14924', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1034', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14937', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1021', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14950', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1047', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14963', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-982', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14976', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-995', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14989', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15210', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15002', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15197', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15015', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15497', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15028', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15484', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15041', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15471', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15054', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16083', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15067', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16070', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15080', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14391', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15093', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14911', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15106', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13793', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15119', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13910', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15132', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13780', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15145', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15458', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15158', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16057', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15171', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1191', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15184', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-449', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15197', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-878', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15210', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-891', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15223', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-917', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15236', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12428', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15249', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13767', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15262', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12415', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15275', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12402', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15288', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14898', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15301', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14651', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15314', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14872', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15327', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14885', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15340', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15445', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15353', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1152', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15366', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=67848-38', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15379', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13221', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15392', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16044', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15405', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16617', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15418', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12844', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15432', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15445', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-305', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15458', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14846', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15471', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15184', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15484', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14859', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15497', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11752', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15510', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16031', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15523', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56404-1555', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15536', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13897', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15549', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-748', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15562', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-683', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15575', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-735', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15588', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16018', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15601', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15171', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15614', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15158', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15627', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14066', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15640', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12831', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15653', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13754', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15666', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16604', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15679', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12389', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15692', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16005', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15705', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15992', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15718', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-318', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15731', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16591', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15744', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15418', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15757', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=66093-54', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15770', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-227', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15783', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-423', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15796', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12896', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15810', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-410', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15823', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-45', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15836', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735-2', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15849', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735-92', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15862', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735-66', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15875', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14053', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15888', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12376', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15901', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12363', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15914', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12337', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15927', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12350', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15940', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12324', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15953', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12311', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15966', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12298', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15979', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15992', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], - props, - example: (props) => , -}); - -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16005', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], - props, - example: (props) => , -}); - -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16018', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15966', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16031', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14833', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16044', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15953', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16057', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1217', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16070', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1060', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16083', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-357', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16096', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13741', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16109', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13208', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16122', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13559', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16135', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14820', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16148', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14807', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16161', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1126', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16174', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1204', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16187', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-1139', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16200', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13728', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16213', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11687', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16226', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11674', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16239', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11661', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16252', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11648', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16265', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11635', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16278', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11622', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16291', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11609', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16305', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11596', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16318', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11583', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16331', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11570', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16344', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-6', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16357', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15145', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16370', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16890', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16383', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16916', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16396', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15132', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16409', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11895', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16422', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11726', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16435', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11739', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16448', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14794', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16461', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-32', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16474', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-501', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16487', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14040', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16500', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14027', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16513', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14014', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16526', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14001', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16539', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-188', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16552', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-175', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16565', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=62581-10', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16578', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-514', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16591', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14638', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16604', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11713', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16617', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15940', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16630', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-14781', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16643', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15119', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16656', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-331', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16669', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-253', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16682', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-279', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16695', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13988', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16708', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13195', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16721', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-462', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16734', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=80181-4', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16747', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12285', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16760', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=64511-16', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16773', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13715', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16786', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15927', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16799', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12272', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16812', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12259', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16825', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15405', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16838', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13949', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16851', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15392', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16864', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13702', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16877', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-865', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16890', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15888', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16903', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15914', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16916', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15379', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16929', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-11700', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16942', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13546', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16955', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15353', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-16968', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15875', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735%3A2', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=73630-4', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735%3A15', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-15862', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735%3A66', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=69679-475', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=30735%3A92', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12246', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=33183%3A8983', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-12233', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=42385%3A43970', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=29452-13182', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); -figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=42385%3A44010', { - imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon';"], +figma.connect(Icon, 'https://figma.com/file/k5CtyJccNQUGMI5bI4lJ2g/?node-id=56214-37', { + imports: ["import { Icon } from '@coinbase/cds-mobile/icons/Icon'"], props, - example: (props) => , + example: (props) => , }); diff --git a/packages/mobile/src/icons/__figma__/LogoMark.figma.tsx b/packages/mobile/src/icons/__figma__/LogoMark.figma.tsx index e557962b18..04451d7df5 100644 --- a/packages/mobile/src/icons/__figma__/LogoMark.figma.tsx +++ b/packages/mobile/src/icons/__figma__/LogoMark.figma.tsx @@ -7,7 +7,7 @@ figma.connect( LogoMark, 'https://www.figma.com/design/46lNmiV1z8I888My5kNq7R/%E2%9C%A8-Logos?node-id=1268-157', { - imports: ["import { LogoMark } from '@coinbase/cds-mobile/icons/LogoMark';"], + imports: ["import { LogoMark } from '@coinbase/cds-mobile/icons/LogoMark'"], props: { size: figma.enum('size', { 'l (32)': 32, diff --git a/packages/mobile/src/icons/__figma__/LogoWordmark.figma.tsx b/packages/mobile/src/icons/__figma__/LogoWordmark.figma.tsx index 0eef319ab6..b26b521cc3 100644 --- a/packages/mobile/src/icons/__figma__/LogoWordmark.figma.tsx +++ b/packages/mobile/src/icons/__figma__/LogoWordmark.figma.tsx @@ -9,7 +9,7 @@ figma.connect( LogoWordmark, 'https://www.figma.com/design/46lNmiV1z8I888My5kNq7R/%E2%9C%A8-Logos?node-id=1269-502', { - imports: ["import { LogoWordmark } from '@coinbase/cds-mobile/icons/LogoWordmark';"], + imports: ["import { LogoWordmark } from '@coinbase/cds-mobile/icons/LogoWordmark'"], props: { foreground: figma.enum('color', { primary: undefined, @@ -26,8 +26,8 @@ figma.connect( 'https://www.figma.com/design/46lNmiV1z8I888My5kNq7R/%E2%9C%A8-Logos?node-id=1269-502', { imports: [ - "import { LogoWordmark } from '@coinbase/cds-mobile/icons/LogoWordmark';", - "import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme';", + "import { LogoWordmark } from '@coinbase/cds-mobile/icons/LogoWordmark'", + "import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'", ], variant: { color: 'primary Foreground' }, props: {}, diff --git a/packages/mobile/src/icons/__figma__/SubBrandLogoMark.figma.tsx b/packages/mobile/src/icons/__figma__/SubBrandLogoMark.figma.tsx index 6be61c6b5e..e08d5638e8 100644 --- a/packages/mobile/src/icons/__figma__/SubBrandLogoMark.figma.tsx +++ b/packages/mobile/src/icons/__figma__/SubBrandLogoMark.figma.tsx @@ -7,7 +7,7 @@ figma.connect( SubBrandLogoMark, 'https://www.figma.com/design/46lNmiV1z8I888My5kNq7R/%E2%9C%A8-Logos?node-id=1268-16', { - imports: ["import { SubBrandLogoMark } from '@coinbase/cds-mobile/icons/SubBrandLogoMark';"], + imports: ["import { SubBrandLogoMark } from '@coinbase/cds-mobile/icons/SubBrandLogoMark'"], props: { foreground: figma.nestedProps('Logo Mark', { color: figma.enum('color', { diff --git a/packages/mobile/src/icons/__figma__/SubBrandLogoWordmark.figma.tsx b/packages/mobile/src/icons/__figma__/SubBrandLogoWordmark.figma.tsx index d3d464e33a..4893a882c2 100644 --- a/packages/mobile/src/icons/__figma__/SubBrandLogoWordmark.figma.tsx +++ b/packages/mobile/src/icons/__figma__/SubBrandLogoWordmark.figma.tsx @@ -8,7 +8,7 @@ figma.connect( 'https://www.figma.com/design/46lNmiV1z8I888My5kNq7R/%E2%9C%A8-Logos?node-id=1268-79', { imports: [ - "import { SubBrandLogoWordmark } from '@coinbase/cds-mobile/icons/SubBrandLogoWordmark';", + "import { SubBrandLogoWordmark } from '@coinbase/cds-mobile/icons/SubBrandLogoWordmark'", ], props: { foreground: figma.nestedProps('Logo Wordmark', { diff --git a/packages/mobile/src/icons/__stories__/Logo.stories.tsx b/packages/mobile/src/icons/__stories__/Logo.stories.tsx index 29e75537a1..d9185678a1 100644 --- a/packages/mobile/src/icons/__stories__/Logo.stories.tsx +++ b/packages/mobile/src/icons/__stories__/Logo.stories.tsx @@ -112,6 +112,9 @@ const LogoScreen = () => { + + + @@ -177,6 +180,9 @@ const LogoScreen = () => { + + + diff --git a/packages/mobile/src/illustrations/__figma__/HeroSquare.figma.tsx b/packages/mobile/src/illustrations/__figma__/HeroSquare.figma.tsx index 5ef0e83e37..899ab3d338 100644 --- a/packages/mobile/src/illustrations/__figma__/HeroSquare.figma.tsx +++ b/packages/mobile/src/illustrations/__figma__/HeroSquare.figma.tsx @@ -4,1751 +4,1751 @@ import { figma } from '@figma/code-connect'; import { HeroSquare } from '../HeroSquare'; figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=10855-264', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=10855-92', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=9552-43', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8706-45', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7731-354', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7731-356', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7731-360', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34166', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7731-358', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7347-39', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7162-1423', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7162-1424', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-7', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-2', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-6', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-4', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-3', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5193-123', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5185-64', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5185-46', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2939', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2951', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2949', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2944', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2956', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2952', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2938', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2946', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2945', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2950', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2948', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2958', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2960', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2961', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2962', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2947', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2943', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2955', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2954', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2942', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2941', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5151-2940', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=5046-183', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2411', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2419', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2416', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2409', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2407', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2423', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2405', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2418', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2406', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2422', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2408', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2410', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2412', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2415', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2413', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2414', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2417', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2421', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4157-2420', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4092-93', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4049-301', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4017-187', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4017-188', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3799-244', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3799-245', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3799-246', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3258-1393', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2705-1341', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34073', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34112', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-121', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-713', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-711', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-710', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-712', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1717', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34201', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34049', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1718', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1715', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1157-170', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34106', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34033', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34148', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34058', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34174', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34104', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34139', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34116', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34075', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34123', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34006', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34226', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34001', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34000', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34084', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34199', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34202', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34074', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34204', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34196', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1719', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1716', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1797', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1720', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1252-1363', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1157-125', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34019', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34181', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34010', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34086', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34179', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34183', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=457-32292', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34129', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34177', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34175', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34085', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33999', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1092-306', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-775', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-762', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-569', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-165', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-131', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1067-109', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1055-206', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34130', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34097', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34156', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34152', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34065', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34035', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34045', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34026', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34122', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34164', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34087', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34188', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34041', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34101', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34111', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34138', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34032', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34180', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34192', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-112', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33988', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34165', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34047', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34158', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34089', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34229', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34182', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34094', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34115', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34090', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34042', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34121', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34227', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34216', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34063', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34114', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34151', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34083', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34127', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34120', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34017', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34061', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34193', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34038', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34031', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34030', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-113', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-116', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34189', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34014', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34076', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-114', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34208', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34013', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34012', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34203', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34206', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34225', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34154', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34143', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34168', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34105', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34048', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34135', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34055', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-716', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33990', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34223', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34036', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3799-315', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6886-86', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34022', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34171', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34140', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34034', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1090-259', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34137', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34205', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34011', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33994', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33996', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34028', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34039', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34149', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34220', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34054', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34113', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34215', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34155', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34009', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34169', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34200', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34107', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34133', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34108', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34125', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34124', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34068', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34027', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34050', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33980', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34142', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34195', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34118', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33979', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34136', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34213', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34008', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34100', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34025', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34144', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34162', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34176', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34194', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34088', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34160', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34096', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-117', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34117', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34023', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34187', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34007', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33981', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34161', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34221', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34109', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34062', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-107', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34131', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-115', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-244', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34044', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-246', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34217', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34185', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34166', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34190', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34153', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34093', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34173', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34098', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33982', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33984', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33983', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33986', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34119', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-715', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34059', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34134', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34211', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34167', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33989', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34178', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34043', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34002', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34040', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34209', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34132', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34224', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34067', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34064', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34005', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34163', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-110', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34004', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34003', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34021', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34191', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34184', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34186', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34037', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34020', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34095', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=458-40839', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34110', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34071', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-108', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34228', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34069', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34218', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34170', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34128', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34150', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34018', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34159', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34056', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34146', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34172', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-111', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34198', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34057', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-118', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-120', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34082', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34046', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-122', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34060', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34222', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34053', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34029', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34141', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33985', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-123', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34147', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-714', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33998', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34103', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34210', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34219', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34197', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-109', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=618-119', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34016', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-33997', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34078', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34102', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34091', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34092', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34070', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); figma.connect(HeroSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-34126', { - imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare';"], + imports: ["import { HeroSquare } from '@coinbase/cds-mobile/illustrations/HeroSquare'"], example: () => , }); diff --git a/packages/mobile/src/illustrations/__figma__/Pictogram.figma.tsx b/packages/mobile/src/illustrations/__figma__/Pictogram.figma.tsx index 07d4a3d0dd..0a9b73a77d 100644 --- a/packages/mobile/src/illustrations/__figma__/Pictogram.figma.tsx +++ b/packages/mobile/src/illustrations/__figma__/Pictogram.figma.tsx @@ -4,1476 +4,1476 @@ import { figma } from '@figma/code-connect'; import { Pictogram } from '../Pictogram'; figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6735-66', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2683-1367', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41520', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41627', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2848', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41437', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41642', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41603', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41425', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41488', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41423', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41569', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41453', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41421', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41591', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1942-1342', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1550-1341', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1795', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1794', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1549-1796', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1174-3797', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41612', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41629', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41484', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41614', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2852', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41480', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41546', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2845', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41556', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41540', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41445', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41620', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41468', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41654', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41619', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41635', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41564', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41565', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41501', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41646', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41578', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41539', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41490', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41517', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41543', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41622', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41538', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41574', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41657', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41571', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41436', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41582', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41623', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41579', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41568', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41562', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41474', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41464', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2842', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41529', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41513', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41592', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41507', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41586', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41459', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41502', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41570', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41463', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41470', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41460', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41600', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41637', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41544', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6971-1443', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41499', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41447', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41632', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41561', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41527', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41542', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41621', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41577', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41439', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41608', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41610', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41589', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41510', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41443', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41559', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41533', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41633', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41496', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2849', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2854', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2839', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41567', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41609', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41403', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41552', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41441', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41410', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41587', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41583', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41585', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2851', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41431', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41636', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41563', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41478', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41606', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41611', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41645', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2847', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2855', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41596', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2850', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41532', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41518', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41454', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41547', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41550', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41523', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41588', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41650', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41432', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41573', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41473', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41521', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41599', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41511', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41524', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41514', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41584', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41554', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41545', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41528', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41506', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41624', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41651', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41530', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41558', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41461', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41444', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41467', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41580', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41452', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2843', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41495', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41433', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41555', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41430', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41429', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41613', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41458', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41566', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41498', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41618', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41482', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41487', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41630', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41434', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41648', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41525', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41500', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41449', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41581', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2840', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2841', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41483', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41604', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41641', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41448', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41427', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41446', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41462', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41504', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41426', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41505', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41486', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41615', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41428', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41466', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41607', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41442', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41590', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41593', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41551', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41639', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41493', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41572', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41494', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41497', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41472', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41631', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41653', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41491', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41616', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41509', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41457', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41625', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41476', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41656', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2853', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41440', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41598', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41469', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41492', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41522', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41512', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41456', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41516', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41465', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41515', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41424', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41575', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41617', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41503', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41526', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41535', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41548', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41626', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41450', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41553', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41643', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41489', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41422', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2844', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41640', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41655', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41541', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41537', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41519', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41531', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41628', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41479', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41508', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7046-35', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7347-64', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-181', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-184', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-183', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-185', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-182', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-186', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8405-247', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8706-65', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=9717-81', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=10211-11', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7976-49', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7975-3', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7531-628', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7531-629', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7531-635', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7531-630', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4017-220', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1418-1634', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1951-1369', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41477', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41402', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41414', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41418', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41407', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41416', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41413', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41647', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7666-71', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41417', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41411', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41435', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41557', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41405', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41419', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41536', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41652', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41649', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41455', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41534', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41451', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41408', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41400', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41404', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41597', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41485', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41412', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41638', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1975-1450', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41401', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41415', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41481', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41594', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41438', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41634', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41576', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); figma.connect(Pictogram, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41406', { - imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram';"], + imports: ["import { Pictogram } from '@coinbase/cds-mobile/illustrations/Pictogram'"], example: () => , }); diff --git a/packages/mobile/src/illustrations/__figma__/SpotIcon.figma.tsx b/packages/mobile/src/illustrations/__figma__/SpotIcon.figma.tsx index e5ad6cb704..74396ff4f1 100644 --- a/packages/mobile/src/illustrations/__figma__/SpotIcon.figma.tsx +++ b/packages/mobile/src/illustrations/__figma__/SpotIcon.figma.tsx @@ -4,281 +4,281 @@ import { figma } from '@figma/code-connect'; import { SpotIcon } from '../SpotIcon'; figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7347-71', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-702', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-703', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-689', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-691', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-690', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-692', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-693', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-706', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-701', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-694', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-695', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-705', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-696', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-697', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-699', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-700', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-698', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-704', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4390-707', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2428', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2454', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2429', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2430', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2443', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2431', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2446', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2432', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2433', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2434', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2435', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2456', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2450', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2436', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2452', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2437', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2453', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2438', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2441', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2440', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=11813-51', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2448', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2445', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2439', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2451', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2457', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2459', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2447', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2449', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2458', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4158-2455', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3669-85', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3669-86', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3669-83', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3669-84', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); figma.connect(SpotIcon, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3669-87', { - imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon';"], + imports: ["import { SpotIcon } from '@coinbase/cds-mobile/illustrations/SpotIcon'"], example: () => , }); diff --git a/packages/mobile/src/illustrations/__figma__/SpotRectangle.figma.tsx b/packages/mobile/src/illustrations/__figma__/SpotRectangle.figma.tsx index ba7e451ba7..3a75696396 100644 --- a/packages/mobile/src/illustrations/__figma__/SpotRectangle.figma.tsx +++ b/packages/mobile/src/illustrations/__figma__/SpotRectangle.figma.tsx @@ -4,916 +4,916 @@ import { figma } from '@figma/code-connect'; import { SpotRectangle } from '../SpotRectangle'; figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=10855-110', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=9914-183', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8890-21', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6886-150', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6677-5', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2726-1381', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2547', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2567', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2590', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2562', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2577', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2548', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2549', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2582', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2587', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2554', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2568', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2579', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2557', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2558', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2559', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2571', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2564', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2566', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2553', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2592', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2578', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2581', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2593', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2552', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2573', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3391', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41315', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41299', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41266', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41256', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2597', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2570', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2594', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2546', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1877-1581', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1418-1584', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1877-1582', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1418-1585', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1877-1583', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1418-1586', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1219-1305', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1167-1886', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1167-1612', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1166-1377', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41270', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3439', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3405', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41295', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3440', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-422', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41272', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41280', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3401', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3407', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3413', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3408', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3436', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3399', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41278', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41294', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3404', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3409', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3416', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3393', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41301', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=3799-378', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1177-2545', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41262', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41261', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=475-22679', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3430', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41277', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41264', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3410', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41293', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3443', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41254', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3419', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41310', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3414', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3422', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3417', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41257', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3435', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41290', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41268', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41269', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3434', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3427', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3431', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41298', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3390', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41307', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3449', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3438', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41306', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41305', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1015-628', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-417', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-421', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1877-1580', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-419', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41286', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-418', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3428', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=156-33578', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=156-33579', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3423', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41279', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3426', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3445', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41302', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3448', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3433', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3420', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3421', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3396', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3432', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3442', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3444', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3400', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3403', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3437', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3425', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41274', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41271', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=475-22680', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3392', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-420', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41253', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41273', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3395', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3441', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41283', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2576', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2561', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2575', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2586', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41288', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41276', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41292', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41308', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41282', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3415', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41289', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41287', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41252', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3406', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41309', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3397', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41260', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2601', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2599', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2602', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2556', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=458-35869', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3411', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8374-67', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2162-1329', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3429', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2683-1345', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2572', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2584', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1418-1824', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1167-1745', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41265', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3402', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3398', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41263', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3446', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41313', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3447', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41275', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41314', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=623-3394', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=955-158', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); figma.connect(SpotRectangle, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41312', { - imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle';"], + imports: ["import { SpotRectangle } from '@coinbase/cds-mobile/illustrations/SpotRectangle'"], example: () => , }); diff --git a/packages/mobile/src/illustrations/__figma__/SpotSquare.figma.tsx b/packages/mobile/src/illustrations/__figma__/SpotSquare.figma.tsx index 3939e34907..ada29d2c81 100644 --- a/packages/mobile/src/illustrations/__figma__/SpotSquare.figma.tsx +++ b/packages/mobile/src/illustrations/__figma__/SpotSquare.figma.tsx @@ -4,991 +4,991 @@ import { figma } from '@figma/code-connect'; import { SpotSquare } from '../SpotSquare'; figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=10855-93', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=8892-31', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7808-2', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7347-53', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=7162-1425', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6891-2', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6843-16', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=6840-5', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4204-136', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4049-300', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2598', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2525', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1977', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2780', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41368', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41353', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2784', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2823', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2816', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2806', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2787', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=4017-201', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1240-1563', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1240-1562', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1125-2336', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2555', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2529', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2531', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2537', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2542', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2793', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2479', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1780', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-323', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-252', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2774', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2580', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2591', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1925', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1571', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1370', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-2132', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1553', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-703', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-624', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-493', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2804', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2809', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2812', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2829', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2826', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41343', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2819', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1866', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2814', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41323', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2821', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2524', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2583', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2527', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2595', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2596', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2544', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2550', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2913', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2796', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2390', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1131-70', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1124-98', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1125-2337', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-2091', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1889', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1733', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1647', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1128-68', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1279', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-2092', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-505', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-299', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-103', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41316', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2833', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2798', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2776', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2781', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2794', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2825', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-157', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2775', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2796', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2569', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2589', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2563', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2551', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2543', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2545', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2565', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2560', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1814', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1119', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-480', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-414', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-796', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-760', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-709', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-672', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2766', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2526', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2528', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2600', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2530', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2532', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2536', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2540', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2585', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1507', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1383', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2782', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2535', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2533', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2534', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2538', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1683-1415', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2265', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-127', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2810', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2818', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2801', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2767', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-509', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1877-1616', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1011-510', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2834', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2539', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2541', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2225-2574', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2914', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2795', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2794', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2451', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2342', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2090', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-2024', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1218-1455', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1167-2001', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-2335', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1691', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-1337', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1124-79', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-993', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-565', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1119-232', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-1123', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-1016', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1111-569', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1100-89', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=1081-94', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2822', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2807', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2836', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2827', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2808', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2771', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2793', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2817', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2777', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2785', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41317', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2820', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2778', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2769', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41349', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2811', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2800', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2768', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2790', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2832', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2828', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2789', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41319', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2813', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2791', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2792', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2773', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2788', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2802', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2797', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2835', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2805', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2786', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2783', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2831', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2803', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2837', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2830', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=2-41320', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2772', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); figma.connect(SpotSquare, 'https://figma.com/file/LmkJatvMRVzNgfiIkJDb99/?node-id=624-2815', { - imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare';"], + imports: ["import { SpotSquare } from '@coinbase/cds-mobile/illustrations/SpotSquare'"], example: () => , }); diff --git a/packages/mobile/src/index.ts b/packages/mobile/src/index.ts index 9c163390e6..75e9a1e689 100644 --- a/packages/mobile/src/index.ts +++ b/packages/mobile/src/index.ts @@ -1,3 +1,5 @@ export * from './core/theme'; +export * from './hooks/useComponentConfig'; export * from './hooks/useTheme'; +export * from './system/ComponentConfigProvider'; export * from './system/ThemeProvider'; diff --git a/packages/mobile/src/layout/Divider.tsx b/packages/mobile/src/layout/Divider.tsx index e518f8e23e..afed931021 100644 --- a/packages/mobile/src/layout/Divider.tsx +++ b/packages/mobile/src/layout/Divider.tsx @@ -2,6 +2,7 @@ import { memo, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import type { BoxProps } from './Box'; @@ -22,11 +23,9 @@ export type DividerBaseProps = { export type DividerProps = DividerBaseProps & BoxProps; -export const Divider = memo(function Divider({ - color = 'bgLine', - direction = 'horizontal', - ...boxProps -}: DividerProps) { +export const Divider = memo((_props: DividerProps) => { + const mergedProps = useComponentConfig('Divider', _props); + const { color = 'bgLine', direction = 'horizontal', ...boxProps } = mergedProps; const theme = useTheme(); const style = useMemo( () => [ diff --git a/packages/mobile/src/layout/Fallback.tsx b/packages/mobile/src/layout/Fallback.tsx index fff7c5b978..2dca933473 100644 --- a/packages/mobile/src/layout/Fallback.tsx +++ b/packages/mobile/src/layout/Fallback.tsx @@ -1,12 +1,13 @@ // Simplified version of https://github.com/tomzaku/react-native-shimmer-placeholder/blob/master/lib/ShimmerPlaceholder.js import React, { memo, useEffect, useMemo, useRef } from 'react'; -import { Animated, StyleSheet, View } from 'react-native'; +import { Animated, StyleSheet, Text, View } from 'react-native'; import type { DimensionValue, ViewStyle } from 'react-native'; import type { UseFallbackShapeOptions } from '@coinbase/cds-common/hooks/useFallbackShape'; import { useFallbackShape } from '@coinbase/cds-common/hooks/useFallbackShape'; import type { Shape } from '@coinbase/cds-common/types/Shape'; import { LinearGradient } from '../gradients/LinearGradient'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { fallbackShimmer } from '../styles/fallbackShimmer'; @@ -31,14 +32,17 @@ export type FallbackBaseProps = { export type FallbackProps = Omit & FallbackBaseProps; -export const Fallback = memo(function Fallback({ - height, - shape = 'rectangle', - width: baseWidth, - disableRandomRectWidth, - rectWidthVariant, - ...props -}: FallbackProps) { +export const Fallback = memo((_props: FallbackProps) => { + const mergedProps = useComponentConfig('Fallback', _props); + const { + height, + shape = 'rectangle', + width: baseWidth, + disableRandomRectWidth, + rectWidthVariant, + accessibilityLabel = 'Loading', + ...props + } = mergedProps; const fallbackShapeOptions = useMemo( (): UseFallbackShapeOptions => ({ disableRandomRectWidth, @@ -94,8 +98,9 @@ export const Fallback = memo(function Fallback({ ); return ( - - + + {accessibilityLabel && {accessibilityLabel}} + ['renderItem']; export type GroupProps = GroupBaseProps; /** - * @deprecated Use `Box`, `HStack` or `VStack` instead. + * @deprecated Use `Box`, `HStack` or `VStack` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 * @danger Make sure to add a `key` prop to each item. */ export const Group = memo( diff --git a/packages/mobile/src/layout/__figma__/Divider.figma.tsx b/packages/mobile/src/layout/__figma__/Divider.figma.tsx index 8a41bc9f70..e726aee299 100644 --- a/packages/mobile/src/layout/__figma__/Divider.figma.tsx +++ b/packages/mobile/src/layout/__figma__/Divider.figma.tsx @@ -7,7 +7,7 @@ figma.connect( Divider, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=283-19869&m=dev', { - imports: ["import { Divider } from '@coinbase/cds-mobile/layout/Divider';"], + imports: ["import { Divider } from '@coinbase/cds-mobile/layout/Divider'"], props: { color: figma.enum('type', { line: 'bgLine', @@ -22,7 +22,7 @@ figma.connect( Divider, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=60-654&m=dev', { - imports: ["import { Divider } from '@coinbase/cds-mobile/layout/Divider';"], + imports: ["import { Divider } from '@coinbase/cds-mobile/layout/Divider'"], props: { color: figma.enum('type', { line: 'bgLine', diff --git a/packages/mobile/src/layout/__figma__/Fallback.figma.tsx b/packages/mobile/src/layout/__figma__/Fallback.figma.tsx index 6414887668..d05e85ffcf 100644 --- a/packages/mobile/src/layout/__figma__/Fallback.figma.tsx +++ b/packages/mobile/src/layout/__figma__/Fallback.figma.tsx @@ -7,7 +7,7 @@ figma.connect( Fallback, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=731-14951&m=dev', { - imports: ["import { Fallback } from '@coinbase/cds-mobile/layout/Fallback';"], + imports: ["import { Fallback } from '@coinbase/cds-mobile/layout/Fallback'"], props: { shape: figma.enum('shape', { circle: 'circle', diff --git a/packages/mobile/src/layout/__stories__/Fallback.stories.tsx b/packages/mobile/src/layout/__stories__/Fallback.stories.tsx new file mode 100644 index 0000000000..8991f1a627 --- /dev/null +++ b/packages/mobile/src/layout/__stories__/Fallback.stories.tsx @@ -0,0 +1,90 @@ +import React from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { Box } from '../Box'; +import { Fallback } from '../Fallback'; +import { HStack } from '../HStack'; +import { VStack } from '../VStack'; + +const FallbackScreen = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default FallbackScreen; diff --git a/packages/mobile/src/layout/__tests__/Fallback.test.tsx b/packages/mobile/src/layout/__tests__/Fallback.test.tsx index d0fe3fb680..61d6a3153b 100644 --- a/packages/mobile/src/layout/__tests__/Fallback.test.tsx +++ b/packages/mobile/src/layout/__tests__/Fallback.test.tsx @@ -19,4 +19,114 @@ describe('Fallback', () => { ); expect(screen.getByTestId(testID)).toBeAccessible(); }); + + describe('shapes', () => { + it('renders rectangle shape by default', () => { + render( + + + , + ); + expect(screen.getByTestId(testID)).toBeTruthy(); + }); + + it('renders square shape', () => { + render( + + + , + ); + expect(screen.getByTestId('square-fallback')).toBeTruthy(); + }); + + it('renders squircle shape', () => { + render( + + + , + ); + expect(screen.getByTestId('squircle-fallback')).toBeTruthy(); + }); + + it('renders circle shape', () => { + render( + + + , + ); + expect(screen.getByTestId('circle-fallback')).toBeTruthy(); + }); + }); + + describe('width variants', () => { + it('renders with disableRandomRectWidth', () => { + render( + + + , + ); + expect(screen.getByTestId('no-random-fallback')).toBeTruthy(); + }); + + it('renders with rectWidthVariant', () => { + render( + + + , + ); + expect(screen.getByTestId('variant-0-fallback')).toBeTruthy(); + }); + + it('renders different rectWidthVariant values deterministically', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByTestId('variant-fallback')).toBeTruthy(); + + rerender( + + + , + ); + expect(screen.getByTestId('variant-fallback')).toBeTruthy(); + + rerender( + + + , + ); + expect(screen.getByTestId('variant-fallback')).toBeTruthy(); + }); + }); + + describe('accessibility', () => { + it('renders visually hidden text with default accessibilityLabel', () => { + render( + + + , + ); + expect(screen.getByText('Loading')).toBeTruthy(); + }); + + it('renders visually hidden text with custom accessibilityLabel', () => { + render( + + + , + ); + expect(screen.getByText('Loading profile')).toBeTruthy(); + }); + + it('does not render visually hidden text when accessibilityLabel is empty', () => { + render( + + + , + ); + expect(screen.queryByText('Loading')).toBeNull(); + }); + }); }); diff --git a/packages/mobile/src/loaders/Spinner.tsx b/packages/mobile/src/loaders/Spinner.tsx index e764973666..2582e3c59b 100644 --- a/packages/mobile/src/loaders/Spinner.tsx +++ b/packages/mobile/src/loaders/Spinner.tsx @@ -4,13 +4,28 @@ 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, + accessibilityLabel = 'Loading', ...props }: ActivityIndicatorProps) { const theme = useTheme(); const color = theme.color.bgPrimary; - return ; + return ( + + ); }); diff --git a/packages/mobile/src/loaders/__tests__/Spinner.test.tsx b/packages/mobile/src/loaders/__tests__/Spinner.test.tsx index 655d0c8587..3fd6f196fe 100644 --- a/packages/mobile/src/loaders/__tests__/Spinner.test.tsx +++ b/packages/mobile/src/loaders/__tests__/Spinner.test.tsx @@ -12,4 +12,64 @@ describe('Spinner', () => { ); expect(screen.getByTestId('mock-spinner')).toBeAccessible(); }); + + describe('size variants', () => { + it('renders with small size (default)', () => { + render( + + + , + ); + expect(screen.getByTestId('small-spinner')).toBeTruthy(); + }); + + it('renders with large size', () => { + render( + + + , + ); + expect(screen.getByTestId('large-spinner')).toBeTruthy(); + }); + }); + + describe('animating prop', () => { + it('renders with animating=true', () => { + render( + + + , + ); + expect(screen.getByTestId('animating-spinner')).toBeTruthy(); + }); + + it('renders with animating=false', () => { + render( + + + , + ); + expect(screen.getByTestId('static-spinner')).toBeTruthy(); + }); + }); + + describe('accessibility', () => { + it('uses default accessibilityLabel', () => { + render( + + + , + ); + expect(screen.getByLabelText('Loading')).toBeTruthy(); + }); + + it('accepts custom accessibilityLabel', () => { + render( + + + , + ); + expect(screen.getByLabelText('Processing')).toBeTruthy(); + }); + }); }); diff --git a/packages/mobile/src/media/Avatar.tsx b/packages/mobile/src/media/Avatar.tsx index 630c1fb052..a24a6e492f 100644 --- a/packages/mobile/src/media/Avatar.tsx +++ b/packages/mobile/src/media/Avatar.tsx @@ -11,6 +11,7 @@ import type { } from '@coinbase/cds-common/types'; import { getAccessibleColor } from '@coinbase/cds-common/utils/getAccessibleColor'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps } from '../layout/Box'; import { Text } from '../typography/Text'; @@ -49,7 +50,8 @@ export type AvatarBaseProps = SharedProps & name?: string; /** * @danger Creates a custom Avatar size. The size prop should be used in most circumstances. - * @deprecated Use the style prop instead to set the width/height properties + * @deprecated Use the style prop instead to set the width/height properties. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 * This is an escape hatch when using the Avatar in a fixed size container where you cannot control the dimensions. */ dangerouslySetSize?: number; @@ -57,8 +59,9 @@ export type AvatarBaseProps = SharedProps & export type AvatarProps = AvatarBaseProps & Omit; -export const Avatar = memo( - ({ +export const Avatar = memo((_props: AvatarProps) => { + const mergedProps = useComponentConfig('Avatar', _props); + const { alt, src, shape = 'circle', @@ -71,130 +74,129 @@ export const Avatar = memo( accessibilityLabel, style, ...props - }: AvatarProps) => { - const imgSrc = src ?? fallbackImageSrc; - const shapeStyle = shapeStyles[shape]; - const theme = useTheme(); - const avatarSize = theme.avatarSize[size]; - const placeholderLetter = name?.charAt(0); - const isLargestSize = size.includes('xx'); - const isCustomSize = typeof dangerouslySetSize !== 'undefined'; - const isCustomSizeAndSmall = isCustomSize && dangerouslySetSize <= smallAvatarSize; - const shouldUseSmallFont = isCustomSizeAndSmall || size === 's' || size === 'm'; - const spectrumColor = colorSchemeMap[colorScheme]; - const colorSchemeRgb = `rgb(${theme.spectrum[spectrumColor]})`; - - const fallbackTextColor = useMemo( - () => getAccessibleColor({ background: colorSchemeRgb }), - [colorSchemeRgb], - ); - - const computedSize = dangerouslySetSize ?? avatarSize; - const shouldShowAvatarImage = !!src || !name; - // only show a border for normal and fallback image treatments - const hasBorder = shouldShowAvatarImage && borderColor && shape !== 'hexagon'; - - const containerStyle = useMemo( - () => [hasBorder && styles.border, shapeStyle, style], - [hasBorder, shapeStyle, style], - ); - - const avatarText = useMemo(() => { - if (isLargestSize || (isCustomSize && !isCustomSizeAndSmall)) { - return ( - - {placeholderLetter} - - ); - } - if (shouldUseSmallFont) { - return ( - - {placeholderLetter} - - ); - } - + } = mergedProps; + const imgSrc = src ?? fallbackImageSrc; + const shapeStyle = shapeStyles[shape]; + const theme = useTheme(); + const avatarSize = theme.avatarSize[size]; + const placeholderLetter = name?.charAt(0); + const isLargestSize = size.includes('xx'); + const isCustomSize = typeof dangerouslySetSize !== 'undefined'; + const isCustomSizeAndSmall = isCustomSize && dangerouslySetSize <= smallAvatarSize; + const shouldUseSmallFont = isCustomSizeAndSmall || size === 's' || size === 'm'; + const spectrumColor = colorSchemeMap[colorScheme]; + const colorSchemeRgb = `rgb(${theme.spectrum[spectrumColor]})`; + + const fallbackTextColor = useMemo( + () => getAccessibleColor({ background: colorSchemeRgb }), + [colorSchemeRgb], + ); + + const computedSize = dangerouslySetSize ?? avatarSize; + const shouldShowAvatarImage = !!src || !name; + // only show a border for normal and fallback image treatments + const hasBorder = shouldShowAvatarImage && borderColor && shape !== 'hexagon'; + + const containerStyle = useMemo( + () => [hasBorder && styles.border, shapeStyle, style], + [hasBorder, shapeStyle, style], + ); + + const avatarText = useMemo(() => { + if (isLargestSize || (isCustomSize && !isCustomSizeAndSmall)) { return ( {placeholderLetter} ); - }, [ - isLargestSize, - isCustomSize, - isCustomSizeAndSmall, - fallbackTextColor, - placeholderLetter, - shouldUseSmallFont, - ]); - - const coloredFallback = useMemo( - () => ( - - {avatarText} - - ), - [avatarText, shapeStyle, colorSchemeRgb], - ); + {placeholderLetter} + + ); + } return ( + + {placeholderLetter} + + ); + }, [ + isLargestSize, + isCustomSize, + isCustomSizeAndSmall, + fallbackTextColor, + placeholderLetter, + shouldUseSmallFont, + ]); + + const coloredFallback = useMemo( + () => ( - - {shouldShowAvatarImage ? ( - - ) : ( - coloredFallback - )} - + {avatarText} - ); - }, -); + ), + [avatarText, shapeStyle, colorSchemeRgb], + ); + + return ( + + + {shouldShowAvatarImage ? ( + + ) : ( + coloredFallback + )} + + + ); +}); const styles = StyleSheet.create({ border: { diff --git a/packages/mobile/src/media/Carousel/Carousel.tsx b/packages/mobile/src/media/Carousel/Carousel.tsx index 751c2b9c1f..63020b8098 100644 --- a/packages/mobile/src/media/Carousel/Carousel.tsx +++ b/packages/mobile/src/media/Carousel/Carousel.tsx @@ -41,7 +41,10 @@ export type CarouselProps = { } & Omit & SharedProps; -/** @deprecated This component will be removed in a future version. Use new Carousel component instead. */ +/** + * @deprecated Use new Carousel component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 + */ export const Carousel = memo( forwardRef( ( diff --git a/packages/mobile/src/media/RemoteImage.tsx b/packages/mobile/src/media/RemoteImage.tsx index 43ef74cd2b..922520956b 100644 --- a/packages/mobile/src/media/RemoteImage.tsx +++ b/packages/mobile/src/media/RemoteImage.tsx @@ -15,6 +15,7 @@ import { SvgCssUri } from 'react-native-svg/css'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import type { AspectRatio, AvatarSize, FixedValue, Shape } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; type SourceProp = string | ImageProps['source']; @@ -43,7 +44,8 @@ type BaseRemoteImageProps = Omit, numb hexagon: 0, }; -export const RemoteImage = memo(function RemoteImage({ - width, - height, - aspectRatio, - shape = 'square', - shouldApplyDarkModeEnhacements, - darkModeEnhancementsApplied, - source, - size = 'm', - style, - borderColor, - borderRadius, - onError, - onLoad, - fallbackAccessibilityLabel, - fallbackAccessibilityHint, - ...props -}: RemoteImageProps) { +export const RemoteImage = memo(function RemoteImage(_props: RemoteImageProps) { + const mergedProps = useComponentConfig('RemoteImage', _props); + const { + width, + height, + aspectRatio, + shape = 'square', + shouldApplyDarkModeEnhacements, + darkModeEnhancementsApplied, + source, + size = 'm', + style, + borderColor, + borderRadius, + onError, + onLoad, + fallbackAccessibilityLabel, + fallbackAccessibilityHint, + ...props + } = mergedProps; const shapeRadius = shapeBorderRadius[shape]; const { activeColorScheme, avatarSize } = useTheme(); diff --git a/packages/mobile/src/media/RemoteImageGroup.tsx b/packages/mobile/src/media/RemoteImageGroup.tsx index 0d36c8008f..3e126c6005 100644 --- a/packages/mobile/src/media/RemoteImageGroup.tsx +++ b/packages/mobile/src/media/RemoteImageGroup.tsx @@ -10,6 +10,7 @@ import type { SharedProps, } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps } from '../layout/Box'; @@ -39,16 +40,18 @@ export type RemoteImageGroupBaseProps = SharedProps & export type RemoteImageGroupProps = RemoteImageGroupBaseProps; -export const RemoteImageGroup = ({ - children, - size = 'm', - max = 4, - shape = 'circle', - testID, - borderWidth, - borderColor = borderWidth ? 'bg' : undefined, - ...props -}: RemoteImageGroupProps) => { +export const RemoteImageGroup = (_props: RemoteImageGroupProps) => { + const mergedProps = useComponentConfig('RemoteImageGroup', _props); + const { + children, + size = 'm', + max = 4, + shape = 'circle', + testID, + borderWidth, + borderColor = borderWidth ? 'bg' : undefined, + ...props + } = mergedProps; const { avatarSize, fontFamily, color } = useTheme(); const shapeStyle = shapeStyles[shape]; @@ -119,7 +122,7 @@ export const RemoteImageGroup = ({ })} {excess > 0 && ( { expect(screen.getByText('T')).toBeTruthy(); }); + + it('applies provider config defaults', () => { + const config: ComponentConfig = { + Avatar: { + shape: 'square', + }, + }; + render( + + + + + , + ); + + expect(screen.getByTestId('avatar')).toHaveStyle({ + borderRadius: defaultTheme.borderRadius[100], + }); + }); + + it('allows local props to override provider defaults', () => { + const config: ComponentConfig = { + Avatar: { + shape: 'square', + }, + }; + render( + + + + + , + ); + + expect(screen.getByTestId('avatar')).toHaveStyle({ + borderRadius: defaultTheme.borderRadius[1000], + }); + }); }); diff --git a/packages/mobile/src/multi-content-module/__figma__/MultiContentModule.figma.tsx b/packages/mobile/src/multi-content-module/__figma__/MultiContentModule.figma.tsx index 42a0ef9d1c..b9c94fc544 100644 --- a/packages/mobile/src/multi-content-module/__figma__/MultiContentModule.figma.tsx +++ b/packages/mobile/src/multi-content-module/__figma__/MultiContentModule.figma.tsx @@ -8,7 +8,7 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14727%3A26365', { imports: [ - "import { MultiContentModule } from '@coinbase/cds-mobile/multi-content-module/MultiContentModule';", + "import { MultiContentModule } from '@coinbase/cds-mobile/multi-content-module/MultiContentModule'", ], props: { title: figma.string('headline'), diff --git a/packages/mobile/src/navigation/BrowserBar.tsx b/packages/mobile/src/navigation/BrowserBar.tsx index f5da1271bd..f68e7f5e2e 100644 --- a/packages/mobile/src/navigation/BrowserBar.tsx +++ b/packages/mobile/src/navigation/BrowserBar.tsx @@ -1,12 +1,13 @@ import React, { createContext, memo, useContext, useEffect, useState } from 'react'; import type { SharedProps } from '@coinbase/cds-common'; -import { HStack, type HStackProps } from '../layout'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { type BoxBaseProps, HStack, type HStackProps } from '../layout'; import { NavBarEnd, NavBarStart } from './TopNavBar'; -export type BrowserBarProps = SharedProps & - Omit & { +export type BrowserBarBaseProps = SharedProps & + Omit & { children: React.ReactNode; /** * start node @@ -18,6 +19,8 @@ export type BrowserBarProps = SharedProps & end?: React.ReactNode; }; +export type BrowserBarProps = BrowserBarBaseProps & Omit; + export const BrowserBarContext = createContext<{ hideStart: boolean; hideEnd: boolean; @@ -46,8 +49,9 @@ export const useBrowserBarContext = () => { return context; }; -export const BrowserBar = memo( - ({ +export const BrowserBar = memo((_props: BrowserBarProps) => { + const mergedProps = useComponentConfig('BrowserBar', _props); + const { start, end, paddingX = 3, @@ -57,38 +61,37 @@ export const BrowserBar = memo( testID, children, ...props - }: BrowserBarProps) => { - const [hideStart, setHideStart] = useState(false); - const [hideEnd, setHideEnd] = useState(false); - return ( - + - - - {hideStart ? null : start} - - - {children} - - - {hideEnd ? null : end} - + + {hideStart ? null : start} + + + {children} - - ); - }, -); + + {hideEnd ? null : end} + + + + ); +}); BrowserBar.displayName = 'BrowserBar'; diff --git a/packages/mobile/src/navigation/NavigationTitle.tsx b/packages/mobile/src/navigation/NavigationTitle.tsx index 68e07bb5a4..c79ef12dee 100644 --- a/packages/mobile/src/navigation/NavigationTitle.tsx +++ b/packages/mobile/src/navigation/NavigationTitle.tsx @@ -1,13 +1,16 @@ import { memo } from 'react'; -import { Text, type TextProps } from '../typography/Text'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { Text, type TextBaseProps, type TextProps } from '../typography/Text'; -export type NavigationTitleProps = TextProps; +export type NavigationTitleBaseProps = TextBaseProps; -export const NavigationTitle = memo( - ({ accessibilityRole = 'header', font = 'headline', ...props }: NavigationTitleProps) => { - return ; - }, -); +export type NavigationTitleProps = NavigationTitleBaseProps & TextProps; + +export const NavigationTitle = memo((_props: NavigationTitleProps) => { + const mergedProps = useComponentConfig('NavigationTitle', _props); + const { accessibilityRole = 'header', font = 'headline', ...props } = mergedProps; + return ; +}); NavigationTitle.displayName = 'NavigationTitle'; diff --git a/packages/mobile/src/navigation/NavigationTitleSelect.tsx b/packages/mobile/src/navigation/NavigationTitleSelect.tsx index 6edb4ed15f..23dec815f4 100644 --- a/packages/mobile/src/navigation/NavigationTitleSelect.tsx +++ b/packages/mobile/src/navigation/NavigationTitleSelect.tsx @@ -3,20 +3,25 @@ import React, { memo, useCallback, useMemo, useRef, useState } from 'react'; import { SelectProvider } from '../controls/SelectContext'; import { SelectOption } from '../controls/SelectOption'; import { useSelect } from '../controls/useSelect'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons'; import { HStack } from '../layout/HStack'; import { type DrawerRefBaseProps, Tray } from '../overlays'; import { Pressable } from '../system'; -import { Text, type TextProps } from '../typography/Text'; +import { Text, type TextBaseProps, type TextProps } from '../typography/Text'; -export type NavigationTitleSelectProps = Omit & { +export type NavigationTitleSelectBaseProps = Omit & { options: { label: React.ReactNode; id: string }[]; value: string; onChange: (value: string) => void; }; -export const NavigationTitleSelect = memo( - ({ +export type NavigationTitleSelectProps = NavigationTitleSelectBaseProps & + Omit; + +export const NavigationTitleSelect = memo((_props: NavigationTitleSelectProps) => { + const mergedProps = useComponentConfig('NavigationTitleSelect', _props); + const { options, value, onChange, @@ -24,53 +29,52 @@ export const NavigationTitleSelect = memo( font = 'headline', accessibilityRole = 'header', ...props - }: NavigationTitleSelectProps) => { - const [visible, setVisible] = useState(false); - const trayRef = useRef(null); + } = mergedProps; + const [visible, setVisible] = useState(false); + const trayRef = useRef(null); - const handleCloseMenu = useCallback(() => { - setVisible(false); - }, []); - const handleOpenMenu = useCallback(() => { - setVisible(true); - }, []); + const handleCloseMenu = useCallback(() => { + setVisible(false); + }, []); + const handleOpenMenu = useCallback(() => { + setVisible(true); + }, []); - const handleOptionPress = useCallback(() => { - trayRef.current?.handleClose(); - }, []); + const handleOptionPress = useCallback(() => { + trayRef.current?.handleClose(); + }, []); - const label = useMemo(() => { - return options.find((option) => option.id === value)?.label; - }, [options, value]); + const label = useMemo(() => { + return options.find((option) => option.id === value)?.label; + }, [options, value]); - const selectContextValue = useSelect({ onChange, value }); + const selectContextValue = useSelect({ onChange, value }); - return ( - <> - - - {typeof label === 'string' ? ( - - {label} - - ) : ( - label - )} - - - - {visible && ( - - - {options.map(({ id, label }) => ( - - ))} - - - )} - - ); - }, -); + return ( + <> + + + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + + + + {visible && ( + + + {options.map(({ id, label }) => ( + + ))} + + + )} + + ); +}); NavigationTitleSelect.displayName = 'NavigationTitleSelect'; diff --git a/packages/mobile/src/navigation/TopNavBar.tsx b/packages/mobile/src/navigation/TopNavBar.tsx index 7a72f73bb5..90bacfba17 100644 --- a/packages/mobile/src/navigation/TopNavBar.tsx +++ b/packages/mobile/src/navigation/TopNavBar.tsx @@ -4,7 +4,8 @@ import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Collapsible } from '../collapsible/Collapsible'; -import { Box, HStack, type HStackProps, VStack } from '../layout'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import { HStack, type HStackProps, VStack } from '../layout'; export const TopNavBarContext = React.createContext<{ isWithinTopNavBar: boolean }>({ isWithinTopNavBar: false, @@ -32,7 +33,7 @@ export type NavBarEndProps = Omit & { paddingStart?: ThemeVars.Space; }; -export type NavigationBarProps = { +export type NavigationBarBaseProps = { start?: React.ReactNode; end?: React.ReactNode; /** @@ -71,6 +72,8 @@ export type NavigationBarProps = { rowGap?: ThemeVars.Space; }; +export type NavigationBarProps = NavigationBarBaseProps; + export const NavBarStart = memo( ({ children, @@ -139,8 +142,9 @@ export const NavBarEnd = memo( NavBarEnd.displayName = 'NavBarEnd'; -export const TopNavBar = memo( - ({ +export const TopNavBar = memo((_props: NavigationBarProps) => { + const mergedProps = useComponentConfig('TopNavBar', _props); + const { start, end, children, @@ -151,38 +155,37 @@ export const TopNavBar = memo( paddingX = 3, paddingTop = 2, paddingBottom = bottom ? undefined : 2, - }: NavigationBarProps) => { - return ( - - - - {/* Always render start container */} - {start} - - {/* Middle content */} - {children} - - {/* Always render end container */} - {end} - - {bottom} - - - ); - }, -); + } = mergedProps; + return ( + + + + {/* Always render start container */} + {start} + + {/* Middle content */} + {children} + + {/* Always render end container */} + {end} + + {bottom} + + + ); +}); TopNavBar.displayName = 'TopNavBar'; diff --git a/packages/mobile/src/navigation/__figma__/BrowserBar.figma.tsx b/packages/mobile/src/navigation/__figma__/BrowserBar.figma.tsx index 37d3f6a213..f03af9d1d2 100644 --- a/packages/mobile/src/navigation/__figma__/BrowserBar.figma.tsx +++ b/packages/mobile/src/navigation/__figma__/BrowserBar.figma.tsx @@ -9,11 +9,11 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=49598-4224', { imports: [ - "import { BrowserBar } from '@coinbase/cds-mobile/navigation/BrowserBar';", - "import { BrowserBarSearchInput } from '@coinbase/cds-mobile/navigation/BrowserBarSearchInput';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { BrowserBar } from '@coinbase/cds-mobile/navigation/BrowserBar'", + "import { BrowserBarSearchInput } from '@coinbase/cds-mobile/navigation/BrowserBarSearchInput'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { leftAction: figma.boolean('show left action', { diff --git a/packages/mobile/src/navigation/__figma__/TopNavBar.figma.tsx b/packages/mobile/src/navigation/__figma__/TopNavBar.figma.tsx index 49703ba0ef..d0c7f41d80 100644 --- a/packages/mobile/src/navigation/__figma__/TopNavBar.figma.tsx +++ b/packages/mobile/src/navigation/__figma__/TopNavBar.figma.tsx @@ -18,12 +18,12 @@ figma.connect( type: 'title + subtitle', }, imports: [ - "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", - "import { NavigationTitle } from '@coinbase/cds-mobile/navigation/NavigationTitle';", - "import { NavigationSubtitle } from '@coinbase/cds-mobile/navigation/NavigationSubtitle';", + "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", + "import { NavigationTitle } from '@coinbase/cds-mobile/navigation/NavigationTitle'", + "import { NavigationSubtitle } from '@coinbase/cds-mobile/navigation/NavigationSubtitle'", ], props: { title: figma.string('↳ title'), @@ -94,11 +94,11 @@ figma.connect( type: 'dropdown', }, imports: [ - "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", - "import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation/NavigationTitleSelect';", + "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", + "import { NavigationTitleSelect } from '@coinbase/cds-mobile/navigation/NavigationTitleSelect'", ], props: { title: figma.string('↳ title'), @@ -157,11 +157,11 @@ figma.connect( type: 'with search', }, imports: [ - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", - "import { BrowserBar } from '@coinbase/cds-mobile/navigation/BrowserBar';", - "import { BrowserBarSearchInput } from '@coinbase/cds-mobile/navigation/BrowserBarSearchInput';", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", + "import { BrowserBar } from '@coinbase/cds-mobile/navigation/BrowserBar'", + "import { BrowserBarSearchInput } from '@coinbase/cds-mobile/navigation/BrowserBarSearchInput'", ], props: { startAction: figma.boolean('show left action', { @@ -215,10 +215,10 @@ figma.connect( type: 'empty', }, imports: [ - "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { startAction: figma.boolean('show left action', { @@ -270,10 +270,10 @@ figma.connect( type: 'stepper', }, imports: [ - "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { startAction: figma.boolean('show left action', { @@ -325,10 +325,10 @@ figma.connect( type: 'Market Selector', }, imports: [ - "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar';", - "import { Divider } from '@coinbase/cds-mobile/layout/Divider';", - "import { VStack } from '@coinbase/cds-mobile/layout/VStack';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { TopNavBar } from '@coinbase/cds-mobile/navigation/TopNavBar'", + "import { Divider } from '@coinbase/cds-mobile/layout/Divider'", + "import { VStack } from '@coinbase/cds-mobile/layout/VStack'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { children: figma.children('SelectChip'), diff --git a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx index 145991bdce..666d3d38d7 100644 --- a/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx +++ b/packages/mobile/src/numbers/RollingNumber/RollingNumber.tsx @@ -20,6 +20,7 @@ import { import { useLocale } from '@coinbase/cds-common/system/LocaleProvider'; import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { HStack, type HStackProps } from '../../layout/HStack'; import type { Transition } from '../../motion/types'; import { Text, type TextBaseProps, type TextProps } from '../../typography/Text'; @@ -119,13 +120,9 @@ export type RollingNumberAffixSectionProps = HStackProps & { */ textProps?: TextProps; styles?: { - /** - * Style override applied to the affix section container. - */ + /** Affix section container element */ root?: StyleProp; - /** - * Style override applied to Text within the affix section. - */ + /** Text element within the affix section */ text?: | AnimatedStyle | StyleProp @@ -180,13 +177,9 @@ export type RollingNumberValueSectionProps = HStackProps & { */ textProps?: TextProps; styles?: { - /** - * Style override applied to the value section container. - */ + /** Value section container element */ root?: StyleProp; - /** - * Style override applied to Text within the value section. - */ + /** Text element within the value section */ text?: | AnimatedStyle | StyleProp @@ -233,13 +226,9 @@ export type RollingNumberDigitProps = ViewProps & { */ textProps?: TextProps; styles?: { - /** - * Style overrides applied to the digit container view. - */ + /** Digit container element */ root?: StyleProp; - /** - * Style overrides applied to Text rendered within the digit column. - */ + /** Digit text element */ text?: | AnimatedStyle | StyleProp @@ -261,13 +250,9 @@ export type RollingNumberSymbolProps = HStackProps & { */ textProps?: TextProps; styles?: { - /** - * Style override applied to the symbol container. - */ + /** Symbol container element */ root?: StyleProp; - /** - * Style override applied to Text within the symbol component. - */ + /** Symbol text element */ text?: | AnimatedStyle | StyleProp @@ -393,308 +378,245 @@ export type RollingNumberBaseProps = SharedProps & export type RollingNumberProps = TextProps & RollingNumberBaseProps & { - /** - * Style overrides applied to RollingNumber slots. - */ + /** Custom styles for individual elements of the RollingNumber component */ styles?: { - /** - * Style override applied to the outer container view. - */ + /** Outer container element */ root?: StyleProp; - /** - * Style override applied to the visible animated content wrapper. - */ + /** Animated visible content wrapper */ visibleContent?: StyleProp; - /** - * Style override applied to the Intl formatted section wrapper. - */ + /** Formatted numeric value wrapper */ formattedValueSection?: StyleProp; - /** - * Style override applied to the prefix section rendered from props. - */ + /** Prefix section (from props) */ prefix?: StyleProp; - /** - * Style override applied to the suffix section rendered from props. - */ + /** Suffix section (from props) */ suffix?: StyleProp; - /** - * The prefix generated by Intl.NumberFormat, for example, the "$" in "$1,000". - */ + /** Prefix from Intl.NumberFormat (e.g. "$" in "$1,000") */ i18nPrefix?: StyleProp; - /** - * The suffix generated by Intl.NumberFormat, for example, the "K" in "100K". - */ + /** Suffix from Intl.NumberFormat (e.g. "K" in "100K") */ i18nSuffix?: StyleProp; - /** - * Style override applied to the integer portion of the formatted value. - */ + /** Integer portion of formatted value */ integer?: StyleProp; - /** - * Style override applied to the fractional portion of the formatted value. - */ + /** Fractional portion of formatted value */ fraction?: StyleProp; - /** - * Style override applied to Text rendered within the component. - */ + /** Text element for digits and symbols */ text?: StyleProp; }; }; export const RollingNumber = memo( - forwardRef( - ( - { - value, - color: colorProp = 'fg', - colorPulseOnUpdate, - positivePulseColor = 'fgPositive', - negativePulseColor = 'fgNegative', - font = 'inherit', - fontFamily = font, - fontSize = font, - fontWeight = font, - // default to fontSize since lineHeight changes depending on the fontSize - lineHeight = fontSize, - tabularNumbers = true, - testID, - accessibilityLiveRegion = 'polite', - locale: localeProp, - format, - style, + forwardRef((_props: RollingNumberProps, ref) => { + const mergedProps = useComponentConfig('RollingNumber', _props); + const { + value, + color: colorProp = 'fg', + colorPulseOnUpdate, + positivePulseColor = 'fgPositive', + negativePulseColor = 'fgNegative', + font = 'inherit', + fontFamily = font, + fontSize = font, + fontWeight = font, + // default to fontSize since lineHeight changes depending on the fontSize + lineHeight = fontSize, + tabularNumbers = true, + testID, + accessibilityLiveRegion = 'polite', + locale: localeProp, + format, + style, + prefix, + suffix, + styles, + enableSubscriptNotation, + transition = defaultTransitionConfig, + digitTransitionVariant = 'every', + formattedValue, + accessibilityLabel, + accessibilityLabelPrefix, + accessibilityLabelSuffix, + RollingNumberMaskComponent = DefaultRollingNumberMask, + RollingNumberAffixSectionComponent = DefaultRollingNumberAffixSection, + RollingNumberValueSectionComponent = DefaultRollingNumberValueSection, + RollingNumberDigitComponent = DefaultRollingNumberDigit, + RollingNumberSymbolComponent = DefaultRollingNumberSymbol, + ...restTextProps + } = mergedProps; + const { locale: defaultLocale } = useLocale(); + const locale = localeProp ?? defaultLocale; + const [digitHeight, setDigitHeight] = useState(); + const direction = useValueChangeDirection(value); + + const handleMeasureDigits = (e: LayoutChangeEvent) => { + const { layout } = e.nativeEvent; + setDigitHeight(layout.height); + }; + + const textProps = useMemo( + () => ({ + font, + fontSize, + fontWeight, + fontFamily, + lineHeight, + tabularNumbers, + color: colorProp, + ...restTextProps, + }), + [ + font, + fontSize, + fontWeight, + fontFamily, + lineHeight, + tabularNumbers, + colorProp, + restTextProps, + ], + ); + + const transitionConfig = useMemo( + () => ({ ...defaultTransitionConfig, ...transition }), + [transition], + ); + + const intlNumberFormatter = useMemo( + () => + new IntlNumberFormat({ + value, + format, + locale, + }), + [value, format, locale], + ); + + const formatted = useMemo( + () => formattedValue ?? intlNumberFormatter.format(), + [formattedValue, intlNumberFormatter], + ); + + const animatedColorStyle = useColorPulse({ + value, + defaultColor: colorProp, + colorPulseOnUpdate: !!colorPulseOnUpdate, + positivePulseColor, + negativePulseColor, + transitionConfig, + formatted, + }); + + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + + const invisibleMeasuredDigits = useMemo( + () => ( + + 0 + + ), + + [textProps, styles?.text], + ); + + const prefixSection = useMemo( + () => ( + // prefix from props + + {prefix} + + ), + [ + RollingNumberAffixSectionComponent, + animatedColorStyle, + styles?.prefix, + textProps, prefix, + styles?.text, + ], + ); + + const suffixSection = useMemo( + () => ( + // suffix from props + + {suffix} + + ), + [ + RollingNumberAffixSectionComponent, + animatedColorStyle, + styles?.suffix, + textProps, suffix, - styles, - enableSubscriptNotation, - transition = defaultTransitionConfig, - digitTransitionVariant = 'every', - formattedValue, - accessibilityLabel, - accessibilityLabelPrefix, - accessibilityLabelSuffix, - RollingNumberMaskComponent = DefaultRollingNumberMask, - RollingNumberAffixSectionComponent = DefaultRollingNumberAffixSection, - RollingNumberValueSectionComponent = DefaultRollingNumberValueSection, - RollingNumberDigitComponent = DefaultRollingNumberDigit, - RollingNumberSymbolComponent = DefaultRollingNumberSymbol, - ...restTextProps - }: RollingNumberProps, - ref, - ) => { - const { locale: defaultLocale } = useLocale(); - const locale = localeProp ?? defaultLocale; - const [digitHeight, setDigitHeight] = useState(); - const direction = useValueChangeDirection(value); - - const handleMeasureDigits = (e: LayoutChangeEvent) => { - const { layout } = e.nativeEvent; - setDigitHeight(layout.height); - }; - - const textProps = useMemo( - () => ({ - font, - fontSize, - fontWeight, - fontFamily, - lineHeight, - tabularNumbers, - color: colorProp, - ...restTextProps, - }), - [ - font, - fontSize, - fontWeight, - fontFamily, - lineHeight, - tabularNumbers, - colorProp, - restTextProps, - ], - ); - - const transitionConfig = useMemo( - () => ({ ...defaultTransitionConfig, ...transition }), - [transition], - ); - - const intlNumberFormatter = useMemo( - () => - new IntlNumberFormat({ - value, - format, - locale, - }), - [value, format, locale], - ); - - const formatted = useMemo( - () => formattedValue ?? intlNumberFormatter.format(), - [formattedValue, intlNumberFormatter], - ); + styles?.text, + ], + ); - const animatedColorStyle = useColorPulse({ - value, - defaultColor: colorProp, - colorPulseOnUpdate: !!colorPulseOnUpdate, - positivePulseColor, - negativePulseColor, - transitionConfig, - formatted, + const intlPartsValueSection = useMemo(() => { + const { pre, integer, fraction, post } = intlNumberFormatter.formatToParts({ + enableSubscriptNotation, }); - - const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - - const invisibleMeasuredDigits = useMemo( - () => ( - - 0 - - ), - - [textProps, styles?.text], - ); - - const prefixSection = useMemo( - () => ( - // prefix from props - + {/* Prefix generated by Intl.NumberFormat */} + - {prefix} - - ), - [ - RollingNumberAffixSectionComponent, - animatedColorStyle, - styles?.prefix, - textProps, - prefix, - styles?.text, - ], - ); - - const suffixSection = useMemo( - () => ( - // suffix from props - + + - {suffix} - - ), - [ - RollingNumberAffixSectionComponent, - animatedColorStyle, - styles?.suffix, - textProps, - suffix, - styles?.text, - ], - ); - - const intlPartsValueSection = useMemo(() => { - const { pre, integer, fraction, post } = intlNumberFormatter.formatToParts({ - enableSubscriptNotation, - }); - return ( - - {/* Prefix generated by Intl.NumberFormat */} - - - - {/* Suffix generated by Intl.NumberFormat */} - - - ); - }, [ - intlNumberFormatter, - enableSubscriptNotation, - styles?.formattedValueSection, - styles?.i18nPrefix, - styles?.text, - styles?.integer, - styles?.fraction, - styles?.i18nSuffix, - RollingNumberValueSectionComponent, - RollingNumberDigitComponent, - RollingNumberMaskComponent, - RollingNumberSymbolComponent, - digitHeight, - digitTransitionVariant, - direction, - animatedColorStyle, - textProps, - transitionConfig, - ]); - - const formattedValueValueSection = useMemo( - () => ( + transitionConfig={transitionConfig} + /> + {/* Suffix generated by Intl.NumberFormat */} - ), - [ - RollingNumberMaskComponent, - styles?.formattedValueSection, - styles?.text, - RollingNumberValueSectionComponent, - RollingNumberDigitComponent, - RollingNumberSymbolComponent, - formattedValue, - digitHeight, - digitTransitionVariant, - direction, - animatedColorStyle, - textProps, - transitionConfig, - ], + ); - - const screenReaderOnlySection = useMemo(() => { - const prefixString = typeof prefix === 'string' ? prefix : ''; - const suffixString = typeof suffix === 'string' ? suffix : ''; - const formattedWithPrefixSuffix = `${prefixString}${formatted}${suffixString}`; - return ( - - {`${accessibilityLabelPrefix ?? ''} - ${accessibilityLabel ?? formattedWithPrefixSuffix} - ${accessibilityLabelSuffix ?? ''}`} - - ); - }, [ - accessibilityLiveRegion, - textProps, - accessibilityLabelPrefix, - accessibilityLabel, - formatted, - prefix, - suffix, - accessibilityLabelSuffix, + }, [ + intlNumberFormatter, + enableSubscriptNotation, + styles?.formattedValueSection, + styles?.i18nPrefix, + styles?.text, + styles?.integer, + styles?.fraction, + styles?.i18nSuffix, + RollingNumberValueSectionComponent, + RollingNumberDigitComponent, + RollingNumberMaskComponent, + RollingNumberSymbolComponent, + digitHeight, + digitTransitionVariant, + direction, + animatedColorStyle, + textProps, + transitionConfig, + ]); + + const formattedValueValueSection = useMemo( + () => ( + + ), + [ + RollingNumberMaskComponent, + styles?.formattedValueSection, styles?.text, - ]); + RollingNumberValueSectionComponent, + RollingNumberDigitComponent, + RollingNumberSymbolComponent, + formattedValue, + digitHeight, + digitTransitionVariant, + direction, + animatedColorStyle, + textProps, + transitionConfig, + ], + ); + const screenReaderOnlySection = useMemo(() => { + const prefixString = typeof prefix === 'string' ? prefix : ''; + const suffixString = typeof suffix === 'string' ? suffix : ''; + const formattedWithPrefixSuffix = `${prefixString}${formatted}${suffixString}`; return ( - - {/* render invisible measured digits for measuring the digits height */} - {invisibleMeasuredDigits} - {/* render screen reader only section for accessibility */} - {screenReaderOnlySection} - - {prefixSection} - {formattedValue ? formattedValueValueSection : intlPartsValueSection} - {suffixSection} - - + + {`${accessibilityLabelPrefix ?? ''} + ${accessibilityLabel ?? formattedWithPrefixSuffix} + ${accessibilityLabelSuffix ?? ''}`} + ); - }, - ), + }, [ + accessibilityLiveRegion, + textProps, + accessibilityLabelPrefix, + accessibilityLabel, + formatted, + prefix, + suffix, + accessibilityLabelSuffix, + styles?.text, + ]); + + return ( + + {/* render invisible measured digits for measuring the digits height */} + {invisibleMeasuredDigits} + {/* render screen reader only section for accessibility */} + {screenReaderOnlySection} + + {prefixSection} + {formattedValue ? formattedValueValueSection : intlPartsValueSection} + {suffixSection} + + + ); + }), ); diff --git a/packages/mobile/src/numpad/Numpad.tsx b/packages/mobile/src/numpad/Numpad.tsx index 1d73e369e2..d7a66ce080 100644 --- a/packages/mobile/src/numpad/Numpad.tsx +++ b/packages/mobile/src/numpad/Numpad.tsx @@ -2,9 +2,10 @@ import React, { forwardRef, memo, useCallback, useMemo } from 'react'; import { StyleSheet, View } from 'react-native'; import { type SharedProps } from '@coinbase/cds-common'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons'; -import { HStack, VStack, type VStackProps } from '../layout'; +import { type BoxBaseProps, HStack, VStack, type VStackProps } from '../layout'; import { type HapticFeedbackType, Pressable } from '../system/Pressable'; import { Text } from '../typography/Text'; @@ -27,21 +28,25 @@ export type NumpadButtonProps = { feedback?: HapticFeedbackType; }; -export type NumpadProps = { - onPress: (value: NumpadValue) => void; - onLongPress?: (value: NumpadValue) => void; +export type NumpadBaseProps = BoxBaseProps & { separator?: string; disabled?: boolean; accessory?: React.ReactNode; action?: React.ReactNode; separatorAccessibilityLabel?: string; deleteAccessibilityLabel?: string; - /** - * Haptic feedback to trigger when being pressed. - * @default none - */ - feedback?: HapticFeedbackType; -} & SharedProps & +}; + +export type NumpadProps = NumpadBaseProps & + VStackProps & { + onPress: (value: NumpadValue) => void; + onLongPress?: (value: NumpadValue) => void; + /** + * Haptic feedback to trigger when being pressed. + * @default none + */ + feedback?: HapticFeedbackType; + } & SharedProps & VStackProps; const buttonValues: NumpadValue[][] = [ @@ -66,8 +71,9 @@ const styles = StyleSheet.create({ }); export const Numpad = memo( - forwardRef(function Numpad( - { + forwardRef((_props: NumpadProps, forwardedRef: React.ForwardedRef) => { + const mergedProps = useComponentConfig('Numpad', _props); + const { separator = '.', disabled, onPress, @@ -83,9 +89,7 @@ export const Numpad = memo( gap = 2, feedback, ...props - }: NumpadProps, - forwardedRef: React.ForwardedRef, - ) { + } = mergedProps; const buttons = useMemo(() => { return buttonValues.map((values, i) => { return ( diff --git a/packages/mobile/src/numpad/__figma__/Numpad.figma.tsx b/packages/mobile/src/numpad/__figma__/Numpad.figma.tsx index fcc11fe68d..5e05281586 100644 --- a/packages/mobile/src/numpad/__figma__/Numpad.figma.tsx +++ b/packages/mobile/src/numpad/__figma__/Numpad.figma.tsx @@ -10,8 +10,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14012%3A4589', { imports: [ - "import { Numpad } from '@coinbase/cds-mobile/numpad/Numpad';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { Numpad } from '@coinbase/cds-mobile/numpad/Numpad'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { disabled: figma.boolean('disabled'), diff --git a/packages/mobile/src/overlays/Alert.tsx b/packages/mobile/src/overlays/Alert.tsx index fdf3550570..70723e80c0 100644 --- a/packages/mobile/src/overlays/Alert.tsx +++ b/packages/mobile/src/overlays/Alert.tsx @@ -8,6 +8,7 @@ import type { } from '@coinbase/cds-common/types'; import { Button } from '../buttons'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Pictogram } from '../illustrations'; import { Box } from '../layout'; import { Text } from '../typography/Text'; @@ -62,147 +63,144 @@ export type AlertBaseProps = SharedProps & export type AlertProps = AlertBaseProps; export const Alert = memo( - forwardRef( - ( - { - title, - body, - pictogram, - visible = false, - onRequestClose, - preferredActionLabel, - onPreferredActionPress, - preferredActionVariant, - dismissActionLabel, - onDismissActionPress, - testID, - actionLayout = 'horizontal', - }, - ref, - ) => { - const [{ modalOpacity, modalScale, overlayOpacity }, animateIn, animateOut] = - useAlertAnimation(); - - useEffect(() => { - if (visible) { - animateIn.start(); - } - }, [visible, animateIn]); + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('Alert', _props); + const { + title, + body, + pictogram, + visible = false, + onRequestClose, + preferredActionLabel, + onPreferredActionPress, + preferredActionVariant, + dismissActionLabel, + onDismissActionPress, + testID, + actionLayout = 'horizontal', + } = mergedProps; + const [{ modalOpacity, modalScale, overlayOpacity }, animateIn, animateOut] = + useAlertAnimation(); - const handleClose = useCallback(() => { - animateOut.start(({ finished }) => { - if (finished) { - onRequestClose?.(); - } - }); - }, [onRequestClose, animateOut]); + useEffect(() => { + if (visible) { + animateIn.start(); + } + }, [visible, animateIn]); - useImperativeHandle( - ref, - () => ({ - onRequestClose: handleClose, - }), - [handleClose], - ); + const handleClose = useCallback(() => { + animateOut.start(({ finished }) => { + if (finished) { + onRequestClose?.(); + } + }); + }, [onRequestClose, animateOut]); - const handlePrimaryActionPress = useCallback(() => { - onPreferredActionPress?.(); - handleClose(); - }, [onPreferredActionPress, handleClose]); + useImperativeHandle( + ref, + () => ({ + onRequestClose: handleClose, + }), + [handleClose], + ); - const handleSecondaryActionPress = useCallback(() => { - onDismissActionPress?.(); - handleClose(); - }, [onDismissActionPress, handleClose]); + const handlePrimaryActionPress = useCallback(() => { + onPreferredActionPress?.(); + handleClose(); + }, [onPreferredActionPress, handleClose]); - const actionFlexBasis = actionLayout === 'vertical' ? undefined : 0; - const actionFlexDirection = actionLayout === 'vertical' ? 'column-reverse' : 'row'; + const handleSecondaryActionPress = useCallback(() => { + onDismissActionPress?.(); + handleClose(); + }, [onDismissActionPress, handleClose]); - const dismissAction = useMemo(() => { - if (!dismissActionLabel) { - return null; - } - return ( - - - - ); - }, [dismissActionLabel, handleSecondaryActionPress, actionFlexBasis]); + const actionFlexBasis = actionLayout === 'vertical' ? undefined : 0; + const actionFlexDirection = actionLayout === 'vertical' ? 'column-reverse' : 'row'; - const preferredAction = useMemo(() => { - return ( - - - - ); - }, [preferredActionLabel, handlePrimaryActionPress, preferredActionVariant, actionFlexBasis]); + const dismissAction = useMemo(() => { + if (!dismissActionLabel) { + return null; + } + return ( + + + + ); + }, [dismissActionLabel, handleSecondaryActionPress, actionFlexBasis]); + const preferredAction = useMemo(() => { return ( - - - + + + + ); + }, [preferredActionLabel, handlePrimaryActionPress, preferredActionVariant, actionFlexBasis]); + + return ( + + + + - - {!!pictogram && ( - - {/* fixed size: 120x120 */} - - - )} - - {title} - - - {body} - - - - {dismissAction} - {preferredAction} - + {!!pictogram && ( + + {/* fixed size: 120x120 */} + + + )} + + {title} + + + {body} + + + + {dismissAction} + {preferredAction} - - ); - }, - ), + + + ); + }), ); diff --git a/packages/mobile/src/overlays/Toast.tsx b/packages/mobile/src/overlays/Toast.tsx index d406c5237f..25010fe5f4 100644 --- a/packages/mobile/src/overlays/Toast.tsx +++ b/packages/mobile/src/overlays/Toast.tsx @@ -7,6 +7,7 @@ import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Button } from '../buttons'; import { useA11y } from '../hooks/useA11y'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps, HStack } from '../layout'; import { ColorSurge } from '../motion/ColorSurge'; @@ -19,106 +20,112 @@ export type ToastBaseProps = CommonToastBaseProps; export type ToastProps = ToastBaseProps & BoxProps; export const Toast = memo( - forwardRef( - ( - { text, action, onWillHide, onDidHide, bottomOffset, variant, accessibilityLabel, ...props }, - ref, - ) => { - const theme = useTheme(); - const [{ opacity, bottom }, animateIn, animateOut] = useToastAnimation(); - const { announceForA11y } = useA11y(); - const defaultA11yLabel = text + (action ? action.label : ''); + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('Toast', _props); + const { + text, + action, + onWillHide, + onDidHide, + bottomOffset, + variant, + accessibilityLabel, + ...props + } = mergedProps; + const theme = useTheme(); + const [{ opacity, bottom }, animateIn, animateOut] = useToastAnimation(); + const { announceForA11y } = useA11y(); + const defaultA11yLabel = text + (action ? action.label : ''); - useEffect(() => { - animateIn.start(({ finished }) => { - if (finished) { - // announce toast copy and action label to screen reader - announceForA11y(accessibilityLabel ?? defaultA11yLabel); - } - }); - }, [animateIn, text, action, accessibilityLabel, defaultA11yLabel, announceForA11y]); + useEffect(() => { + animateIn.start(({ finished }) => { + if (finished) { + // announce toast copy and action label to screen reader + announceForA11y(accessibilityLabel ?? defaultA11yLabel); + } + }); + }, [animateIn, text, action, accessibilityLabel, defaultA11yLabel, announceForA11y]); - const handleClose = useCallback(async (): Promise => { - onWillHide?.(); + const handleClose = useCallback(async (): Promise => { + onWillHide?.(); - return new Promise((resolve) => { - animateOut.start(({ finished }) => { - if (finished) { - onDidHide?.(); - resolve(finished); - } - }); + return new Promise((resolve) => { + animateOut.start(({ finished }) => { + if (finished) { + onDidHide?.(); + resolve(finished); + } }); - }, [onWillHide, onDidHide, animateOut]); - - const { panHandlers, panResponderAnimation } = useToastPanResponder({ - onWillHide, - onDidHide, }); + }, [onWillHide, onDidHide, animateOut]); - useImperativeHandle( - ref, - () => ({ - hide: handleClose, - }), - [handleClose], - ); + const { panHandlers, panResponderAnimation } = useToastPanResponder({ + onWillHide, + onDidHide, + }); + + useImperativeHandle( + ref, + () => ({ + hide: handleClose, + }), + [handleClose], + ); - const handleActionPress = useCallback(() => { - action?.onPress(); - void handleClose(); - }, [action, handleClose]); + const handleActionPress = useCallback(() => { + action?.onPress(); + void handleClose(); + }, [action, handleClose]); - return ( - + - - - {/* avoid pushing contents off screen */} - - {text} - - {!!action && ( - - )} - - - ); - }, - ), + + {/* avoid pushing contents off screen */} + + {text} + + {!!action && ( + + )} + + + ); + }), ); diff --git a/packages/mobile/src/overlays/__figma__/Toast.figma.tsx b/packages/mobile/src/overlays/__figma__/Toast.figma.tsx index a6a3f38920..c300ca9f3c 100644 --- a/packages/mobile/src/overlays/__figma__/Toast.figma.tsx +++ b/packages/mobile/src/overlays/__figma__/Toast.figma.tsx @@ -11,7 +11,7 @@ figma.connect( Toast, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=8500%3A674', { - imports: ["import { useToast } from '@coinbase/cds-mobile/overlays/useToast';"], + imports: ["import { useToast } from '@coinbase/cds-mobile/overlays/useToast'"], props: { hideCloseButton: figma.boolean('close', { true: undefined, diff --git a/packages/mobile/src/overlays/__stories__/AlertBasic.stories.tsx b/packages/mobile/src/overlays/__stories__/AlertBasic.stories.tsx index aca6b19ff6..1132912749 100644 --- a/packages/mobile/src/overlays/__stories__/AlertBasic.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/AlertBasic.stories.tsx @@ -5,7 +5,7 @@ import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Alert } from '../Alert'; const BasicAlert = () => { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleShow = useCallback(() => setVisible(true), []); const handleClose = useCallback(() => setVisible(false), []); @@ -13,7 +13,7 @@ const BasicAlert = () => { return ( <> - + { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleShow = useCallback(() => setVisible(true), []); const handleClose = useCallback(() => setVisible(false), []); @@ -13,7 +13,7 @@ const LongTitleAlert = () => { return ( <> - + { ); }, [closeModal, openModal, showAlert]); - useEffect(() => { - handlePress(); - showAlert(); - - return () => { - close(); - closeModal(); - }; - }, [close, closeModal, handlePress, showAlert]); - - return ; + return ; }; const AlertOverModalScreen = () => { diff --git a/packages/mobile/src/overlays/__stories__/AlertPortal.stories.tsx b/packages/mobile/src/overlays/__stories__/AlertPortal.stories.tsx index db95327602..4b1c1ce3f8 100644 --- a/packages/mobile/src/overlays/__stories__/AlertPortal.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/AlertPortal.stories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback } from 'react'; import { useAlert } from '@coinbase/cds-common/overlays/useAlert'; import { Button } from '../../buttons/Button'; @@ -36,12 +36,7 @@ const AlertExample = () => { [open, close, handleAction], ); - useEffect(() => { - showAlert(); - return () => close(); - }, [close, showAlert]); - - return ; + return ; }; const AlertPortalScreen = () => { diff --git a/packages/mobile/src/overlays/__stories__/AlertSingleAction.stories.tsx b/packages/mobile/src/overlays/__stories__/AlertSingleAction.stories.tsx index c4395020a7..ad3d12ad66 100644 --- a/packages/mobile/src/overlays/__stories__/AlertSingleAction.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/AlertSingleAction.stories.tsx @@ -5,7 +5,7 @@ import { Example, ExampleScreen } from '../../examples/ExampleScreen'; import { Alert } from '../Alert'; const SingleActionAlert = () => { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleShow = useCallback(() => setVisible(true), []); const handleClose = useCallback(() => setVisible(false), []); @@ -13,7 +13,7 @@ const SingleActionAlert = () => { return ( <> - + { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleShow = useCallback(() => setVisible(true), []); const handleClose = useCallback(() => setVisible(false), []); @@ -13,7 +13,7 @@ const BasicAlert = () => { return ( <> - + { +const SidebarDrawerContentFallback = ({ handleClose }: { handleClose: () => void }) => { return ( @@ -22,13 +22,15 @@ const SidebarDrawerContentFallback = () => { ))} - + ); }; const SideDrawerWithFallback = ({ pin = 'left' }: Pick) => { - const [isVisible, setIsVisible] = useState(true); + const [isVisible, setIsVisible] = useState(false); const setIsVisibleToOn = useCallback(() => setIsVisible(true), []); const setIsVisibleToOff = useCallback(() => setIsVisible(false), []); const [isLoading, setIsLoading] = useState(true); @@ -47,7 +49,7 @@ const SideDrawerWithFallback = ({ pin = 'left' }: Pick) {({ handleClose }) => isLoading ? ( - + ) : ( ) diff --git a/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx b/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx new file mode 100644 index 0000000000..28d4aa8a9e --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/DrawerReduceMotion.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; + +import { DefaultDrawer } from './Drawers'; + +const DrawerReduceMotionScreen = () => { + return ( + + + + + + ); +}; + +export default DrawerReduceMotionScreen; diff --git a/packages/mobile/src/overlays/__stories__/DrawerScrollable.stories.tsx b/packages/mobile/src/overlays/__stories__/DrawerScrollable.stories.tsx index af473cec3d..f7c8a5fdda 100644 --- a/packages/mobile/src/overlays/__stories__/DrawerScrollable.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/DrawerScrollable.stories.tsx @@ -19,7 +19,7 @@ type RenderItemProps = { }; const SideDrawerScrollableContent = ({ pin = 'left' }: Pick) => { - const [isVisible, setIsVisible] = useState(true); + const [isVisible, setIsVisible] = useState(false); const setIsVisibleToOn = useCallback(() => setIsVisible(true), []); const setIsVisibleToOff = useCallback(() => setIsVisible(false), []); const drawerRef = useRef(null); @@ -64,6 +64,11 @@ const SideDrawerScrollableContent = ({ pin = 'left' }: Pick + + + )} diff --git a/packages/mobile/src/overlays/__stories__/Drawers.tsx b/packages/mobile/src/overlays/__stories__/Drawers.tsx index 21bcbf8c84..78e3c04b78 100644 --- a/packages/mobile/src/overlays/__stories__/Drawers.tsx +++ b/packages/mobile/src/overlays/__stories__/Drawers.tsx @@ -11,20 +11,23 @@ import { Text } from '../../typography/Text'; import type { DrawerBaseProps } from '../drawer/Drawer'; import { Drawer } from '../drawer/Drawer'; -export const DefaultDrawer = ({ pin = 'left' }: Pick) => { - const [isVisible, setIsVisible] = useState(true); +export const DefaultDrawer = ({ + pin = 'left', + reduceMotion, +}: Pick) => { + const [isVisible, setIsVisible] = useState(false); const setIsVisibleOff = useCallback(() => setIsVisible(false), [setIsVisible]); const setIsVisibleOn = useCallback(() => setIsVisible(true), [setIsVisible]); return ( <> {isVisible && ( - + {({ handleClose }) => ( )} @@ -94,14 +97,14 @@ export const SideDrawerContent = ({ handleClose }: SideDrawerContentProps) => { ); }; export const SideDrawer = ({ pin = 'left' }: Pick) => { - const [isVisible, setIsVisible] = useState(true); + const [isVisible, setIsVisible] = useState(false); const setIsVisibleOff = useCallback(() => setIsVisible(false), [setIsVisible]); const setIsVisibleOn = useCallback(() => setIsVisible(true), [setIsVisible]); return ( diff --git a/packages/mobile/src/overlays/__stories__/ModalBackButton.stories.tsx b/packages/mobile/src/overlays/__stories__/ModalBackButton.stories.tsx index 819cd8eee3..b24d7e304a 100644 --- a/packages/mobile/src/overlays/__stories__/ModalBackButton.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/ModalBackButton.stories.tsx @@ -9,14 +9,14 @@ import { ModalFooter } from '../modal/ModalFooter'; import { ModalHeader } from '../modal/ModalHeader'; const ModalBackButtonScreen = () => { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleClose = useCallback(() => setVisible(false), []); const handleOpen = useCallback(() => setVisible(true), []); return ( - + { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleClose = useCallback(() => setVisible(false), []); const handleOpen = useCallback(() => setVisible(true), []); return ( - + setCustomFontVisible(false), []); + const handleCustomFontOpen = useCallback(() => setCustomFontVisible(true), []); + + const [reactNodeVisible, setReactNodeVisible] = useState(false); + const handleReactNodeClose = useCallback(() => setReactNodeVisible(false), []); + const handleReactNodeOpen = useCallback(() => setReactNodeVisible(true), []); + + return ( + + + + + + + + + Save} + secondaryAction={ + + } + /> + + + + + + + + Custom Title + + with subtitle + + + } + /> + + + + Save} + secondaryAction={ + + } + /> + + + + ); +} diff --git a/packages/mobile/src/overlays/__stories__/ModalCustomPadding.stories.tsx b/packages/mobile/src/overlays/__stories__/ModalCustomPadding.stories.tsx new file mode 100644 index 0000000000..a4a586d0e3 --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/ModalCustomPadding.stories.tsx @@ -0,0 +1,44 @@ +import React, { useCallback, useState } from 'react'; + +import { Button } from '../../buttons/Button'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { LoremIpsum } from '../../layout/__stories__/LoremIpsum'; +import { Modal } from '../modal/Modal'; +import { ModalBody } from '../modal/ModalBody'; +import { ModalFooter } from '../modal/ModalFooter'; +import { ModalHeader } from '../modal/ModalHeader'; + +export default function ModalCustomPaddingScreen() { + const [visible, setVisible] = useState(false); + const handleClose = useCallback(() => setVisible(false), []); + const handleOpen = useCallback(() => setVisible(true), []); + + return ( + + + + + + + + + Save} + secondaryAction={ + + } + /> + + + + ); +} diff --git a/packages/mobile/src/overlays/__stories__/ModalLong.stories.tsx b/packages/mobile/src/overlays/__stories__/ModalLong.stories.tsx index a78464c15a..b7ec58529d 100644 --- a/packages/mobile/src/overlays/__stories__/ModalLong.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/ModalLong.stories.tsx @@ -10,14 +10,14 @@ import { ModalFooter } from '../modal/ModalFooter'; import { ModalHeader } from '../modal/ModalHeader'; const ModalLongScreen = () => { - const [visible, setVisible] = useState(true); + const [visible, setVisible] = useState(false); const handleClose = useCallback(() => setVisible(false), []); const handleOpen = useCallback(() => setVisible(true), []); return ( - + { [openModal, closeModal], ); - useEffect(() => { - handlePress(); - - return () => closeModal(); - }, [closeModal, handlePress]); return ( - + ); diff --git a/packages/mobile/src/overlays/__stories__/Overlay.stories.tsx b/packages/mobile/src/overlays/__stories__/Overlay.stories.tsx index 998d729d2d..9a7d87ab03 100644 --- a/packages/mobile/src/overlays/__stories__/Overlay.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/Overlay.stories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { Modal } from 'react-native'; import { Button } from '../../buttons/Button'; @@ -23,16 +23,10 @@ const OverlayScreen = () => { }); }, [animateOverlayOut, setVisibleToOff]); - useEffect(() => { - openModal(); - - return () => closeModal(); - }, [closeModal, openModal]); - return ( - + { + return ( + + + + + Open delay 400ms + + + Close delay 200ms + + + + + Open 300 / Close 150 + + + Open 500 / Close 300 + + + + + ); +}; + const ToolTipWithA11y = ({ tooltipText, yShiftByStatusBarHeight }: Omit) => { const triggerRef = useRef(null); const { setA11yFocus } = useA11y(); @@ -206,7 +231,7 @@ const RNModalTest = () => { ); return ( - <> + @@ -232,7 +257,7 @@ const RNModalTest = () => { yShiftByStatusBarHeight={yShiftByStatusBarHeight} /> - + ); }; @@ -249,11 +274,14 @@ const DisabledTest = () => { const TooltipV2Screen = () => { return ( - - - - - + + + + + + + + ); }; diff --git a/packages/mobile/src/overlays/__stories__/TrayAction.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayAction.stories.tsx index 924850018a..4d36d55f85 100644 --- a/packages/mobile/src/overlays/__stories__/TrayAction.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TrayAction.stories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { FlatList } from 'react-native'; import { assets } from '@coinbase/cds-common/internal/data/assets'; @@ -13,7 +13,7 @@ import { Link } from '../../typography/Link'; import { Text } from '../../typography/Text'; import type { DrawerRefBaseProps } from '../drawer/Drawer'; import type { TrayBaseProps } from '../tray/Tray'; -import { Tray, TrayStickyFooter } from '../tray/Tray'; +import { Tray } from '../tray/Tray'; type Option = { title: string; @@ -145,21 +145,19 @@ export const WithStickyFooter = () => { ( + + + + )} handleBarAccessibilityLabel="Dismiss" onCloseComplete={setIsTrayVisibleToFalse} onVisibilityChange={handleTrayVisibilityChange} title={} > - {({ handleClose }) => ( - - - - - - - )} + )} diff --git a/packages/mobile/src/overlays/__stories__/TrayInformational.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayInformational.stories.tsx index 5a667c80ed..467fca8bb7 100644 --- a/packages/mobile/src/overlays/__stories__/TrayInformational.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TrayInformational.stories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Button } from '../../buttons/Button'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; @@ -8,7 +8,7 @@ import { StickyFooter } from '../../sticky-footer/StickyFooter'; import { Text } from '../../typography/Text'; import { ProgressBar } from '../../visualizations/ProgressBar'; import type { DrawerRefBaseProps } from '../drawer/Drawer'; -import { Tray, TrayStickyFooter } from '../tray/Tray'; +import { Tray } from '../tray/Tray'; export const Default = () => { const [isTrayVisible, setIsTrayVisible] = useState(false); @@ -26,35 +26,33 @@ export const Default = () => { {isTrayVisible && ( ( + + + + )} handleBarAccessibilityLabel="Dismiss" + handleBarVariant="inside" onCloseComplete={setIsTrayVisibleToFalse} onVisibilityChange={handleTrayVisibilityChange} - title={} + title="Trading activity" > - {({ handleClose }) => ( - - - - The percentage of Coinbase customers who increased or decreased their net position - in 00 over the past 24 hours through trading. What this means: Increased buying - activity can signal that the asset is gaining popularity. Last updated on May 2, - 2023. - - What this means: - - Increased buying activity can signal that the asset is gaining popularity. - - - Last updated on May 2, 2023. - - - - - - - )} + + + The percentage of Coinbase customers who increased or decreased their net position in + 00 over the past 24 hours through trading. What this means: Increased buying activity + can signal that the asset is gaining popularity. Last updated on May 2, 2023. + + What this means: + + Increased buying activity can signal that the asset is gaining popularity. + + + Last updated on May 2, 2023. + + )} @@ -83,7 +81,7 @@ export const WithProgressBar = () => { title={} > {({ handleClose }) => ( - + <> The percentage of this asset currently being held in cold storage. In order to @@ -115,7 +113,7 @@ export const WithProgressBar = () => { Got it - + )} )} diff --git a/packages/mobile/src/overlays/__stories__/TrayMessaging.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayMessaging.stories.tsx index 276cbc41d4..9f980c371a 100644 --- a/packages/mobile/src/overlays/__stories__/TrayMessaging.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TrayMessaging.stories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import { Button } from '../../buttons/Button'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; @@ -7,7 +7,7 @@ import { Box, VStack } from '../../layout'; import { StickyFooter } from '../../sticky-footer/StickyFooter'; import { Text } from '../../typography/Text'; import type { DrawerRefBaseProps } from '../drawer/Drawer'; -import { Tray, TrayStickyFooter } from '../tray/Tray'; +import { Tray } from '../tray/Tray'; export const Default = () => { const [isTrayVisible, setIsTrayVisible] = useState(false); @@ -25,30 +25,28 @@ export const Default = () => { {isTrayVisible && ( ( + + + + )} handleBarAccessibilityLabel="Dismiss" onCloseComplete={setIsTrayVisibleToFalse} onVisibilityChange={handleTrayVisibilityChange} > - {({ handleClose }) => ( - - - - - - - You’re ready to explore - - - You don’t have any dapps open right now. Here’s one you might like: [dapp] - - - - - - - )} + + + + + + You’re ready to explore + + + You don’t have any dapps open right now. Here’s one you might like: [dapp] + + )} diff --git a/packages/mobile/src/overlays/__stories__/TrayNavigation.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayNavigation.stories.tsx index 544f428ee3..6db7fe80ae 100644 --- a/packages/mobile/src/overlays/__stories__/TrayNavigation.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TrayNavigation.stories.tsx @@ -3,6 +3,7 @@ import { navigationOptions } from '@coinbase/cds-common/internal/data/navigation import type { IllustrationPictogramNames } from '@coinbase/cds-common/types'; import { NoopFn } from '@coinbase/cds-common/utils/mockUtils'; +import { Button } from '../../buttons/Button'; import { IconButton } from '../../buttons/IconButton'; import { Menu } from '../../controls/Menu'; import { SelectOption } from '../../controls/SelectOption'; @@ -13,7 +14,7 @@ import type { DrawerRefBaseProps } from '../drawer/Drawer'; import { Tray } from '../tray/Tray'; const NavigationTray = () => { - const [isTrayVisible, setIsTrayVisible] = useState(true); + const [isTrayVisible, setIsTrayVisible] = useState(false); const setIsTrayVisibleToFalse = useCallback(() => setIsTrayVisible(false), []); const [value, setValue] = useState(); const trayRef = useRef(null); @@ -27,6 +28,7 @@ const NavigationTray = () => { return ( <> + diff --git a/packages/mobile/src/overlays/__stories__/TrayPromotional.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayPromotional.stories.tsx index 1abc799109..01a89843a3 100644 --- a/packages/mobile/src/overlays/__stories__/TrayPromotional.stories.tsx +++ b/packages/mobile/src/overlays/__stories__/TrayPromotional.stories.tsx @@ -1,13 +1,21 @@ -import React, { useCallback, useRef, useState } from 'react'; +import { memo, useCallback, useRef, useState } from 'react'; +import type { IconName } from '@coinbase/cds-common/types'; import { Button } from '../../buttons/Button'; +import { ListCell } from '../../cells/ListCell'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; +import { Icon } from '../../icons'; import { SpotRectangle } from '../../illustrations'; -import { Box, VStack } from '../../layout'; +import { Box, HStack, VStack } from '../../layout'; import { StickyFooter } from '../../sticky-footer/StickyFooter'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; import { Text } from '../../typography/Text'; +import { TextBody } from '../../typography/TextBody'; +import { TextTitle1 } from '../../typography/TextTitle1'; import type { DrawerRefBaseProps } from '../drawer/Drawer'; -import { Tray, TrayStickyFooter } from '../tray/Tray'; +import { Tray } from '../tray/Tray'; export const Default = () => { const [isTrayVisible, setIsTrayVisible] = useState(false); @@ -25,31 +33,29 @@ export const Default = () => { {isTrayVisible && ( ( + + + + )} handleBarAccessibilityLabel="Dismiss" onCloseComplete={setIsTrayVisibleToFalse} onVisibilityChange={handleTrayVisibilityChange} > - {({ handleClose }) => ( - - - - - - - Earn crypto by lending, staking, and more - - - Many decentralized apps (“dapps”) let you earn yield on your crypto. Check out - trusted dapps like Aave and Compound without leaving Coinbaes. - - - - - - - )} + + + + + + Earn crypto by lending, staking, and more + + + Many decentralized apps (“dapps”) let you earn yield on your crypto. Check out trusted + dapps like Aave and Compound without leaving Coinbaes. + + )} @@ -62,8 +68,216 @@ export const PromotionalTrayScreen = () => { + + + + + + ); }; +type TrayContentType = 'addFundsInfo' | 'addAssets'; + +type QuickAction = { + name: string; + title: string; + description: string; +}; + +const quickActions: QuickAction[] = [ + { + name: 'buy', + title: 'Buy', + description: 'Buy crypto with cash', + }, + { + name: 'transfer', + title: 'Deposit', + description: 'Transfer funds from your bank', + }, + { + name: 'receive', + title: 'Receive crypto', + description: 'From another crypto wallet', + }, +]; + +const CreditCardAddAssetsTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [trayContentType, setTrayContentType] = useState('addFundsInfo'); + const setIsTrayVisibleOff = useCallback(() => { + setTrayContentType('addFundsInfo'); + setIsTrayVisible(false); + }, []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + + const handleCTAPress = useCallback(() => { + setTrayContentType('addAssets'); + }, []); + + return ( + <> + + {isTrayVisible && ( + + {trayContentType === 'addFundsInfo' ? ( + + + Here's a breakdown of your lifetime rewards earned through your Coinbase Card + purchases and transactions. + + + + + + ) : ( + + {quickActions.map((action) => ( + alert(`${action.title} pressed`)} + spacingVariant="condensed" + title={action.title} + /> + ))} + + )} + + )} + + ); +}; + +const BACKGROUND_COLOR = '#011C92'; + +type UpsellBenefit = { + key: string; + icon: IconName; + text: string; +}; + +const UPSELL_BENEFITS: UpsellBenefit[] = [ + { key: '1', icon: 'cash', text: 'Earn rewards on every purchase' }, + { key: '2', icon: 'lock', text: 'No annual fee, ever' }, + { key: '3', icon: 'star', text: 'Instant cashback in crypto' }, +]; + +const UpsellBenefitPoint = memo(function UpsellBenefitPoint({ + icon, + text, +}: { + icon: IconName; + text: string; +}) { + return ( + + + + {text} + + + ); +}); + +const ProductUpsellTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + const safeBottomPadding = useSafeBottomPadding(); + return ( + <> + + {isTrayVisible && ( + + {({ handleClose }) => ( + + )} + + )} + + ); +}; + +const ProductUpsellTrayContent = memo(function ProductUpsellTrayContent({ + benefits, + dismiss, +}: { + benefits: UpsellBenefit[]; + dismiss: () => void; +}) { + const handlePrimaryCtaPress = useCallback(() => { + alert('Primary CTA pressed'); + dismiss(); + }, [dismiss]); + + return ( + + + + + + + + + + + Upgrade your experience + + + Unlock premium features and earn more rewards. + + + + + {benefits.map(({ key, ...benefit }) => ( + + ))} + + + + + + + + + + By continuing, you agree to the terms and conditions. Rewards are subject to + eligibility requirements. + + + + + + + + ); +}); + export default PromotionalTrayScreen; diff --git a/packages/mobile/src/overlays/__stories__/TrayRedesign.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayRedesign.stories.tsx new file mode 100644 index 0000000000..d47baa2e70 --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/TrayRedesign.stories.tsx @@ -0,0 +1,1117 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Image, ScrollView } from 'react-native'; +import type { NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle } from 'react-native'; +import type { PictogramName } from '@coinbase/cds-common/types'; + +import { Button } from '../../buttons/Button'; +import { IconButton } from '../../buttons/IconButton'; +import { ListCell } from '../../cells/ListCell'; +import { Menu, SelectOption } from '../../controls'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; +import { useTheme } from '../../hooks/useTheme'; +import { Pictogram } from '../../illustrations'; +import { Box, VStack } from '../../layout'; +import { StickyFooter } from '../../sticky-footer/StickyFooter'; +import { Text } from '../../typography/Text'; +import type { DrawerRefBaseProps } from '../drawer/Drawer'; +import { Tray, type TrayProps } from '../tray/Tray'; + +import { options } from './Trays'; + +export const TrayRedesignScreen = () => { + return ( + + {/* Standard Tray Examples */} + + + + + + + + + + + + + + {/* Illustration Tray Examples */} + + + + + + + + + + + + + + {/* Full Bleed Image Tray Examples */} + + + + + + + + + + + + + + {/* Composed Tray Examples */} + + + + + + + + + + + + + + ); +}; + +// ============================================================================ +// Standard Tray Examples +// ============================================================================ + +const BasicTray = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +const TrayWithStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + title="Header" + > + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +const TrayWithListCells = () => { + const safeBottomPadding = useSafeBottomPadding(); + + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + const scrollContentStyle = useMemo( + () => ({ + paddingBottom: safeBottomPadding, + }), + [safeBottomPadding], + ); + + return ( + <> + + {isTrayVisible && ( + + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +const TrayWithListCellsStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + headerElevation={isScrolled ? 2 : 0} + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + title="Header" + verticalDrawerPercentageOfView={0.9} + > + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +// ============================================================================ +// Illustration Tray Examples +// ============================================================================ + +const IllustrationTray = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const [value, setValue] = useState(); + const trayRef = useRef(null); + + const handleOptionPress = () => { + trayRef.current?.handleClose(); + }; + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + Header + + } + > +

    + {options.map((option: string) => ( + + ))} + + + )} + + ); +}; + +const IllustrationTrayWithListCells = () => { + const safeBottomPadding = useSafeBottomPadding(); + + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + const scrollContentStyle = useMemo( + () => ({ + paddingBottom: safeBottomPadding, + }), + [safeBottomPadding], + ); + + return ( + <> + + {isTrayVisible && ( + + + Header +
    + } + verticalDrawerPercentageOfView={0.9} + > + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +const IllustrationTrayWithStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + title={ + + + Header + + } + > + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +const IllustrationTrayWithListCellsStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + headerElevation={isScrolled ? 2 : 0} + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + title={ + + + Header + + } + verticalDrawerPercentageOfView={0.9} + > + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +// ============================================================================ +// Full Bleed Image Tray Examples +// ============================================================================ + +const FULL_BLEED_IMAGE_URI = + 'https://static-assets.coinbase.com/design-system/placeholder/coinbaseHeader.jpg'; + +const FullBleedImageTray = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const [value, setValue] = useState(); + const trayRef = useRef(null); + + const handleOptionPress = () => { + trayRef.current?.handleClose(); + }; + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + + } + > + + Header + + + {options.map((option: string) => ( + + ))} + + + )} + + ); +}; + +const FullBleedImageTrayWithListCells = () => { + const safeBottomPadding = useSafeBottomPadding(); + + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const scrollContentStyle = useMemo( + () => ({ + paddingBottom: safeBottomPadding, + }), + [safeBottomPadding], + ); + + return ( + <> + + {isTrayVisible && ( + + Header + + } + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + styles={{ + handleBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + header: { + paddingHorizontal: 0, + paddingBottom: 0, + }, + }} + title={ + + + + } + verticalDrawerPercentageOfView={0.9} + > + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +const FullBleedImageTrayWithStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + styles={{ + handleBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + header: { + paddingHorizontal: 0, + paddingBottom: 0, + }, + }} + title={ + + + + } + > + + + Header + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +const FullBleedImageTrayWithListCellsStickyFooter = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + return ( + <> + + {isTrayVisible && ( + ( + + + + )} + handleBarVariant="inside" + header={ + + Header + + } + headerElevation={isScrolled ? 2 : 0} + onCloseComplete={setIsTrayVisibleOff} + onVisibilityChange={handleTrayVisibilityChange} + styles={{ + handleBar: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + zIndex: 1, + }, + header: { + paddingHorizontal: 0, + paddingBottom: 0, + }, + }} + title={ + + + + } + verticalDrawerPercentageOfView={0.9} + > + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + )} + + ); +}; + +// ============================================================================ +// Composed Tray Examples +// ============================================================================ + +type FloatingTrayProps = TrayProps & { + offset?: number; + borderRadiusValue?: number; +}; + +function FloatingTray({ + offset = 2, + borderRadiusValue = 600, + children, + styles, + ...props +}: FloatingTrayProps) { + const safeBottomPadding = useSafeBottomPadding(); + const theme = useTheme(); + + const offsetPx = theme.space[offset as keyof typeof theme.space]; + const borderRadius = theme.borderRadius[borderRadiusValue as keyof typeof theme.borderRadius]; + + const floatingStyles: ViewStyle = useMemo( + () => ({ + bottom: offsetPx + safeBottomPadding, + left: offsetPx, + right: offsetPx, + borderRadius, + width: 'auto', + }), + [offsetPx, safeBottomPadding, borderRadius], + ); + + const containerStyles: StyleProp[] = useMemo( + () => [floatingStyles, styles?.container], + [floatingStyles, styles?.container], + ); + + const drawerStyles: StyleProp[] = useMemo( + () => [{ paddingBottom: 0 }, styles?.drawer], + [styles?.drawer], + ); + + return ( + + {children} + + ); +} + +const FloatingTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + + const handleScroll = useCallback((e: NativeSyntheticEvent) => { + const scrollY = e.nativeEvent.contentOffset.y; + setIsScrolled(scrollY > 0); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + + {Array.from({ length: 20 }, (_, i) => ( + alert('Cell clicked!')} + spacingVariant="condensed" + title="Title" + /> + ))} + + + + )} + + ); +}; + +type Screen = { + title: string; + render: (props: { onNavigate: (index: number) => void }) => React.ReactNode; +}; + +type MultiScreenTrayProps = Omit & { + screens: Screen[]; + initialScreen?: number; +}; + +function MultiScreenTray({ screens, initialScreen = 0, ...props }: MultiScreenTrayProps) { + const [currentScreen, setCurrentScreen] = useState(initialScreen); + const screen = screens[currentScreen]; + + const handleBack = useCallback(() => setCurrentScreen(0), []); + const handleNavigate = useCallback((index: number) => setCurrentScreen(index), []); + + return ( + + {currentScreen > 0 && ( + + )} + {screen.title} + + } + > + {screen.render({ onNavigate: handleNavigate })} + + ); +} + +const MultiScreenTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + + const screens: Screen[] = useMemo( + () => [ + { + title: 'Settings', + render: ({ onNavigate }) => ( + + onNavigate(1)} + spacingVariant="condensed" + title="Account" + /> + onNavigate(2)} + spacingVariant="condensed" + title="Notifications" + /> + onNavigate(3)} + spacingVariant="condensed" + title="Privacy" + /> + + ), + }, + { + title: 'Account', + render: () => ( + + + Account settings content goes here. + + + ), + }, + { + title: 'Notifications', + render: () => ( + + + Notification preferences content goes here. + + + ), + }, + { + title: 'Privacy', + render: () => ( + + + Privacy settings content goes here. + + + ), + }, + ], + [], + ); + + return ( + <> + + {isTrayVisible && } + + ); +}; + +type ComposedIllustrationTrayProps = Omit & { + pictogramName: PictogramName; + title: string; +}; + +function ComposedIllustrationTray({ + pictogramName, + title, + children, + ...props +}: ComposedIllustrationTrayProps) { + return ( + + + {title} + + } + > + {children} + + ); +} + +const ComposedIllustrationTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + + return ( + <> + + {isTrayVisible && ( + + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +type ResponsiveTrayProps = TrayProps & { + footerLabel?: string; +}; + +function ResponsiveTray({ footer, footerLabel, children, ...props }: ResponsiveTrayProps) { + const resolvedFooter = + footer ?? + (footerLabel + ? ({ handleClose }: { handleClose: () => void }) => ( + + + + ) + : undefined); + + return ( + + {children} + + ); +} + +const ResponsiveTrayExample = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), []); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), []); + + return ( + <> + + {isTrayVisible && ( + + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +export default TrayRedesignScreen; diff --git a/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx b/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx new file mode 100644 index 0000000000..2bdfbe40c3 --- /dev/null +++ b/packages/mobile/src/overlays/__stories__/TrayReduceMotion.stories.tsx @@ -0,0 +1,54 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import { Button } from '../../buttons/Button'; +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { VStack } from '../../layout'; +import { Text } from '../../typography/Text'; +import type { DrawerRefBaseProps } from '../drawer/Drawer'; +import { Tray } from '../tray/Tray'; + +export const TrayReduceMotionScreen = () => { + return ( + + + + + + ); +}; + +const TrayWithReduceMotion = () => { + const [isTrayVisible, setIsTrayVisible] = useState(false); + const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); + const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); + const trayRef = useRef(null); + + const handleTrayVisibilityChange = useCallback((e: 'visible' | 'hidden') => { + console.log('Tray visibility changed:', e); + }, []); + + return ( + <> + + {isTrayVisible && ( + + + + Curabitur commodo nulla vel dolor vulputate vestibulum. Nulla et nisl molestie, + interdum lorem id, viverra. + + + + )} + + ); +}; + +export default TrayReduceMotionScreen; diff --git a/packages/mobile/src/overlays/__stories__/Trays.tsx b/packages/mobile/src/overlays/__stories__/Trays.tsx index 1cf6e4bd14..dcebc68cef 100644 --- a/packages/mobile/src/overlays/__stories__/Trays.tsx +++ b/packages/mobile/src/overlays/__stories__/Trays.tsx @@ -14,7 +14,7 @@ export const options: string[] = prices.slice(0, 4); const lotsOfOptions: string[] = prices.slice(0, 30); export const DefaultTray = ({ title }: { title?: React.ReactNode }) => { - const [isTrayVisible, setIsTrayVisible] = useState(true); + const [isTrayVisible, setIsTrayVisible] = useState(false); const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); const [value, setValue] = useState(); @@ -38,17 +38,22 @@ export const DefaultTray = ({ title }: { title?: React.ReactNode }) => { onVisibilityChange={handleTrayVisibilityChange} title={title} > - - {options.map((option: string) => ( - - ))} - + + + {options.map((option: string) => ( + + ))} + + + )} @@ -74,7 +79,7 @@ export const ScrollableTray = ({ fallbackEnabled?: boolean; verticalDrawerPercentageOfView?: number; }) => { - const [isTrayVisible, setIsTrayVisible] = useState(true); + const [isTrayVisible, setIsTrayVisible] = useState(false); const setIsTrayVisibleOff = useCallback(() => setIsTrayVisible(false), [setIsTrayVisible]); const setIsTrayVisibleOn = useCallback(() => setIsTrayVisible(true), [setIsTrayVisible]); const [value, setValue] = useState(); @@ -121,6 +126,7 @@ export const ScrollableTray = ({ & Omit & { /** Component to render as the Modal content */ - children: DrawerRenderChildren | React.ReactNode; + children?: DrawerRenderChildren | React.ReactNode; /** * Pin the modal to one side of the screen * @default bottom * */ - pin: PinningDirection; + pin?: PinningDirection; /** * Prevents a user from dismissing the drawer by pressing the overlay or swiping - * @default false */ preventDismissGestures?: boolean; /** * Prevents a user from dismissing the drawer by pressing hardware back button on Android - * @default false */ preventHardwareBackBehaviorAndroid?: boolean; + /** + * The HandleBar can be rendered inside or outside the drawer, when pinned to bottom. + * @default 'outside' + * @note The 'outside' variant is deprecated. Use 'inside' for new implementations. + */ + handleBarVariant?: HandleBarProps['variant']; /** * The HandleBar by default only is used for a bottom pinned drawer. This removes it. - * @default false * */ hideHandleBar?: boolean; /** Action that will happen when drawer is dismissed */ @@ -96,35 +97,72 @@ export type DrawerBaseProps = SharedProps & handleBarAccessibilityLabel?: string; /** * StickyFooter to be rendered at bottom of Drawer - * @deprecated Use TrayStickyFooter as a Tray child instead. + * @deprecated Use TrayStickyFooter as a Tray child instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 */ stickyFooter?: DrawerRenderChildren | React.ReactNode; + /** + * When true, the drawer opens and closes with an opacity fade instead of + * a slide animation. Swipe-to-dismiss gestures remain enabled and use + * the slide transform so the drawer follows the user's finger naturally. + */ + reduceMotion?: boolean; + /** Callback fired when the open animation completes. */ + onOpenComplete?: () => void; + /** + * disable safe area padding for bottom of drawer when true + */ + disableSafeAreaPaddingBottom?: boolean; }; -export type DrawerProps = DrawerBaseProps; +export type DrawerProps = DrawerBaseProps & { + styles?: { + /** Root container element */ + root?: StyleProp; + /** Overlay backdrop element */ + overlay?: StyleProp; + /** Animated sliding container element */ + container?: StyleProp; + /** Handle bar container element */ + handleBar?: PressableProps['style']; + /** Handle bar indicator element */ + handleBarHandle?: StyleProp; + /** Drawer content wrapper element */ + drawer?: StyleProp; + }; +}; const overlayContentContextValue: OverlayContentContextValue = { isDrawer: true, }; export const Drawer = memo( - forwardRef(function Drawer( - { + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('Drawer', _props); + const { children, pin = 'bottom', onCloseComplete, - preventDismissGestures = false, - preventHardwareBackBehaviorAndroid = false, - hideHandleBar = false, + preventDismissGestures, + preventHardwareBackBehaviorAndroid, + handleBarVariant = 'outside', + hideHandleBar, disableCapturePanGestureToDismiss = false, onBlur, verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, handleBarAccessibilityLabel = 'Dismiss', + accessibilityLabel, + accessibilityLabelledBy, + reduceMotion, + onOpenComplete, + style, + styles, + accessibilityRole = 'alert', + animationType = 'none', + disableSafeAreaPaddingBottom, ...props - }, - ref, - ) { - const { activeColorScheme } = useTheme(); + } = mergedProps; + const theme = useTheme(); const { width, height } = useWindowDimensions(); const isAndroid = Platform.OS === 'android'; @@ -132,13 +170,14 @@ export const Drawer = memo( drawerAnimation, animateDrawerOut, animateDrawerIn, + animateSnapBack, drawerAnimationStyles, animateSwipeToClose, - } = useDrawerAnimation(pin, verticalDrawerPercentageOfView); + } = useDrawerAnimation(pin, verticalDrawerPercentageOfView, reduceMotion); const [opacityAnimation, animateOverlayIn, animateOverlayOut] = useOverlayAnimation( drawerAnimationDefaultDuration, ); - const spacingStyles = useDrawerSpacing(pin); + const paddingStyles = useDrawerSpacing(pin, disableSafeAreaPaddingBottom); const isMounted = useRef(false); const handleClose = useCallback(() => { @@ -171,15 +210,16 @@ export const Drawer = memo( Animated.parallel([animateOverlayIn, animateDrawerIn]).start(({ finished }) => { if (finished) { isMounted.current = true; + onOpenComplete?.(); } }); } - }, [drawerAnimation, animateDrawerIn, animateOverlayIn]); + }, [drawerAnimation, animateDrawerIn, animateOverlayIn, onOpenComplete]); const panGestureHandlers = useDrawerPanResponder({ pin, drawerAnimation, - animateDrawerIn, + animateSnapBack, disableCapturePanGestureToDismiss, onBlur, handleSwipeToClose, @@ -187,8 +227,10 @@ export const Drawer = memo( verticalDrawerPercentageOfView, }); - const isPinHorizontal = pin === 'left' || pin === 'right'; - const shouldShowHandleBar = !hideHandleBar && pin === 'bottom'; + const isSideDrawer = pin === 'left' || pin === 'right'; + const showHandleBar = !hideHandleBar && pin === 'bottom'; + const showHandleBarOutside = showHandleBar && handleBarVariant === 'outside'; + const showHandleBarInside = showHandleBar && handleBarVariant === 'inside'; // leave 15% of the screenwidth as open area for menu drawer const horizontalDrawerWidth = useMemo( @@ -215,9 +257,11 @@ export const Drawer = memo( [height, verticalDrawerPercentageOfView, keyboardInset], ); - const getPanGestureHandlers = !preventDismissGestures - ? panGestureHandlers.panHandlers - : undefined; + // For inside variant, pan handlers go on handlebar, for outside variant, on container + const getContainerPanHandlers = + !preventDismissGestures && !showHandleBarInside ? panGestureHandlers.panHandlers : undefined; + const getHandleBarPanHandlers = + !preventDismissGestures && showHandleBarInside ? panGestureHandlers.panHandlers : undefined; const handleOverlayPress = useCallback(() => { if (!preventDismissGestures) { @@ -226,69 +270,86 @@ export const Drawer = memo( } }, [handleClose, preventDismissGestures, onBlur]); - const cardStyles = StyleSheet.create({ - spacing: { - ...spacingStyles, - }, - overflowStyles: { - overflow: 'hidden', - }, - }); - - useImperativeHandle( - ref, - () => ({ - handleClose, - }), - [handleClose], - ); + useImperativeHandle(ref, () => ({ handleClose }), [handleClose]); const content = useMemo( () => (typeof children === 'function' ? children({ handleClose }) : children), [children, handleClose], ); + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + + const containerStyle = useMemo( + () => [drawerAnimationStyles, styles?.container], + [drawerAnimationStyles, styles?.container], + ); + + const drawerStyle: StyleProp = useMemo( + () => [paddingStyles, { overflow: 'hidden' }, styles?.drawer], + [paddingStyles, styles?.drawer], + ); + + const handleBar = useMemo( + () => ( + + ), + [ + handleBarAccessibilityLabel, + handleClose, + getHandleBarPanHandlers, + styles?.handleBar, + styles?.handleBarHandle, + handleBarVariant, + ], + ); + return ( - {shouldShowHandleBar && ( - - )} + {showHandleBarOutside && handleBar} + {showHandleBarInside && handleBar} {content} diff --git a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx index 349879927c..5fded9f3b6 100644 --- a/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx +++ b/packages/mobile/src/overlays/drawer/__tests__/Drawer.test.tsx @@ -42,6 +42,7 @@ const MockDrawer = ({ onCloseComplete, pin = 'bottom', preventDismissGestures, + reduceMotion, }: Partial) => { const [isVisible, setIsVisible] = useState(false); const setIsVisibleOn = useCallback(() => setIsVisible(true), [setIsVisible]); @@ -61,6 +62,7 @@ const MockDrawer = ({ onCloseComplete={handleRequestClose} pin={pin} preventDismissGestures={preventDismissGestures} + reduceMotion={reduceMotion} visible={isVisible} > {({ handleClose }) => ( @@ -151,4 +153,28 @@ describe('Drawer', () => { await delay(DURATION); expect(onCloseComplete).not.toHaveBeenCalled(); }); + + describe('reduceMotion', () => { + it('closes the Drawer when the close button is pressed with reduceMotion enabled', async () => { + const onCloseComplete = jest.fn(); + render(); + + fireEvent.press(screen.getByText('Open Drawer')); + expect(screen.getByText(loremIpsum)).toBeTruthy(); + + fireEvent.press(screen.getByText('Close Drawer')); + await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); + }); + + it('still closes the Drawer via overlay press with reduceMotion enabled', async () => { + const onCloseComplete = jest.fn(); + render(); + + fireEvent.press(screen.getByText('Open Drawer')); + expect(screen.getByText(loremIpsum)).toBeTruthy(); + + fireEvent(screen.getByTestId('drawer-overlay'), 'onTouchStart'); + await waitFor(() => expect(onCloseComplete).toHaveBeenCalledTimes(1)); + }); + }); }); diff --git a/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts b/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts index 240e52820e..f1756fe9b6 100644 --- a/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts +++ b/packages/mobile/src/overlays/drawer/useDrawerAnimation.ts @@ -24,6 +24,7 @@ const animateDrawer = { export const useDrawerAnimation = ( pin: PinningDirection | undefined = 'bottom', verticalDrawerPercentageOfView: number | undefined = defaultVerticalDrawerPercentageOfView, + reduceMotion?: boolean, ) => { const windowDimensions = useWindowDimensions(); @@ -33,15 +34,40 @@ export const useDrawerAnimation = ( : windowDimensions.width * horizontalDrawerPercentageOfView; const drawerAnimation = useRef(new Animated.Value(0)); + // Separate opacity value used when reduceMotion is true so that + // open/close-button fades are independent of the transform that + // the pan-responder drives during swipe gestures. + const contentOpacity = useRef(new Animated.Value(reduceMotion ? 0 : 1)); - const animateDrawerIn = useMemo( - () => Animated.timing(drawerAnimation.current, animateDrawer.animateIn), - [], - ); - const animateDrawerOut = useMemo( - () => Animated.timing(drawerAnimation.current, animateDrawer.animateOut), - [], - ); + const animateDrawerIn = useMemo(() => { + if (reduceMotion) { + return Animated.parallel([ + Animated.timing(drawerAnimation.current, { + ...animateDrawer.animateIn, + duration: 0, + }), + Animated.timing(contentOpacity.current, animateDrawer.animateIn), + ]); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateIn); + }, [reduceMotion]); + + const animateDrawerOut = useMemo(() => { + if (reduceMotion) { + return Animated.timing(contentOpacity.current, animateDrawer.animateOut); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateOut); + }, [reduceMotion]); + + const animateSnapBack = useMemo(() => { + if (reduceMotion) { + return Animated.parallel([ + Animated.timing(drawerAnimation.current, animateDrawer.animateIn), + Animated.timing(contentOpacity.current, animateDrawer.animateIn), + ]); + } + return Animated.timing(drawerAnimation.current, animateDrawer.animateIn); + }, [reduceMotion]); /** custom animation config for swipe and fling to close that has no friction and is faster */ const animateSwipeToClose = useMemo( @@ -89,13 +115,30 @@ export const useDrawerAnimation = ( } }, [pin, drawerDimension]); + const drawerAnimationStyles = useMemo(() => { + if (reduceMotion) { + return { + opacity: contentOpacity.current, + transform: [translation], + }; + } + return { transform: [translation] }; + }, [reduceMotion, translation]); + return useMemo(() => { return { drawerAnimation: drawerAnimation.current, animateDrawerOut, animateDrawerIn, - drawerAnimationStyles: { transform: [translation] }, + animateSnapBack, + drawerAnimationStyles, animateSwipeToClose, }; - }, [animateDrawerOut, animateDrawerIn, translation, animateSwipeToClose]); + }, [ + animateDrawerOut, + animateDrawerIn, + animateSnapBack, + drawerAnimationStyles, + animateSwipeToClose, + ]); }; diff --git a/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts b/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts index ea2c65be97..5323da4942 100644 --- a/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts +++ b/packages/mobile/src/overlays/drawer/useDrawerPanResponder.ts @@ -19,7 +19,7 @@ import { modulate } from '@coinbase/cds-common/utils/modulate'; type UseDrawerPanResponderParams = { drawerAnimation: Animated.Value; - animateDrawerIn: Animated.CompositeAnimation; + animateSnapBack: Animated.CompositeAnimation; pin: PinningDirection; disableCapturePanGestureToDismiss: boolean; onBlur?: () => void; @@ -38,7 +38,7 @@ const calculateDragOffset = (x: number) => { export const useDrawerPanResponder = ({ pin, drawerAnimation, - animateDrawerIn, + animateSnapBack, disableCapturePanGestureToDismiss, onBlur, handleSwipeToClose, @@ -233,13 +233,13 @@ export const useDrawerPanResponder = ({ onBlur?.(); handleSwipeToClose(); } else { - animateDrawerIn.start(); + animateSnapBack.start(); } }, }); }, [ drawerAnimation, - animateDrawerIn, + animateSnapBack, parseGestureState, shouldCaptureGestures, shouldDismiss, diff --git a/packages/mobile/src/overlays/drawer/useDrawerSpacing.ts b/packages/mobile/src/overlays/drawer/useDrawerSpacing.ts index 7863acf1be..7e1c7ac187 100644 --- a/packages/mobile/src/overlays/drawer/useDrawerSpacing.ts +++ b/packages/mobile/src/overlays/drawer/useDrawerSpacing.ts @@ -5,24 +5,35 @@ import { MAX_OVER_DRAG } from '@coinbase/cds-common/animation/drawer'; import { useSafeBottomPadding } from '../../hooks/useSafeBottomPadding'; -export const useDrawerSpacing = (pin: PinningDirection | undefined = 'bottom') => { - const { top } = useSafeAreaInsets(); +export const useDrawerSpacing = ( + pin: PinningDirection | undefined = 'bottom', + disableSafeAreaPaddingBottom: boolean = false, +) => { + const { top: safeTopPadding } = useSafeAreaInsets(); const safeBottomPadding: number = useSafeBottomPadding(); const safeAreaStyles = useMemo(() => { switch (pin) { case 'top': - return { paddingTop: top + MAX_OVER_DRAG }; + return { paddingTop: safeTopPadding + MAX_OVER_DRAG }; case 'left': - return { paddingTop: top, paddingLeft: MAX_OVER_DRAG }; + return { paddingTop: safeTopPadding, paddingLeft: MAX_OVER_DRAG }; case 'bottom': - return { paddingBottom: safeBottomPadding + MAX_OVER_DRAG }; + return { + paddingBottom: disableSafeAreaPaddingBottom + ? MAX_OVER_DRAG + : safeBottomPadding + MAX_OVER_DRAG, + }; case 'right': - return { paddingTop: top, paddingRight: MAX_OVER_DRAG }; + return { paddingTop: safeTopPadding, paddingRight: MAX_OVER_DRAG }; default: - return { paddingBottom: safeBottomPadding + MAX_OVER_DRAG }; + return { + paddingBottom: disableSafeAreaPaddingBottom + ? MAX_OVER_DRAG + : safeBottomPadding + MAX_OVER_DRAG, + }; } - }, [pin, safeBottomPadding, top]); + }, [pin, safeBottomPadding, safeTopPadding, disableSafeAreaPaddingBottom]); return safeAreaStyles; }; diff --git a/packages/mobile/src/overlays/handlebar/HandleBar.tsx b/packages/mobile/src/overlays/handlebar/HandleBar.tsx index 35151e28e3..c7e641b120 100644 --- a/packages/mobile/src/overlays/handlebar/HandleBar.tsx +++ b/packages/mobile/src/overlays/handlebar/HandleBar.tsx @@ -1,6 +1,14 @@ -import { useCallback } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; -import type { AccessibilityActionEvent, ViewProps } from 'react-native'; +import { useCallback, useMemo } from 'react'; +import { Pressable, View } from 'react-native'; +import type { + AccessibilityActionEvent, + GestureResponderHandlers, + PressableProps, + PressableStateCallbackType, + StyleProp, + ViewProps, + ViewStyle, +} from 'react-native'; import { handleBarHeight } from '@coinbase/cds-common/tokens/drawer'; import { useTheme } from '../../hooks/useTheme'; @@ -8,19 +16,36 @@ import { useTheme } from '../../hooks/useTheme'; export type HandleBarProps = ViewProps & { /** Callback fired when the handlebar is pressed via accessibility action */ onAccessibilityPress?: () => void; + /** + * The HandleBar can be rendered inside or outside the drawer. + * @default 'outside' + */ + variant?: 'inside' | 'outside'; + /** Pan responder handlers for drag-to-dismiss functionality. */ + panHandlers?: GestureResponderHandlers; + styles?: { + root?: PressableProps['style']; + handle?: StyleProp; + }; }; -export const HandleBar = ({ onAccessibilityPress, ...props }: HandleBarProps) => { - const theme = useTheme(); - const handleBarBackgroundColor = theme.color.bgSecondary; - const handleBarStyles = { - backgroundColor: handleBarBackgroundColor, - }; +// Fixed pixel values used intentionally — handle size should not scale with theme density. +const HANDLE_WIDTH_OUTSIDE = 64; +const HANDLE_WIDTH_INSIDE = 32; +const HANDLE_OPACITY_INSIDE = 0.4; - const touchableAreaStyles = { - paddingBottom: theme.space[2], - paddingTop: theme.space[2], - }; +export const HandleBar = ({ + onAccessibilityPress, + variant = 'outside', + panHandlers, + style, + styles, + ...props +}: HandleBarProps) => { + const theme = useTheme(); + const paddingY = theme.space[2]; + const isInside = variant === 'inside'; + const handleBarBackgroundColor = theme.color[isInside ? 'bgInverse' : 'bgSecondary']; const handleAccessibilityAction = useCallback( (event: AccessibilityActionEvent) => { @@ -31,29 +56,63 @@ export const HandleBar = ({ onAccessibilityPress, ...props }: HandleBarProps) => [onAccessibilityPress], ); + const pressableStyle = useCallback( + (state: PressableStateCallbackType) => [ + { + alignItems: 'center' as const, + paddingBottom: paddingY, + paddingTop: paddingY, + }, + style, + typeof styles?.root === 'function' ? styles?.root(state) : styles?.root, + ], + [paddingY, style, styles], + ); + + const handleBarStyle = useMemo( + () => [ + { + width: isInside ? HANDLE_WIDTH_INSIDE : HANDLE_WIDTH_OUTSIDE, + height: handleBarHeight, + backgroundColor: handleBarBackgroundColor, + borderRadius: 4, + opacity: isInside ? HANDLE_OPACITY_INSIDE : 1, + }, + styles?.handle, + ], + [isInside, handleBarBackgroundColor, styles?.handle], + ); + + if (isInside) { + return ( + + + + ); + } + return ( - + ); }; -const styles = StyleSheet.create({ - touchableArea: { - alignItems: 'center', - }, - handleBar: { - width: 64, - height: handleBarHeight, - borderRadius: 4, - }, -}); - HandleBar.displayName = 'HandleBar'; diff --git a/packages/mobile/src/overlays/modal/Modal.tsx b/packages/mobile/src/overlays/modal/Modal.tsx index b0677a9c0b..9df112d2ce 100644 --- a/packages/mobile/src/overlays/modal/Modal.tsx +++ b/packages/mobile/src/overlays/modal/Modal.tsx @@ -17,6 +17,7 @@ import { } from '@coinbase/cds-common/overlays/OverlayContentContext'; import type { PositionStyles, SharedProps } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { VStack } from '../../layout'; import { useModalAnimation } from './useModalAnimation'; @@ -48,7 +49,9 @@ const overlayContentContextValue: OverlayContentContextValue = { }; export const Modal = memo( - forwardRef((props, ref) => { + forwardRef((_props, ref) => { + const mergedProps = useComponentConfig('Modal', _props); + const props = mergedProps; const { children, visible, diff --git a/packages/mobile/src/overlays/modal/ModalBody.tsx b/packages/mobile/src/overlays/modal/ModalBody.tsx index 93b7948a7e..08314742d1 100644 --- a/packages/mobile/src/overlays/modal/ModalBody.tsx +++ b/packages/mobile/src/overlays/modal/ModalBody.tsx @@ -1,18 +1,40 @@ -import React, { useMemo } from 'react'; +import React, { memo, useMemo } from 'react'; import { KeyboardAvoidingView, ScrollView } from 'react-native'; import type { ScrollViewProps } from 'react-native'; import { useModalContext } from '@coinbase/cds-common/overlays/ModalContext'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { useContentSize } from '../../hooks/useContentSize'; import { useLayout } from '../../hooks/useLayout'; -import { Box } from '../../layout'; +import { Box, type BoxBaseProps } from '../../layout/Box'; -type ModalBodyProps = ScrollViewProps; +export type ModalBodyBaseProps = ScrollViewProps & + Pick< + BoxBaseProps, + | 'padding' + | 'paddingX' + | 'paddingY' + | 'paddingTop' + | 'paddingBottom' + | 'paddingStart' + | 'paddingEnd' + >; -export const ModalBody: React.FC> = ({ - children, - ...props -}) => { +export type ModalBodyProps = ModalBodyBaseProps; + +export const ModalBody: React.FC> = memo((_props) => { + const mergedProps = useComponentConfig('ModalBody', _props); + const { + children, + padding, + paddingX = 3, + paddingY: paddingYProp, + paddingTop, + paddingBottom, + paddingStart, + paddingEnd, + ...props + } = mergedProps; const [{ height: contentHeight }, onContentSizeChange] = useContentSize(); const [{ height: scrollHeight }, onLayout] = useLayout(); const { hideDividers } = useModalContext(); @@ -23,6 +45,11 @@ export const ModalBody: React.FC> = ({ [contentHeight, scrollHeight], ); + const paddingY = useMemo(() => { + if (paddingYProp !== undefined) return paddingYProp; + return hideDividers ? 0 : 3; + }, [paddingYProp, hideDividers]); + return ( > = ({ > ); -}; +}); diff --git a/packages/mobile/src/overlays/modal/ModalFooter.tsx b/packages/mobile/src/overlays/modal/ModalFooter.tsx index cc55261810..13c1f6ea05 100644 --- a/packages/mobile/src/overlays/modal/ModalFooter.tsx +++ b/packages/mobile/src/overlays/modal/ModalFooter.tsx @@ -1,28 +1,34 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, memo } from 'react'; import type { PressableProps } from 'react-native'; import { useModalContext } from '@coinbase/cds-common/overlays/ModalContext'; -import type { SharedProps } from '@coinbase/cds-common/types'; import type { ButtonBaseProps } from '../../buttons/Button'; import { ButtonGroup, type ButtonGroupProps } from '../../buttons/ButtonGroup'; -import { Box } from '../../layout/Box'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { Box, type BoxBaseProps, type BoxProps } from '../../layout/Box'; -export type ModalFooterProps = { - /** Primary action button */ - primaryAction: NonNullable< - React.ReactElement - >; - /** Secondary action button */ - secondaryAction?: React.ReactElement; -} & Pick & - SharedProps; +export type ModalFooterBaseProps = Omit & + Pick & { + /** Primary action button */ + primaryAction: NonNullable< + React.ReactElement + >; + /** Secondary action button */ + secondaryAction?: React.ReactElement; + }; -export const ModalFooter = ({ - primaryAction, - secondaryAction, - direction = 'horizontal', - testID, -}: ModalFooterProps) => { +export type ModalFooterProps = ModalFooterBaseProps & Omit; + +export const ModalFooter = memo((_props: ModalFooterProps) => { + const mergedProps = useComponentConfig('ModalFooter', _props); + const { + primaryAction, + secondaryAction, + direction = 'horizontal', + paddingX = 3, + paddingY = 2, + ...props + } = mergedProps; const { hideDividers = false } = useModalContext(); const actions = [secondaryAction, primaryAction].filter(Boolean); const isVertical = direction === 'vertical'; @@ -33,7 +39,7 @@ export const ModalFooter = ({ } return ( - + {actions.map((action, i) => ( // actions are stable so should be fine to use index as key @@ -43,4 +49,4 @@ export const ModalFooter = ({ ); -}; +}); diff --git a/packages/mobile/src/overlays/modal/ModalHeader.tsx b/packages/mobile/src/overlays/modal/ModalHeader.tsx index 051b09ea95..d013329d5b 100644 --- a/packages/mobile/src/overlays/modal/ModalHeader.tsx +++ b/packages/mobile/src/overlays/modal/ModalHeader.tsx @@ -1,17 +1,19 @@ import React from 'react'; import { type GestureResponderEvent } from 'react-native'; import { useModalContext } from '@coinbase/cds-common/overlays/ModalContext'; -import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common/types'; import { IconButton } from '../../buttons'; -import { Box, HStack } from '../../layout'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { Box, type BoxBaseProps } from '../../layout/Box'; +import { HStack, type HStackProps } from '../../layout/HStack'; import { Text } from '../../typography/Text'; -export type ModalHeaderBaseProps = SharedProps & { +export type ModalHeaderBaseProps = Omit & { /** Handles back button press */ onBackButtonClick?: (event: GestureResponderEvent) => void; /** Title of the Modal */ - title?: string; + title?: React.ReactNode; /** * Sets an accessible label for the back button. * On web, maps to `aria-label` and defines a string value that labels an interactive element. @@ -48,26 +50,36 @@ export type ModalHeaderBaseProps = SharedProps & { closeAccessibilityHint?: SharedAccessibilityProps['accessibilityHint']; }; -export type ModalHeaderProps = ModalHeaderBaseProps; +export type ModalHeaderProps = ModalHeaderBaseProps & Omit; -export const ModalHeader: React.FC> = ({ - title, - onBackButtonClick, - backAccessibilityLabel, - backAccessibilityHint, - closeAccessibilityLabel, - closeAccessibilityHint, - testID, -}) => { +export const ModalHeader: React.FC> = (_props) => { + const mergedProps = useComponentConfig('ModalHeader', _props); + const { + alignItems = 'center', + paddingX = 3, + paddingY = 2, + font = 'headline', + fontFamily, + fontSize, + fontWeight, + lineHeight, + title, + onBackButtonClick, + backAccessibilityLabel, + backAccessibilityHint, + closeAccessibilityLabel, + closeAccessibilityHint, + ...props + } = mergedProps; const { onRequestClose, hideCloseButton, hideDividers } = useModalContext(); return ( {!!onBackButtonClick && ( @@ -82,11 +94,21 @@ export const ModalHeader: React.FC> = )} - {title && ( - - {title} - - )} + {title && + (typeof title === 'string' ? ( + + {title} + + ) : ( + title + ))} {!hideCloseButton && ( diff --git a/packages/mobile/src/overlays/modal/__figma__/Modal.figma.tsx b/packages/mobile/src/overlays/modal/__figma__/Modal.figma.tsx index b4673c63cd..c7c943bd79 100644 --- a/packages/mobile/src/overlays/modal/__figma__/Modal.figma.tsx +++ b/packages/mobile/src/overlays/modal/__figma__/Modal.figma.tsx @@ -14,11 +14,11 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=68-1065&m=dev', { imports: [ - "import { Modal } from '@coinbase/cds-mobile/overlays/Modal/Modal';", - "import { ModalHeader } from '@coinbase/cds-mobile/overlays/Modal/ModalHeader';", - "import { ModalFooter } from '@coinbase/cds-mobile/overlays/Modal/ModalFooter';", - "import { ModalBody } from '@coinbase/cds-mobile/overlays/Modal/ModalBody';", - "import { useToggler } from '@coinbase/cds-common/hooks/useToggler';", + "import { Modal } from '@coinbase/cds-mobile/overlays/Modal/Modal'", + "import { ModalHeader } from '@coinbase/cds-mobile/overlays/Modal/ModalHeader'", + "import { ModalFooter } from '@coinbase/cds-mobile/overlays/Modal/ModalFooter'", + "import { ModalBody } from '@coinbase/cds-mobile/overlays/Modal/ModalBody'", + "import { useToggler } from '@coinbase/cds-common/hooks/useToggler'", ], props: { modalHeader: figma.nestedProps('.Modal Header', { diff --git a/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx b/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx index e96b19f6be..fe9c474d90 100644 --- a/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx +++ b/packages/mobile/src/overlays/modal/__tests__/Modal.test.tsx @@ -10,7 +10,7 @@ import { DefaultThemeProvider } from '../../../utils/testHelpers'; import { Modal } from '../Modal'; import { ModalBody } from '../ModalBody'; import { ModalFooter } from '../ModalFooter'; -import { ModalHeader } from '../ModalHeader'; +import { ModalHeader, type ModalHeaderProps } from '../ModalHeader'; type LoremIpsumProps = { title?: string; @@ -47,19 +47,11 @@ type ModalA11yProps = { accessibilityLabel?: string; }; -type ModalHeaderProps = { - title?: string; - backAccessibilityLabel?: string; - backAccessibilityHint?: string; - closeAccessibilityLabel?: string; - closeAccessibilityHint?: string; - onBackButtonClick?: () => void; -}; - const MockModal = ({ onRequestClose, onDidClose, onBackButtonClick, + font, title = 'Basic Modal', visible: externalVisible = false, testID, @@ -111,6 +103,7 @@ const MockModal = ({ backAccessibilityLabel={backAccessibilityLabel} closeAccessibilityHint={closeAccessibilityHint} closeAccessibilityLabel={closeAccessibilityLabel} + font={font} onBackButtonClick={onBackButtonClick} title={title} /> @@ -253,6 +246,31 @@ describe('Modal', () => { expect(await screen.findByText(title)).toBeTruthy(); }); + it('renders ReactNode title', async () => { + render( + + Custom Title} /> + , + ); + + fireEvent.press(screen.getByText('Open Modal')); + + expect(await screen.findByTestId('custom-title')).toBeTruthy(); + expect(screen.getByText('Custom Title')).toBeTruthy(); + }); + + it('applies custom font prop to title text', async () => { + render( + + + , + ); + + fireEvent.press(screen.getByText('Open Modal')); + + expect(await screen.findByText('Styled Title')).toBeTruthy(); + }); + it('renders modal body', async () => { render( diff --git a/packages/mobile/src/overlays/overlay/Overlay.tsx b/packages/mobile/src/overlays/overlay/Overlay.tsx index b093c1ab1d..da7f25167d 100644 --- a/packages/mobile/src/overlays/overlay/Overlay.tsx +++ b/packages/mobile/src/overlays/overlay/Overlay.tsx @@ -5,7 +5,9 @@ import { type OverlayContentContextValue, } from '@coinbase/cds-common/overlays/OverlayContentContext'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { useTheme } from '../../hooks/useTheme'; +import type { BoxBaseProps } from '../../layout'; import type { VStackProps } from '../../layout/VStack'; import { VStack } from '../../layout/VStack'; @@ -13,12 +15,16 @@ const overlayContentContextValue: OverlayContentContextValue = { isOverlay: true, }; -export type OverlayProps = { +export type OverlayBaseProps = Omit & { /** Opacity of overlay. Pass in the animated value from useOverlayAnimation to use CDS approved animation curves and timings. */ opacity: Animated.Value; -} & Omit; +}; + +export type OverlayProps = OverlayBaseProps & Omit; -export const Overlay = memo(function Overlay({ opacity, ...props }: OverlayProps) { +export const Overlay = memo((_props: OverlayProps) => { + const mergedProps = useComponentConfig('Overlay', _props); + const { opacity, ...props } = mergedProps; const theme = useTheme(); return ( diff --git a/packages/mobile/src/overlays/tooltip/Tooltip.tsx b/packages/mobile/src/overlays/tooltip/Tooltip.tsx index 180a640597..d42e52a470 100644 --- a/packages/mobile/src/overlays/tooltip/Tooltip.tsx +++ b/packages/mobile/src/overlays/tooltip/Tooltip.tsx @@ -1,14 +1,25 @@ -import React, { Fragment, memo, useCallback, useMemo, useRef, useState } from 'react'; -import { Modal as RNModal, TouchableOpacity, View } from 'react-native'; +import React, { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { type AccessibilityState, Modal as RNModal, TouchableOpacity, View } from 'react-native'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { InvertedThemeProvider } from '../../system/ThemeProvider'; import { InternalTooltip } from './InternalTooltip'; -import type { SubjectLayout, TooltipProps } from './TooltipProps'; +import type { SubjectLayout, TooltipBaseProps } from './TooltipProps'; import { useTooltipAnimation } from './useTooltipAnimation'; -export const Tooltip = memo( - ({ +export type { TooltipBaseProps }; + +export type TooltipProps = TooltipBaseProps & { + /** + * Accessibility state for the trigger. + */ + accessibilityState?: AccessibilityState; +}; + +export const Tooltip = memo((_props: TooltipProps) => { + const mergedProps = useComponentConfig('Tooltip', _props); + const { children, content, placement = 'top', @@ -21,113 +32,163 @@ export const Tooltip = memo( accessibilityHint, accessibilityLabelForContent, accessibilityHintForContent, + accessibilityState, visible, invertColorScheme = true, elevation, - }: TooltipProps) => { - const subjectRef = useRef(null); - const [isOpen, setIsOpen] = useState(false); - const isVisible = visible !== false && isOpen; - const [subjectLayout, setSubjectLayout] = useState(); + openDelay, + closeDelay, + } = mergedProps; + const subjectRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const isVisible = visible !== false && isOpen; + const [subjectLayout, setSubjectLayout] = useState(); + const openTimeoutRef = useRef | null>(null); + const closeTimeoutRef = useRef | null>(null); + + const WrapperComponent = invertColorScheme ? InvertedThemeProvider : Fragment; + + const { opacity, translateY, animateIn, animateOut } = useTooltipAnimation(placement); - const WrapperComponent = invertColorScheme ? InvertedThemeProvider : Fragment; + const clearOpenTimeout = useCallback(() => { + if (openTimeoutRef.current) { + clearTimeout(openTimeoutRef.current); + openTimeoutRef.current = null; + } + }, []); - const { opacity, translateY, animateIn, animateOut } = useTooltipAnimation(placement); + const clearCloseTimeout = useCallback(() => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + closeTimeoutRef.current = null; + } + }, []); - const handleRequestClose = useCallback(() => { + const handleRequestClose = useCallback(() => { + clearOpenTimeout(); + clearCloseTimeout(); + + const closeTooltip = () => { animateOut.start(() => { setIsOpen(false); onCloseTooltip?.(); }); - }, [animateOut, onCloseTooltip]); - - const handlePressSubject = useCallback(() => { - subjectRef.current?.measure((x, y, width, height, pageOffsetX, pageOffsetY) => { - setSubjectLayout({ - width, - height, - pageOffsetX, - pageOffsetY, - }); + }; + + if (closeDelay && closeDelay > 0) { + closeTimeoutRef.current = setTimeout(closeTooltip, closeDelay); + } else { + closeTooltip(); + } + }, [animateOut, clearCloseTimeout, clearOpenTimeout, closeDelay, onCloseTooltip]); + + const handlePressSubject = useCallback(() => { + clearCloseTimeout(); + subjectRef.current?.measure((x, y, width, height, pageOffsetX, pageOffsetY) => { + setSubjectLayout({ + width, + height, + pageOffsetX, + pageOffsetY, }); + }); + const openTooltip = () => { setIsOpen(true); onOpenTooltip?.(); - }, [onOpenTooltip]); - - // The accessibility props for the trigger component. Trigger component - // equals the component where when you click on it, it will show the tooltip - const accessibilityPropsForTrigger = useMemo( - () => ({ - accessibilityLabel: - typeof children === 'string' && accessibilityLabel === undefined - ? children - : accessibilityLabel, - accessibilityHint: - typeof children === 'string' && accessibilityHint === undefined - ? children - : accessibilityHint, - }), - [children, accessibilityLabel, accessibilityHint], - ); - - const accessibilityPropsForContent = useMemo( - () => ({ - accessibilityLabel: - typeof content === 'string' && accessibilityLabelForContent === undefined - ? content - : accessibilityLabelForContent, - accessibilityHint: - typeof content === 'string' && accessibilityHintForContent === undefined - ? content - : accessibilityHintForContent, - onAccessibilityEscape: handleRequestClose, - onAccessibilityTap: handleRequestClose, - }), - [content, accessibilityLabelForContent, accessibilityHintForContent, handleRequestClose], - ); - - return ( - + }; + + clearOpenTimeout(); + if (openDelay && openDelay > 0) { + openTimeoutRef.current = setTimeout(openTooltip, openDelay); + } else { + openTooltip(); + } + }, [clearCloseTimeout, clearOpenTimeout, onOpenTooltip, openDelay]); + + // The accessibility props for the trigger component. Trigger component + // equals the component where when you click on it, it will show the tooltip + const accessibilityPropsForTrigger = useMemo( + () => ({ + accessibilityLabel: + typeof children === 'string' && accessibilityLabel === undefined + ? children + : accessibilityLabel, + accessibilityHint: + typeof children === 'string' && accessibilityHint === undefined + ? children + : accessibilityHint, + // accessibilityState is applied to the trigger regardless of screen reader usage. + // Only set it when you need screen reader behavior. + // e.g. disabled = true: state is announced and the trigger cannot activate + accessibilityState, + }), + [children, accessibilityLabel, accessibilityHint, accessibilityState], + ); + + const accessibilityPropsForContent = useMemo( + () => ({ + accessibilityLabel: + typeof content === 'string' && accessibilityLabelForContent === undefined + ? content + : accessibilityLabelForContent, + accessibilityHint: + typeof content === 'string' && accessibilityHintForContent === undefined + ? content + : accessibilityHintForContent, + onAccessibilityEscape: handleRequestClose, + onAccessibilityTap: handleRequestClose, + }), + [content, accessibilityLabelForContent, accessibilityHintForContent, handleRequestClose], + ); + + useEffect(() => { + return () => { + clearOpenTimeout(); + clearCloseTimeout(); + }; + }, [clearCloseTimeout, clearOpenTimeout]); + + return ( + + + {children} + + + - {children} - - - - + + - - - - - - ); - }, -); + + + + ); +}); diff --git a/packages/mobile/src/overlays/tooltip/TooltipProps.ts b/packages/mobile/src/overlays/tooltip/TooltipProps.ts index e70ea469da..cf73412ff9 100644 --- a/packages/mobile/src/overlays/tooltip/TooltipProps.ts +++ b/packages/mobile/src/overlays/tooltip/TooltipProps.ts @@ -27,6 +27,14 @@ export type TooltipBaseProps = SharedProps & * @default true */ visible?: boolean; + /** + * Delay (in ms) before showing the tooltip after press. + */ + openDelay?: number; + /** + * Delay (in ms) before hiding the tooltip after dismiss. + */ + closeDelay?: number; /** Invert the theme's activeColorScheme for this component * @default true */ diff --git a/packages/mobile/src/overlays/tooltip/__figma__/Tooltip.figma.tsx b/packages/mobile/src/overlays/tooltip/__figma__/Tooltip.figma.tsx index 8dee658bce..d33dcb570c 100644 --- a/packages/mobile/src/overlays/tooltip/__figma__/Tooltip.figma.tsx +++ b/packages/mobile/src/overlays/tooltip/__figma__/Tooltip.figma.tsx @@ -10,8 +10,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=715%3A14162', { imports: [ - "import { Tooltip } from '@coinbase/cds-mobile/overlays';", - "import { Button } from '@coinbase/cds-mobile/buttons/Button';", + "import { Tooltip } from '@coinbase/cds-mobile/overlays'", + "import { Button } from '@coinbase/cds-mobile/buttons/Button'", ], variant: { type: 'body' }, props: { @@ -43,8 +43,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=715%3A14162', { imports: [ - "import { Tooltip } from '@coinbase/cds-mobile/overlays';", - "import { Button } from '@coinbase/cds-mobile/buttons/Button';", + "import { Tooltip } from '@coinbase/cds-mobile/overlays'", + "import { Button } from '@coinbase/cds-mobile/buttons/Button'", ], variant: { type: 'title + body' }, props: { diff --git a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx index 00e8492be3..e599017ba9 100644 --- a/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx +++ b/packages/mobile/src/overlays/tooltip/__tests__/Tooltip.test.tsx @@ -1,5 +1,5 @@ import { renderHook } from '@testing-library/react-hooks'; -import { fireEvent, render, screen } from '@testing-library/react-native'; +import { act, fireEvent, render, screen } from '@testing-library/react-native'; import { Button } from '../../../buttons'; import { useDimensions } from '../../../hooks/useDimensions'; @@ -100,4 +100,31 @@ describe('Tooltip', () => { expect(await screen.findByText(contentText)).toBeTruthy(); expect(onOpenTooltip).toHaveBeenCalled(); }); + + it('respects openDelay before showing tooltip content', async () => { + jest.useFakeTimers(); + render( + + + , + ); + + fireEvent.press(screen.getByAccessibilityHint('delay-hint')); + + expect(screen.queryByText(contentText)).toBeNull(); + + act(() => { + jest.advanceTimersByTime(200); + }); + + expect(screen.queryByText(contentText)).toBeNull(); + + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(await screen.findByText(contentText)).toBeTruthy(); + + jest.useRealTimers(); + }); }); diff --git a/packages/mobile/src/overlays/tray/Tray.tsx b/packages/mobile/src/overlays/tray/Tray.tsx index 56b8afc085..5031868dcc 100644 --- a/packages/mobile/src/overlays/tray/Tray.tsx +++ b/packages/mobile/src/overlays/tray/Tray.tsx @@ -10,22 +10,35 @@ import React, { } from 'react'; import { useWindowDimensions } from 'react-native'; import type { ReactNode } from 'react'; -import type { LayoutChangeEvent } from 'react-native'; +import type { LayoutChangeEvent, StyleProp, TextStyle, ViewStyle } from 'react-native'; +import type { ElevationLevels } from '@coinbase/cds-common'; import { MAX_OVER_DRAG } from '@coinbase/cds-common/animation/drawer'; import { verticalDrawerPercentageOfView as defaultVerticalDrawerPercentageOfView } from '@coinbase/cds-common/tokens/drawer'; -import { Box, HStack, VStack } from '../../layout'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { Box, VStack } from '../../layout'; import { Text } from '../../typography/Text'; -import { Drawer, type DrawerBaseProps, type DrawerRefBaseProps } from '../drawer/Drawer'; +import { + Drawer, + type DrawerBaseProps, + type DrawerProps, + type DrawerRefBaseProps, +} from '../drawer/Drawer'; export type TrayRenderChildren = React.FC<{ handleClose: () => void }>; export type TrayBaseProps = Omit & { - children: React.ReactNode | TrayRenderChildren; - /** ReactNode to render as the Tray header */ - header?: React.ReactNode; - /** ReactNode to render as the Tray footer */ - footer?: React.ReactNode; + /** Component to render as the Tray content */ + children?: React.ReactNode | TrayRenderChildren; + /** Component to render as the Tray header */ + header?: React.ReactNode | TrayRenderChildren; + /** + * Elevation level for the header area (includes title and header content). + * Use this to add a drop shadow below the header when content is scrolled. + */ + headerElevation?: ElevationLevels; + /** Component to render as the Tray footer */ + footer?: React.ReactNode | TrayRenderChildren; /** * Optional callback that, if provided, will be triggered when the Tray is toggled open/ closed * If used for analytics, context ('visible' | 'hidden') can be bundled with the event info to track whether the @@ -36,7 +49,18 @@ export type TrayBaseProps = Omit & { title?: React.ReactNode; }; -export type TrayProps = TrayBaseProps; +export type TrayProps = TrayBaseProps & + Omit & { + pin?: DrawerProps['pin']; + styles?: DrawerProps['styles'] & { + /** Content area element */ + content?: StyleProp; + /** Header section element */ + header?: StyleProp; + /** Title text element */ + title?: StyleProp; + }; + }; export const TrayContext = createContext<{ verticalDrawerPercentageOfView: number; @@ -47,19 +71,33 @@ export const TrayContext = createContext<{ }); export const Tray = memo( - forwardRef(function Tray( - { + forwardRef(function Tray(_props, ref) { + const mergedProps = useComponentConfig('Tray', _props); + const { children, + title, header, + headerElevation, footer, - title, onVisibilityChange, + handleBarVariant = 'outside', verticalDrawerPercentageOfView = defaultVerticalDrawerPercentageOfView, + styles, ...props - }, - ref, - ) { + } = mergedProps; const [titleHeight, setTitleHeight] = useState(0); + const isInsideHandleBar = handleBarVariant === 'inside'; + const isTitleString = typeof title === 'string'; + + const { contentStyle, headerStyle, titleStyle, drawerStyles } = useMemo(() => { + const { + content: contentStyle, + header: headerStyle, + title: titleStyle, + ...drawerStyles + } = styles ?? {}; + return { contentStyle, headerStyle, titleStyle, drawerStyles }; + }, [styles]); const onTitleLayout = useCallback( (event: LayoutChangeEvent) => { @@ -72,32 +110,60 @@ export const Tray = memo( const renderChildren: TrayRenderChildren = useCallback( ({ handleClose }) => { const content = typeof children === 'function' ? children({ handleClose }) : children; + const headerContent = typeof header === 'function' ? header({ handleClose }) : header; + const footerContent = typeof footer === 'function' ? footer({ handleClose }) : footer; return ( - - {title && - (typeof title === 'string' ? ( - - {title} - - ) : ( - {title} - ))} - {header} + + {(title || headerContent) && ( + + {title && ( + + {isTitleString ? ( + + {title} + + ) : ( + title + )} + + )} + {headerContent} + + )} {content} - {footer} + {footerContent} ); }, - [children, footer, header, onTitleLayout, title], + [ + title, + isTitleString, + contentStyle, + onTitleLayout, + isInsideHandleBar, + headerElevation, + headerStyle, + titleStyle, + header, + children, + footer, + ], ); useEffect(() => { @@ -115,10 +181,11 @@ export const Tray = memo( return ( {renderChildren} @@ -127,6 +194,10 @@ export const Tray = memo( }), ); +/** + * @deprecated Redundant component. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ export const TrayStickyFooter = ({ children }: { children: ReactNode }) => { const { verticalDrawerPercentageOfView, titleHeight } = useContext(TrayContext); const { height } = useWindowDimensions(); diff --git a/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx b/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx index 9f476cf83e..3406b6f83d 100644 --- a/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx +++ b/packages/mobile/src/overlays/tray/__figma__/Tray.figma.tsx @@ -1,46 +1,47 @@ -import { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { figma } from '@figma/code-connect'; import { Button } from '../../../buttons/Button'; import { Box, VStack } from '../../../layout'; -import { TextBody, TextTitle1 } from '../../../typography'; -import { Tray, TrayStickyFooter } from '../Tray'; +import { StickyFooter } from '../../../sticky-footer/StickyFooter'; +import { Text } from '../../../typography/Text'; +import { Tray } from '../Tray'; figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33327&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray';"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { title: figma.boolean('show section header', { true: figma.textContent('SectionHeader'), false: undefined, }), - stickyFooter: figma.children('StickyFooter'), content: figma.children('.Select Option*'), }, - example: function TrayExample({ stickyFooter, content, title }) { + example: function TrayExample({ content, title }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} title={title} > - {({ handleClose }) => ( - - {content} - {stickyFooter} - - )} + {content} )} @@ -53,43 +54,37 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33472&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray';"], + imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'"], props: { pictogram: figma.boolean('show pictogram', { true: figma.children('Spot Square/blockchain'), false: undefined, }), - title: figma.textContent('SectionHeader'), - stickyFooter: figma.children('StickyFooter'), + sectionTitle: figma.textContent('SectionHeader'), }, - example: function TrayExample({ pictogram, title, stickyFooter }) { + example: function TrayExample({ pictogram, sectionTitle }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( setIsTrayVisible(false)} - onVisibilityChange={() => {}} - title={title} - > - {({ handleClose }) => ( - + title={ + {pictogram} - - Lorem ipsum dolor sit amet consectetur. Lacus vitae vulputate maecenas sed ac - cursus enim elementum euismod. Ac vulputate gravida mauris id nulla imperdiet - eget. Dictum vitae enim eget ut. Maecenas hendrerit amet integer sagittis cras. - Fermentum ultricies malesuada interdum - - {stickyFooter} - - )} + {sectionTitle} + + } + > + + + Content goes here. + + )} @@ -102,44 +97,45 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33505&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray';"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { spotRectangle: figma.instance('spot rectangle'), title: figma.string('title'), body: figma.string('body'), - stickyFooter: figma.children('StickyFooter'), }, - example: function TrayExample({ spotRectangle, title, body, stickyFooter }) { + example: function TrayExample({ spotRectangle, title, body }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} > - {({ handleClose }) => ( - - - - {spotRectangle} - - - {title} - - - {body} - - - {stickyFooter} - - )} + + + {spotRectangle} + + + {title} + + + {body} + + )} @@ -152,26 +148,23 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-33538&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray';"], + imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'"], props: { children: figma.children('*'), }, example: function TrayExample({ children }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( setIsTrayVisible(false)} - onVisibilityChange={() => {}} + title="Title" > - {({ handleClose }) => {children}} + {children} )} @@ -184,37 +177,37 @@ figma.connect( Tray, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=14729-77780&m=dev', { - imports: ["import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray';"], + imports: [ + "import { Tray } from '@coinbase/cds-mobile/overlays/tray/Tray'", + "import { StickyFooter } from '@coinbase/cds-mobile/sticky-footer/StickyFooter'", + ], props: { content: figma.instance('content'), - stickyFooter: figma.children('StickyFooter'), title: figma.boolean('show section header', { true: figma.textContent('SectionHeader'), false: undefined, }), }, - example: function TrayExample({ content, stickyFooter, title }) { + example: function TrayExample({ content, title }) { const [isTrayVisible, setIsTrayVisible] = useState(false); - const trayRef = useRef(null); return ( <> - + {isTrayVisible && ( ( + + + + )} + handleBarVariant="inside" onCloseComplete={() => setIsTrayVisible(false)} - onVisibilityChange={() => {}} title={title} > - {({ handleClose }) => ( - - {content} - {stickyFooter} - - )} + {content} )} diff --git a/packages/mobile/src/overlays/tray/__tests__/Tray.test.tsx b/packages/mobile/src/overlays/tray/__tests__/Tray.test.tsx index fd74353556..28ed01e3d3 100644 --- a/packages/mobile/src/overlays/tray/__tests__/Tray.test.tsx +++ b/packages/mobile/src/overlays/tray/__tests__/Tray.test.tsx @@ -91,6 +91,145 @@ describe('Tray', () => { expect(onVisibilityChangeSpy).toHaveBeenCalledWith('hidden'); }); + describe('header and footer', () => { + it('renders a custom header', () => { + const onCloseCompleteSpy = jest.fn(); + const customHeader = ( + + Custom Header Content + + ); + render( + + + + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('test-header')).toBeTruthy(); + }); + + it('renders a custom footer', () => { + const onCloseCompleteSpy = jest.fn(); + const customFooter = ( + + Custom Footer Content + + ); + render( + + + + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('test-footer')).toBeTruthy(); + }); + + it('renders header as render function', () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + ( + + Header from render function + + )} + onCloseComplete={onCloseCompleteSpy} + > + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('header-render-fn')).toBeTruthy(); + }); + + it('renders footer as render function', () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + ( + + Footer from render function + + )} + onCloseComplete={onCloseCompleteSpy} + > + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('footer-render-fn')).toBeTruthy(); + }); + + it('renders children as render function', () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + + {({ handleClose: _handleClose }) => ( + + Children from render function + + )} + + + , + ); + + expect(screen.getByTestId('children-render-fn')).toBeTruthy(); + }); + }); + + describe('handleBarVariant', () => { + it('renders with inside handleBarVariant', () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('handleBar')).toBeTruthy(); + expect(screen.getByText(titleText)).toBeTruthy(); + }); + + it('renders with outside handleBarVariant', () => { + const onCloseCompleteSpy = jest.fn(); + render( + + + + {loremIpsum} + + + , + ); + + expect(screen.getByTestId('handleBar')).toBeTruthy(); + expect(screen.getByText(titleText)).toBeTruthy(); + }); + }); + it('renders correctly and provides the correct context value', () => { const verticalDrawerPercentageOfView = 0.75; const titleHeight = 0; diff --git a/packages/mobile/src/overlays/useModal.ts b/packages/mobile/src/overlays/useModal.ts index 1cd2dda6df..c9dd8af227 100644 --- a/packages/mobile/src/overlays/useModal.ts +++ b/packages/mobile/src/overlays/useModal.ts @@ -1,6 +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 + * @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..488cc18aa1 100644 --- a/packages/mobile/src/page/PageFooter.tsx +++ b/packages/mobile/src/page/PageFooter.tsx @@ -4,6 +4,7 @@ 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 { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, type BoxProps } from '../layout/Box'; export type PageFooterBaseProps = SharedProps & @@ -20,10 +21,9 @@ export type PageFooterBaseProps = SharedProps & export type PageFooterProps = PageFooterBaseProps & BoxProps; export const PageFooter = memo( - forwardRef(function PageFooter( - { action, ...props }: PageFooterProps, - ref: React.ForwardedRef, - ) { + forwardRef((_props: PageFooterProps, ref: React.ForwardedRef) => { + const mergedProps = useComponentConfig('PageFooter', _props); + const { action, ...props } = mergedProps; return ( ; - /** - * Custom styles for the start element. - */ + /** Start element */ start?: StyleProp; - /** - * Custom styles for the end element. - */ + /** End element */ end?: StyleProp; - /** - * Custom styles for the title element. - */ + /** Title element */ title?: StyleProp; }; }; export const PageHeader = memo( - forwardRef(function PageHeader( - { start, title, end, styles, style, ...props }: PageHeaderProps, - ref: React.ForwardedRef, - ) { + forwardRef((_props: PageHeaderProps, ref: React.ForwardedRef) => { + const mergedProps = useComponentConfig('PageHeader', _props); + const { start, title, end, styles, style, ...props } = mergedProps; const isMultiRow = useMemo(() => Boolean(start && title && end), [start, end, title]); return ( diff --git a/packages/mobile/src/page/__figma__/PageFooter.figma.tsx b/packages/mobile/src/page/__figma__/PageFooter.figma.tsx index 6593ab2218..18bab9cd8b 100644 --- a/packages/mobile/src/page/__figma__/PageFooter.figma.tsx +++ b/packages/mobile/src/page/__figma__/PageFooter.figma.tsx @@ -7,7 +7,7 @@ figma.connect( PageFooter, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=17685%3A3266', { - imports: ["import { PageFooter } from '@coinbase/cds-mobile/page/PageFooter';"], + imports: ["import { PageFooter } from '@coinbase/cds-mobile/page/PageFooter'"], props: { action: figma.children('ButtonGroup'), }, diff --git a/packages/mobile/src/page/__figma__/PageHeader.figma.tsx b/packages/mobile/src/page/__figma__/PageHeader.figma.tsx index 6325bb488b..58efd590d9 100644 --- a/packages/mobile/src/page/__figma__/PageHeader.figma.tsx +++ b/packages/mobile/src/page/__figma__/PageHeader.figma.tsx @@ -10,8 +10,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=17685%3A3171', { imports: [ - "import { PageHeader } from '@coinbase/cds-mobile/page/PageHeader';", - "import { HStack } from '@coinbase/cds-mobile/layout/HStack';", + "import { PageHeader } from '@coinbase/cds-mobile/page/PageHeader'", + "import { HStack } from '@coinbase/cds-mobile/layout/HStack'", ], props: { start: figma.boolean('show start', { diff --git a/packages/mobile/src/perf/component-config/Button.component-config.perf-test.tsx b/packages/mobile/src/perf/component-config/Button.component-config.perf-test.tsx new file mode 100644 index 0000000000..1c2e00ae5d --- /dev/null +++ b/packages/mobile/src/perf/component-config/Button.component-config.perf-test.tsx @@ -0,0 +1,39 @@ +import { measurePerformance } from 'reassure'; + +import { Button } from '../../buttons/Button'; +import { ComponentConfigProvider } from '../../system/ComponentConfigProvider'; +import { DefaultThemeProvider } from '../../utils/testHelpers'; + +const buttonCount = 1000; + +const ButtonList = () => { + return ( + <> + {Array.from({ length: buttonCount }, (_, index) => ( + + ))} + + ); +}; + +describe('Button component-config performance (mobile)', () => { + jest.setTimeout(20000); + + it('no provider', async () => { + await measurePerformance( + + + , + ); + }); + + it('provider customization', async () => { + await measurePerformance( + + + + + , + ); + }); +}); diff --git a/packages/mobile/src/perf/component-config/ComponentConfigProvider.perf-test.tsx b/packages/mobile/src/perf/component-config/ComponentConfigProvider.perf-test.tsx new file mode 100644 index 0000000000..f2f807d0e3 --- /dev/null +++ b/packages/mobile/src/perf/component-config/ComponentConfigProvider.perf-test.tsx @@ -0,0 +1,141 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, Text } from 'react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { measurePerformance } from 'reassure'; + +import type { ComponentConfig } from '../../core/componentConfig'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { ComponentConfigProvider } from '../../system/ComponentConfigProvider'; + +const consumerCount = 1000; +const updateIterations = 50; +const testTimeoutMs = 20000; + +const stableButtonConfig: NonNullable = () => ({ + compact: true, +}); + +const stableAvatarConfig: NonNullable = () => ({}); + +type ConsumerProps = { + index: number; +}; + +const ButtonConfigConsumer = ({ index }: ConsumerProps) => { + const mergedProps = useComponentConfig('Button', { + compact: false, + variant: 'primary', + }); + + return {mergedProps.compact ? 'compact' : 'default'}; +}; + +const ButtonConfigConsumerList = ({ count }: { count: number }) => { + return ( + <> + {Array.from({ length: count }, (_, index) => ( + + ))} + + ); +}; + +const UnrelatedKeyUpdateHarness = ({ count }: { count: number }) => { + const [unrelatedUpdates, setUnrelatedUpdates] = useState(0); + + const value: ComponentConfig = useMemo( + () => ({ + Avatar: () => (unrelatedUpdates % 2 === 0 ? {} : {}), + Button: stableButtonConfig, + }), + [unrelatedUpdates], + ); + + return ( + <> + setUnrelatedUpdates((v) => v + 1)} testID="update-unrelated-key"> + Update unrelated key + + + + + + ); +}; + +const TargetKeyUpdateHarness = ({ count }: { count: number }) => { + const [targetUpdates, setTargetUpdates] = useState(0); + + const value: ComponentConfig = useMemo( + () => ({ + Avatar: stableAvatarConfig, + Button: () => ({ + compact: targetUpdates % 2 === 0, + }), + }), + [targetUpdates], + ); + + return ( + <> + setTargetUpdates((v) => v + 1)} testID="update-target-key"> + Update target key + + + + + + ); +}; + +describe('ComponentConfigProvider performance tests (mobile)', () => { + jest.setTimeout(testTimeoutMs); + + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('Scenario A: renders 1000 consumers under one provider', async () => { + await measurePerformance( + + + , + ); + }); + + it('Scenario B: updates unrelated component key 50 times', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-unrelated-key')); + } + }; + + await measurePerformance(, { scenario }); + }); + + it('Scenario C: updates target component key 50 times', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-target-key')); + } + }; + + await measurePerformance(, { scenario }); + }); + + it('Scenario D (baseline): no provider with 1000 consumers', async () => { + await measurePerformance(); + }); + + it('Scenario D (provider): provider enabled with 1000 consumers', async () => { + await measurePerformance( + + + , + ); + }); +}); diff --git a/packages/mobile/src/perf/component-config/ComponentConfigStickerSheet.perf-test.tsx b/packages/mobile/src/perf/component-config/ComponentConfigStickerSheet.perf-test.tsx new file mode 100644 index 0000000000..39a5c49826 --- /dev/null +++ b/packages/mobile/src/perf/component-config/ComponentConfigStickerSheet.perf-test.tsx @@ -0,0 +1,314 @@ +import React from 'react'; +import { Pressable } from 'react-native'; +import { fireEvent, screen } from '@testing-library/react-native'; +import { measurePerformance } from 'reassure'; + +import { Button } from '../../buttons/Button'; +import { IconButton } from '../../buttons/IconButton'; +import { ListCell } from '../../cells/ListCell'; +import { Chip } from '../../chips/Chip'; +import { SearchInput } from '../../controls/SearchInput'; +import { TextInput } from '../../controls/TextInput'; +import type { ComponentConfig } from '../../core/componentConfig'; +import type { ThemeConfig } from '../../core/theme'; +import { DotCount } from '../../dots/DotCount'; +import { Icon } from '../../icons/Icon'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { Avatar } from '../../media/Avatar'; +import { ComponentConfigProvider } from '../../system/ComponentConfigProvider'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { Tag } from '../../tag/Tag'; +import { defaultTheme } from '../../themes/defaultTheme'; +import { Text } from '../../typography/Text'; + +const updateIterations = 50; + +const customPerfTheme: ThemeConfig = { + ...defaultTheme, + id: 'component-config-mobile-perf-theme', + lightColor: { + ...defaultTheme.lightColor, + bgAlternate: defaultTheme.lightColor.bgSecondary, + }, + darkColor: { + ...defaultTheme.darkColor, + bgAlternate: defaultTheme.darkColor.bgSecondary, + }, +}; + +const customComponentConfig: ComponentConfig = { + Button: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + font: props.compact ? 'label1' : 'headline', + }), + IconButton: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + width: props.compact ? 24 : 32, + }), + TextInput: (props) => ({ + bordered: false, + inputBackground: 'bgAlternate', + font: props.compact ? 'label2' : 'body', + variant: 'foregroundMuted', + focusedBorderWidth: 100, + }), + SearchInput: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + }), + Chip: { + borderRadius: 200, + }, + ListCell: { + spacingVariant: 'condensed', + }, +}; + +const ComplexStickerSheetLike = ({ tick = 0 }: { tick?: number }) => ( + + + + + {Array.from({ length: 12 }, (_, i) => ( + + ))} + + + {Array.from({ length: 12 }, (_, i) => ( + + ))} + + + {Array.from({ length: 8 }, (_, i) => ( + {}} value="" /> + ))} + + + {Array.from({ length: 8 }, (_, i) => ( + {}} + value="" + /> + ))} + + + {Array.from({ length: 8 }, (_, i) => ( + } + onPress={() => {}} + subtitle="Subtitle" + title={`Row ${i}`} + /> + ))} + + + + + {Array.from({ length: 16 }, (_, i) => ( + + ))} + + + {Array.from({ length: 24 }, (_, i) => ( + {}}> + Chip {i} + + ))} + + + {Array.from({ length: 20 }, (_, i) => ( + + Tag {i} + + ))} + + + {Array.from({ length: 10 }, (_, i) => ( + + + + ))} + + Complex story-like surface tick={tick} + + + +); + +const BaselineHarness = () => ( + + + +); + +const CustomHarness = () => ( + + + + + +); + +const UnrelatedConfigUpdateHarness = () => { + const [tick, setTick] = React.useState(0); + + const value = React.useMemo( + () => ({ + ...customComponentConfig, + Tour: tick % 2 === 0 ? {} : {}, + }), + [tick], + ); + + return ( + <> + setTick((v) => v + 1)} testID="update-unrelated-config" /> + + + + + + + ); +}; + +const TargetedConfigUpdateHarness = () => { + const [tick, setTick] = React.useState(0); + + const value = React.useMemo( + () => ({ + ...customComponentConfig, + Button: (props) => ({ + borderRadius: tick % 2 === 0 ? 200 : 300, + height: props.compact ? 24 : 32, + font: props.compact ? 'label1' : 'headline', + }), + }), + [tick], + ); + + return ( + <> + setTick((v) => v + 1)} testID="update-targeted-config" /> + + + + + + + ); +}; + +const RandomStateUpdateHarness = () => { + const [tick, setTick] = React.useState(0); + + return ( + <> + setTick((v) => v + 1)} testID="update-random-state" /> + + + + + + + ); +}; + +const CustomThemeNoProviderHarness = () => ( + + + +); + +const CustomThemeNoProviderStateUpdateHarness = () => { + const [tick, setTick] = React.useState(0); + return ( + <> + setTick((v) => v + 1)} testID="update-page-state-no-provider" /> + + + + + ); +}; + +describe('ComponentConfig StickerSheet performance tests (mobile)', () => { + jest.setTimeout(90000); + + beforeAll(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it('renders StickerSheet baseline (no provider)', async () => { + await measurePerformance(); + }); + + it('renders StickerSheet custom story (theme + component config)', async () => { + await measurePerformance(); + }); + + it('updates unrelated config key 50 times', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-unrelated-config')); + } + }; + + await measurePerformance(, { scenario }); + }); + + it('updates targeted config key 50 times', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-targeted-config')); + } + }; + + await measurePerformance(, { scenario }); + }); + + it('updates random local state 50 times (provider enabled)', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-random-state')); + } + }; + + await measurePerformance(, { scenario }); + }); + + it('renders custom theme with no provider', async () => { + await measurePerformance(); + }); + + it('updates page state 50 times with custom theme and no provider', async () => { + const scenario = async () => { + for (let i = 0; i < updateIterations; i += 1) { + fireEvent.press(screen.getByTestId('update-page-state-no-provider')); + } + }; + + await measurePerformance(, { scenario }); + }); +}); diff --git a/packages/mobile/src/perf/component-config/README.md b/packages/mobile/src/perf/component-config/README.md new file mode 100644 index 0000000000..c2f2256f27 --- /dev/null +++ b/packages/mobile/src/perf/component-config/README.md @@ -0,0 +1,8 @@ +# Component Config Perf Tests + +This folder contains manual performance benchmarks for component config behavior. + +## Run + +- Web + mobile together: + - `yarn perf:component-config` diff --git a/packages/mobile/src/section-header/__figma__/SectionHeader.figma.tsx b/packages/mobile/src/section-header/__figma__/SectionHeader.figma.tsx index fe188cad33..d89a27bdcb 100644 --- a/packages/mobile/src/section-header/__figma__/SectionHeader.figma.tsx +++ b/packages/mobile/src/section-header/__figma__/SectionHeader.figma.tsx @@ -8,7 +8,7 @@ figma.connect( SectionHeader, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=19270%3A19118', { - imports: ["import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader';"], + imports: ["import { SectionHeader } from '@coinbase/cds-mobile/section-header/SectionHeader'"], props: { title: figma.children('string.section title'), balance: figma.enum('type', { diff --git a/packages/mobile/src/stepper/Stepper.tsx b/packages/mobile/src/stepper/Stepper.tsx index e780f1fdf2..4859003131 100644 --- a/packages/mobile/src/stepper/Stepper.tsx +++ b/packages/mobile/src/stepper/Stepper.tsx @@ -11,6 +11,7 @@ import { useSprings, } from '@react-spring/native'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import type { IconProps } from '../icons/Icon'; import { Box, type BoxBaseProps, type BoxProps } from '../layout/Box'; import { VStack } from '../layout/VStack'; @@ -65,7 +66,7 @@ type StepperSubcomponentProps = Record< complete?: boolean; /** Whether the active step is a descendent of this step */ isDescendentActive: boolean; - /** Inline styles for this component */ + /** Inline styles for the subcomponent element */ style?: StyleProp; }; @@ -228,21 +229,21 @@ export type StepperBaseProps = Record = Record> = BoxProps & StepperBaseProps & { - /** Inline styles for specific child elements of Stepper */ + /** Custom styles for individual elements of the Stepper component */ styles?: { - /** Inline styles for the root Stepper container element */ + /** Root Stepper container element */ root?: StyleProp; - /** Inline styles for the Step subcomponent */ + /** Step subcomponent element */ step?: StyleProp; - /** Inline styles for the SubstepContainer subcomponent */ + /** Substep container element */ substepContainer?: StyleProp; - /** Inline styles for the Label subcomponent */ + /** Label subcomponent element */ label?: StyleProp; - /** Inline styles for the Progress subcomponent */ + /** Progress subcomponent element */ progress?: StyleProp; - /** Inline styles for the Icon subcomponent */ + /** Icon subcomponent element */ icon?: StyleProp; - /** Inline styles for the Header subcomponent */ + /** Header subcomponent element */ header?: StyleProp; }; }; @@ -258,7 +259,11 @@ type StepperComponent = = Record = Record>( - { + _props: StepperProps, + ref: React.Ref, + ) => { + const mergedProps = useComponentConfig('Stepper', _props); + const { direction, activeStepId, steps, @@ -291,9 +296,7 @@ const StepperBase = memo( animate = true, disableAnimateOnMount, ...props - }: StepperProps, - ref: React.Ref, - ) => { + } = mergedProps; const hasMounted = useHasMounted(); const flatStepIds = useMemo(() => flattenSteps(steps).map((step) => step.id), [steps]); @@ -343,9 +346,8 @@ const StepperBase = memo( const previousActiveStepIndex = usePreviousValue(activeStepIndex) ?? -1; const [progressSprings, progressSpringsApi] = useSprings(steps.length, (index) => ({ - progress: complete ? 1 : 0, + from: { progress: complete ? 1 : 0 }, config: progressSpringConfig, - immediate: !animate || (disableAnimateOnMount && !hasMounted), })); useEffect(() => { @@ -356,10 +358,12 @@ const StepperBase = memo( // Case when going from not-complete to complete if (Boolean(complete) !== previousComplete) { if (complete) { - // Going to complete: animate from activeStepIndex+1 to end + // 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 - activeStepIndex - 1 }, - (_, i) => activeStepIndex + 1 + i, + { length: steps.length - lastFilledIndex - 1 }, + (_, i) => lastFilledIndex + 1 + i, ); isAnimatingForward = true; } else { diff --git a/packages/mobile/src/sticky-footer/StickyFooter.tsx b/packages/mobile/src/sticky-footer/StickyFooter.tsx index a5e5a95bfd..8e5c68d452 100644 --- a/packages/mobile/src/sticky-footer/StickyFooter.tsx +++ b/packages/mobile/src/sticky-footer/StickyFooter.tsx @@ -1,12 +1,14 @@ import React, { forwardRef, memo } from 'react'; import { View } from 'react-native'; +import { useSafeBottomPadding } from '../hooks/useSafeBottomPadding'; import { Box, type BoxProps } from '../layout'; export type StickyFooterProps = BoxProps & { /** * Whether to apply a box shadow to the StickyFooter element. - * @deprecated Use elevation instead. + * @deprecated Use elevation instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 */ elevated?: boolean; }; @@ -22,6 +24,7 @@ export const StickyFooter = memo( role = 'toolbar', accessibilityLabel = 'footer', padding = 2, + flexShrink = 0, ...props }: StickyFooterProps, forwardedRef: React.ForwardedRef, @@ -31,6 +34,7 @@ export const StickyFooter = memo( ref={forwardedRef} accessibilityLabel={accessibilityLabel} elevation={elevation} + flexShrink={flexShrink} padding={padding} role={role} testID={testID} diff --git a/packages/mobile/src/sticky-footer/__figma__/StickyFooter.figma.tsx b/packages/mobile/src/sticky-footer/__figma__/StickyFooter.figma.tsx index b82fdf1e8c..72f71b5e2b 100644 --- a/packages/mobile/src/sticky-footer/__figma__/StickyFooter.figma.tsx +++ b/packages/mobile/src/sticky-footer/__figma__/StickyFooter.figma.tsx @@ -7,7 +7,7 @@ figma.connect( StickyFooter, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10340-69579&m=dev', { - imports: ["import {StickyFooter} from '@coinbase/cds-mobile/sticky-footer/StickyFooter';"], + imports: ["import {StickyFooter} from '@coinbase/cds-mobile/sticky-footer/StickyFooter'"], props: { // showlegaltext1391921: figma.boolean('show legal text'), // buttons: figma.enum('buttons', { diff --git a/packages/mobile/src/sticky-footer/__stories__/StickyFooter.stories.tsx b/packages/mobile/src/sticky-footer/__stories__/StickyFooter.stories.tsx index de43ce0ace..52f794179f 100644 --- a/packages/mobile/src/sticky-footer/__stories__/StickyFooter.stories.tsx +++ b/packages/mobile/src/sticky-footer/__stories__/StickyFooter.stories.tsx @@ -8,7 +8,7 @@ import { VStack } from '../../layout'; import { StickyFooter } from '../StickyFooter'; const StickyFooterScreen = () => { - const [showStickyFooter, setShowStickyFooter] = useState(true); + const [showStickyFooter, setShowStickyFooter] = useState(false); const handleButtonPress = useCallback(() => { setShowStickyFooter(!showStickyFooter); }, [showStickyFooter]); @@ -17,7 +17,7 @@ const StickyFooterScreen = () => { return ( - + {showStickyFooter && ( diff --git a/packages/mobile/src/sticky-footer/__stories__/StickyFooterWithTray.stories.tsx b/packages/mobile/src/sticky-footer/__stories__/StickyFooterWithTray.stories.tsx index b0f76d992c..33676acf65 100644 --- a/packages/mobile/src/sticky-footer/__stories__/StickyFooterWithTray.stories.tsx +++ b/packages/mobile/src/sticky-footer/__stories__/StickyFooterWithTray.stories.tsx @@ -14,7 +14,7 @@ import { StickyFooter } from '../StickyFooter'; const options: string[] = prices.slice(0, 20); const StickyFooterWithTray = ({ title }: { title?: string }) => { - const [isTrayVisible, setIsTrayVisible] = useState(true); + const [isTrayVisible, setIsTrayVisible] = useState(false); const setIsTrayVisibleToFalse = useCallback(() => setIsTrayVisible(false), []); const setIsTrayVisibleToTrue = useCallback(() => setIsTrayVisible(true), []); const [value, setValue] = useState(); @@ -46,39 +46,28 @@ const StickyFooterWithTray = ({ title }: { title?: string }) => { ( + + + + + + + + + + + )} + handleBarVariant="inside" onCloseComplete={setIsTrayVisibleToFalse} title={title} verticalDrawerPercentageOfView={0.75} > - {({ handleClose }) => ( - - - - - - - - - - - - - - - - - - )} + + + )} diff --git a/packages/mobile/src/system/ComponentConfigProvider.tsx b/packages/mobile/src/system/ComponentConfigProvider.tsx new file mode 100644 index 0000000000..760ef5db9b --- /dev/null +++ b/packages/mobile/src/system/ComponentConfigProvider.tsx @@ -0,0 +1,61 @@ +import React, { createContext, useContext, useRef } from 'react'; +import { createStore, type StoreApi } from 'zustand'; + +import type { ComponentConfig } from '../core/componentConfig'; + +type ComponentConfigStoreState = { + components?: ComponentConfig; +}; + +export type ComponentConfigContextValue = StoreApi; + +export const ComponentConfigContext = createContext( + undefined, +); + +const createComponentConfigStoreState = ( + config: ComponentConfig | undefined, +): ComponentConfigStoreState => { + return { + components: config, + }; +}; + +export type ComponentConfigProviderProps = { + /** Component config: static objects and/or functional resolvers per component. */ + value?: ComponentConfig; + children?: React.ReactNode; +}; + +/** + * Provides component-level default props via a zustand store. + * Each component subscribes to only its own config slice, preventing cross-component re-renders. + * Supports nesting with isolated scopes: a child provider only applies its own config map. + */ +export const ComponentConfigProvider = ({ value, children }: ComponentConfigProviderProps) => { + const storeRef = useRef(null); + + if (!storeRef.current) { + storeRef.current = createStore(() => + createComponentConfigStoreState(value), + ); + } + + const newState = createComponentConfigStoreState(value); + storeRef.current.setState(newState, true); + + return ( + + {children} + + ); +}; + +/** Singleton empty store used when no ComponentConfigProvider exists in the tree. */ +const emptyComponentConfigStore = createStore(() => ({})); + +/** Returns the nearest ComponentConfigProvider's zustand store, or an empty fallback. */ +export const useComponentConfigStore = (): ComponentConfigContextValue => { + const context = useContext(ComponentConfigContext); + return context ?? emptyComponentConfigStore; +}; diff --git a/packages/mobile/src/system/PressableOpacity.tsx b/packages/mobile/src/system/PressableOpacity.tsx index 1295078833..8b77888c72 100644 --- a/packages/mobile/src/system/PressableOpacity.tsx +++ b/packages/mobile/src/system/PressableOpacity.tsx @@ -3,13 +3,19 @@ import React from 'react'; import type { PressableProps } from './Pressable'; import { Pressable } from './Pressable'; -/** @deprecated This component will be removed in a future version. Use `` instead. */ +/** + * @deprecated Use `` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 + */ export type PressableOpacityProps = Omit< PressableProps, 'background' | 'borderColor' | 'borderRadius' | 'borderWidth' | 'transparentWhileInactive' >; -/** @deprecated This component will be removed in a future version. Use `` instead. */ +/** + * @deprecated Use `` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 + */ export const PressableOpacity = ({ children, ...props }: PressableOpacityProps) => { return ( diff --git a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx b/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx index 3cbd00ca06..a7b0b7cc47 100644 --- a/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx +++ b/packages/mobile/src/system/__figma__/AndroidNavigationBar.figma.tsx @@ -8,7 +8,7 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10414%3A896', { imports: [ - "import { AndroidNavigationBar } from '@coinbase/cds-mobile/system/AndroidNavigationBar';", + "import { AndroidNavigationBar } from '@coinbase/cds-mobile/system/AndroidNavigationBar'", ], props: { showsearch27799: figma.boolean('show search'), diff --git a/packages/mobile/src/system/__stories__/ComponentConfigProvider.stories.tsx b/packages/mobile/src/system/__stories__/ComponentConfigProvider.stories.tsx new file mode 100644 index 0000000000..f2237e3024 --- /dev/null +++ b/packages/mobile/src/system/__stories__/ComponentConfigProvider.stories.tsx @@ -0,0 +1,15 @@ +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; + +import { StickerSheet } from './componentConfigStickerSheet/StickerSheet'; + +const ComponentConfigProviderStory = () => { + return ( + + + + + + ); +}; + +export default ComponentConfigProviderStory; diff --git a/packages/mobile/src/system/__stories__/ComponentConfigProviderCustom.stories.tsx b/packages/mobile/src/system/__stories__/ComponentConfigProviderCustom.stories.tsx new file mode 100644 index 0000000000..ee16a412b3 --- /dev/null +++ b/packages/mobile/src/system/__stories__/ComponentConfigProviderCustom.stories.tsx @@ -0,0 +1,22 @@ +import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { ComponentConfigProvider, ThemeProvider } from '..'; + +import { customComponentConfig } from './componentConfigStickerSheet/customComponentConfig'; +import { customTheme } from './componentConfigStickerSheet/customTheme'; +import { StickerSheet } from './componentConfigStickerSheet/StickerSheet'; + +const ComponentConfigProviderCustomStory = () => { + return ( + + + + + + + + + + ); +}; + +export default ComponentConfigProviderCustomStory; diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/Container.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/Container.tsx new file mode 100644 index 0000000000..0bb5195f10 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/Container.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react'; + +import { VStack } from '../../../layout/VStack'; +import { Text } from '../../../typography/Text'; + +type ContainerProps = React.ComponentProps & { + title?: string; +}; + +export const Container = memo( + ({ paddingX = 2, gap = 2, title, children, ...props }: ContainerProps) => { + return ( + + {title && ( + + {title} + + )} + {children} + + ); + }, +); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/StickerSheet.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/StickerSheet.tsx new file mode 100644 index 0000000000..3ef1b00355 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/StickerSheet.tsx @@ -0,0 +1,88 @@ +import React, { memo } from 'react'; + +import { HStack } from '../../../layout/HStack'; +import { VStack } from '../../../layout/VStack'; + +import { AccordionExample } from './examples/Accordion'; +import { AvatarExample } from './examples/Avatar'; +import { BannerExample } from './examples/Banner'; +import { ButtonExample } from './examples/Button'; +import { CoachmarkExample } from './examples/Coachmark'; +import { ControlsExample } from './examples/Controls'; +import { DatePickerExample } from './examples/DatePicker'; +import { DotCountExample } from './examples/DotCount'; +import { IconExample } from './examples/Icon'; +import { InputChipExample } from './examples/InputChip'; +import { ListCellExample } from './examples/ListCell'; +import { SearchExample } from './examples/Search'; +import { SegmentedTabsExample } from './examples/SegmentedTabs'; +import { SelectExample } from './examples/Select'; +import { SelectChipExample } from './examples/SelectChip'; +import { TagExample } from './examples/Tag'; +import { TextInputExample } from './examples/TextInput'; +import { Container } from './Container'; + +export const StickerSheet = memo(() => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx new file mode 100644 index 0000000000..075288bf19 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customComponentConfig.tsx @@ -0,0 +1,127 @@ +import type { ComponentConfig } from '../../../core/componentConfig'; +import { Text } from '../../../typography/Text'; + +export const customComponentConfig: ComponentConfig = { + Banner: { + borderRadius: 0, + }, + + Button: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + font: props.compact ? 'label1' : 'headline', + progressCircleSize: props.compact ? 12 : 16, + paddingY: 0, + }), + + IconButton: (props) => { + const isCompact = props.compact ?? true; + return { + borderRadius: 200, + height: isCompact ? 24 : 32, + width: isCompact ? 24 : 32, + ...(props.variant === 'tertiary' + ? { + background: 'bgAlternate', + color: 'fg', + borderColor: 'bgAlternate', + } + : {}), + }; + }, + + TextInput: ({ label, labelNode, ...props }) => ({ + labelNode: + (labelNode ?? label) ? ( + + {label} + + ) : undefined, + bordered: false, + inputBackground: 'bgAlternate', + font: props.compact ? 'label2' : 'body', + variant: 'foregroundMuted', + focusedBorderWidth: 100, + }), + + Switch: (props) => ({ + background: props.checked ? 'bgPrimary' : undefined, + controlColor: props.checked ? 'bgAlternate' : 'fg', + }), + + Tooltip: { + invertColorScheme: false, + }, + + Radio: (props) => ({ + background: 'bg', + borderWidth: props.checked ? 200 : 100, + borderColor: props.checked ? 'bgPrimary' : 'bgLinePrimarySubtle', + controlColor: 'bgPrimary', + dotSize: 20 / 3, + }), + + /** + * Advanced parity gap: we use 4px border radius instead of 2px border radius, could be fixed by adding borderRadius of 50 + */ + Checkbox: (props) => ({ + borderWidth: 200, + controlColor: 'fg', + background: props.checked ? 'bgSecondary' : undefined, + borderColor: props.checked ? 'bgSecondary' : 'bgLinePrimarySubtle', + }), + + ModalHeader: { + paddingX: 4, + paddingY: 3, + }, + + ModalFooter: { + paddingX: 4, + paddingY: 4, + }, + + ModalBody: { + paddingX: 4, + }, + + SegmentedTabs: { + activeBackground: 'bgSecondary', + background: 'bgAlternate', + borderRadius: 300, + }, + + SegmentedTab: { + activeColor: 'fg', + borderRadius: 200, + font: 'headline', + }, + + Chip: { + borderRadius: 200, + }, + + Link: { + underline: true, + }, + + ControlGroup: { + gap: 1, + }, + + SearchInput: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + }), + + Select: (props) => ({ + bordered: false, + variant: 'foregroundMuted', + inputBackground: 'bgAlternate', + focusedBorderWidth: 100, + height: props.compact ? 24 : props.labelVariant === 'inside' ? 40 : 32, + font: props.compact ? 'label2' : 'body', + labelColor: 'fgMuted', + labelFont: props.compact ? (props.align === 'end' ? 'label1' : 'label2') : 'body', + }), +}; diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customTheme.ts b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customTheme.ts new file mode 100644 index 0000000000..72cc55c4eb --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/customTheme.ts @@ -0,0 +1,525 @@ +import type { ThemeConfig } from '@coinbase/cds-mobile/core/theme'; +import { defaultTheme } from '@coinbase/cds-mobile/themes/defaultTheme'; + +export const customThemeId = 'custom-theme'; + +const lightSpectrum = { + blue0: '245,248,255', + blue5: '211,225,255', + blue10: '176,202,255', + blue15: '146,182,255', + blue20: '115,162,255', + blue30: '70,132,255', + blue40: '38,110,255', + blue50: '16,94,255', + blue60: '0,82,255', + blue70: '0,75,235', + blue80: '0,62,193', + blue90: '0,41,130', + blue100: '0,24,77', + green0: '245,255,251', + green5: '203,245,227', + green10: '171,230,206', + green15: '131,224,186', + green20: '101,214,167', + green30: '60,194,138', + green40: '34,173,115', + green50: '18,153,97', + green60: '9,133,81', + green70: '4,112,67', + green80: '2,83,50', + green90: '0,57,35', + green100: '0,31,18', + orange0: '255,250,245', + orange5: '254,232,210', + orange10: '253,213,176', + orange15: '251,194,147', + orange20: '249,174,118', + orange30: '244,140,76', + orange40: '237,112,47', + orange50: '225,89,27', + orange60: '207,71,14', + orange70: '181,54,6', + orange80: '145,39,2', + orange90: '100,26,0', + orange100: '51,13,0', + gray0: '255,255,255', + gray5: '247,248,249', + gray10: '238,240,243', + gray15: '222,225,231', + gray20: '206,210,219', + gray30: '177,183,195', + gray40: '137,144,158', + gray50: '113,120,134', + gray60: '91,97,110', + gray70: '70,75,85', + gray80: '50,53,61', + gray90: '30,32,37', + gray100: '10,11,13', + indigo0: '246,247,255', + indigo5: '230,232,255', + indigo10: '214,218,254', + indigo15: '198,204,253', + indigo20: '181,189,253', + indigo30: '148,161,251', + indigo40: '116,135,247', + indigo50: '89,111,242', + indigo60: '66,91,233', + indigo70: '47,74,215', + indigo80: '31,54,173', + indigo90: '17,32,107', + indigo100: '8,15,51', + pink0: '255,245,255', + pink5: '253,228,253', + pink10: '251,212,250', + pink15: '248,195,245', + pink20: '244,178,240', + pink30: '235,143,227', + pink40: '221,110,209', + pink50: '203,81,187', + pink60: '179,58,162', + pink70: '149,39,133', + pink80: '116,26,102', + pink90: '83,17,72', + pink100: '51,10,44', + purple0: '251,247,255', + purple5: '244,232,255', + purple10: '237,217,255', + purple15: '230,201,255', + purple20: '222,184,255', + purple30: '205,153,253', + purple40: '188,123,251', + purple50: '157,107,242', + purple60: '138,85,233', + purple70: '119,67,215', + purple80: '90,48,173', + purple90: '54,27,107', + purple100: '25,13,51', + red0: '255,245,246', + red5: '254,225,228', + red10: '253,206,210', + red15: '251,186,191', + red20: '249,166,173', + red30: '244,127,136', + red40: '237,89,102', + red50: '225,57,71', + red60: '207,32,47', + red70: '181,15,29', + red80: '145,5,16', + red90: '100,1,9', + red100: '51,0,4', + teal0: '240,254,255', + teal5: '188,246,253', + teal10: '136,237,251', + teal15: '93,226,248', + teal20: '51,213,244', + teal30: '0,188,235', + teal40: '0,169,221', + teal50: '0,147,203', + teal60: '0,123,179', + teal70: '0,97,149', + teal80: '0,71,116', + teal90: '0,47,83', + teal100: '0,27,51', + yellow0: '255,252,241', + yellow5: '255,244,192', + yellow10: '255,240,145', + yellow15: '255,234,100', + yellow20: '255,228,54', + yellow30: '247,210,26', + yellow40: '235,186,0', + yellow50: '207,151,0', + yellow60: '174,113,0', + yellow70: '136,76,0', + yellow80: '96,48,0', + yellow90: '58,20,0', + yellow100: '27,6,0', + chartreuse0: '245,255,250', + chartreuse5: '221,251,232', + chartreuse10: '198,247,209', + chartreuse15: '176,242,182', + chartreuse20: '159,238,155', + chartreuse30: '137,223,117', + chartreuse40: '127,208,87', + chartreuse50: '86,179,64', + chartreuse60: '53,151,48', + chartreuse70: '35,122,43', + chartreuse80: '25,93,41', + chartreuse90: '17,64,35', + chartreuse100: '7,26,17', +}; + +const darkSpectrum = { + blue0: '0,16,51', + blue5: '1,29,91', + blue10: '1,42,130', + blue15: '3,51,154', + blue20: '5,59,177', + blue30: '10,72,206', + blue40: '19,84,225', + blue50: '33,98,238', + blue60: '55,115,245', + blue70: '87,139,250', + blue80: '132,170,253', + blue90: '185,207,255', + blue100: '245,248,255', + green0: '0,31,18', + green5: '0,56,36', + green10: '1,70,42', + green15: '2,82,48', + green20: '2,92,55', + green30: '6,112,68', + green40: '11,133,82', + green50: '21,153,98', + green60: '39,173,117', + green70: '68,194,141', + green80: '111,214,171', + green90: '171,235,208', + green100: '245,255,251', + orange0: '51,13,0', + orange5: '79,20,0', + orange10: '107,28,1', + orange15: '131,36,2', + orange20: '155,44,4', + orange30: '189,59,9', + orange40: '213,76,18', + orange50: '230,96,32', + orange60: '240,120,54', + orange70: '248,150,86', + orange80: '252,185,131', + orange90: '254,219,185', + orange100: '255,250,245', + gray0: '10,11,13', + gray5: '20,21,25', + gray10: '30,32,37', + gray15: '40,43,49', + gray20: '50,53,61', + gray30: '70,75,85', + gray40: '91,97,110', + gray50: '114,120,134', + gray60: '138,145,158', + gray70: '165,170,182', + gray80: '193,198,207', + gray90: '224,226,231', + gray100: '255,255,255', + indigo0: '8,15,51', + indigo5: '14,27,91', + indigo10: '21,39,130', + indigo15: '27,47,154', + indigo20: '33,56,177', + indigo30: '48,73,206', + indigo40: '68,92,225', + indigo50: '92,113,238', + indigo60: '121,138,245', + indigo70: '153,165,250', + indigo80: '187,194,253', + indigo90: '219,223,255', + indigo100: '246,247,255', + pink0: '51,10,44', + pink5: '70,14,61', + pink10: '89,19,78', + pink15: '108,24,94', + pink20: '126,30,111', + pink30: '159,44,142', + pink40: '187,64,170', + pink50: '208,88,193', + pink60: '225,117,214', + pink70: '237,149,230', + pink80: '246,184,243', + pink90: '252,217,251', + pink100: '255,245,255', + purple0: '25,13,51', + purple5: '43,22,89', + purple10: '73,30,137', + purple15: '97,37,175', + purple20: '123,45,211', + purple30: '142,51,234', + purple40: '164,84,244', + purple50: '188,123,251', + purple60: '205,153,253', + purple70: '217,176,254', + purple80: '230,201,255', + purple90: '237,217,255', + purple100: '251,247,255', + red0: '51,0,4', + red5: '80,17,22', + red10: '107,1,10', + red15: '131,4,14', + red20: '155,7,19', + red30: '189,19,33', + red40: '213,38,52', + red50: '230,64,78', + red60: '240,97,109', + red70: '248,134,144', + red80: '252,174,181', + red90: '254,213,216', + red100: '255,245,246', + teal0: '0,20,38', + teal5: '0,32,59', + teal10: '0,45,79', + teal15: '0,58,99', + teal20: '0,72,118', + teal30: '0,99,153', + teal40: '0,125,182', + teal50: '0,149,205', + teal60: '0,170,223', + teal70: '6,190,236', + teal80: '69,217,245', + teal90: '149,239,251', + teal100: '240,254,255', + yellow0: '27,6,0', + yellow5: '49,17,0', + yellow10: '81,40,0', + yellow15: '96,48,0', + yellow20: '115,64,0', + yellow30: '147,96,0', + yellow40: '175,128,0', + yellow50: '199,158,0', + yellow60: '222,189,23', + yellow70: '229,205,48', + yellow80: '242,222,94', + yellow90: '255,240,145', + yellow100: '255,252,241', + chartreuse0: '5,22,14', + chartreuse5: '14,54,29', + chartreuse10: '21,79,34', + chartreuse15: '29,103,36', + chartreuse20: '45,128,40', + chartreuse30: '73,152,54', + chartreuse40: '107,176,73', + chartreuse50: '123,200,105', + chartreuse60: '140,209,136', + chartreuse70: '158,217,163', + chartreuse80: '178,222,188', + chartreuse90: '209,238,220', + chartreuse100: '245,255,250', +}; + +export const customTheme = { + ...defaultTheme, + id: customThemeId, + lightSpectrum, + darkSpectrum, + lightColor: { + // Foreground + fg: `rgb(${lightSpectrum.gray100})`, + fgMuted: `rgb(${lightSpectrum.gray60})`, + fgInverse: `rgb(${lightSpectrum.gray0})`, + fgPrimary: `rgb(${lightSpectrum.gray100})`, + fgWarning: `rgb(${lightSpectrum.orange60})`, + fgPositive: `rgb(${lightSpectrum.green60})`, + fgNegative: `rgb(${lightSpectrum.red60})`, + // Background + bg: `rgb(${lightSpectrum.gray0})`, + bgAlternate: `rgb(${lightSpectrum.gray5})`, + bgInverse: `rgb(${lightSpectrum.gray100})`, + bgOverlay: `rgba(${lightSpectrum.gray80},0.33)`, + bgPrimary: `rgb(${lightSpectrum.gray100})`, + bgPrimaryWash: `rgb(${lightSpectrum.gray5})`, + bgSecondary: `rgb(${lightSpectrum.gray10})`, + bgTertiary: `rgb(${lightSpectrum.gray20})`, + bgSecondaryWash: `rgb(${lightSpectrum.gray15})`, + bgNegative: `rgb(${lightSpectrum.red60})`, + bgNegativeWash: `rgb(${lightSpectrum.red5})`, + bgPositive: `rgb(${lightSpectrum.green60})`, + bgPositiveWash: `rgb(${lightSpectrum.green10})`, + bgWarning: `rgb(${lightSpectrum.orange40})`, + bgWarningWash: `rgb(${lightSpectrum.orange0})`, + currentColor: 'currentColor', + // Line + bgLine: `rgba(${lightSpectrum.gray60},0.2)`, + bgLineHeavy: `rgba(${lightSpectrum.gray60},0.66)`, + bgLineInverse: `rgb(${lightSpectrum.gray0})`, + bgLinePrimary: `rgb(${lightSpectrum.gray100})`, + bgLinePrimarySubtle: `rgb(${lightSpectrum.gray20})`, + // Elevation + bgElevation1: `rgb(${lightSpectrum.gray0})`, + bgElevation2: `rgb(${lightSpectrum.gray0})`, + // Accent + accentSubtleGreen: `rgb(${lightSpectrum.green0})`, + accentBoldGreen: `rgb(${lightSpectrum.green60})`, + accentSubtleBlue: `rgb(${lightSpectrum.blue0})`, + accentBoldBlue: `rgb(${lightSpectrum.blue60})`, + accentSubtlePurple: `rgb(${lightSpectrum.purple0})`, + accentBoldPurple: `rgb(${lightSpectrum.purple80})`, + accentSubtleYellow: `rgb(${lightSpectrum.yellow0})`, + accentBoldYellow: `rgb(${lightSpectrum.yellow30})`, + accentSubtleRed: `rgb(${lightSpectrum.red0})`, + accentBoldRed: `rgb(${lightSpectrum.red60})`, + accentSubtleGray: `rgb(${lightSpectrum.gray10})`, + accentBoldGray: `rgb(${lightSpectrum.gray80})`, + // Transparent + transparent: `rgba(${lightSpectrum.gray100},0)`, + }, + darkColor: { + // Foreground + fg: `rgb(${darkSpectrum.gray100})`, + fgInverse: `rgb(${darkSpectrum.gray0})`, + fgMuted: `rgb(${darkSpectrum.gray60})`, + fgPrimary: `rgb(${darkSpectrum.gray100})`, + fgPositive: `rgb(${darkSpectrum.green60})`, + fgNegative: `rgb(${darkSpectrum.red60})`, + fgWarning: `rgb(${darkSpectrum.orange60})`, + // Background + bg: `rgb(${darkSpectrum.gray0})`, + bgAlternate: `rgb(${darkSpectrum.gray5})`, + bgInverse: `rgb(${darkSpectrum.gray100})`, + bgOverlay: `rgba(${darkSpectrum.gray0},0.66)`, + bgPrimary: `rgb(${darkSpectrum.gray100})`, + bgPrimaryWash: `rgb(${darkSpectrum.gray10})`, + bgSecondary: `rgb(${darkSpectrum.gray15})`, + bgTertiary: `rgb(${darkSpectrum.gray30})`, + bgSecondaryWash: `rgb(${darkSpectrum.gray20})`, + bgNegative: `rgb(${darkSpectrum.red60})`, + bgNegativeWash: `rgb(${darkSpectrum.red5})`, + bgPositive: `rgb(${darkSpectrum.green60})`, + bgPositiveWash: `rgb(${darkSpectrum.green5})`, + bgWarning: `rgb(${darkSpectrum.orange40})`, + bgWarningWash: `rgb(${darkSpectrum.orange0})`, + currentColor: 'currentColor', + // Line + bgLine: `rgba(${darkSpectrum.gray60},0.2)`, + bgLineInverse: `rgb(${darkSpectrum.gray0})`, + bgLineHeavy: `rgba(${darkSpectrum.gray60},0.66)`, + bgLinePrimary: `rgb(${darkSpectrum.gray100})`, + bgLinePrimarySubtle: `rgb(${darkSpectrum.gray20})`, + // Elevation + bgElevation1: `rgb(${darkSpectrum.gray0})`, + bgElevation2: `rgb(${darkSpectrum.gray0})`, + // Accent + accentSubtleGreen: `rgb(${darkSpectrum.green0})`, + accentBoldGreen: `rgb(${darkSpectrum.green60})`, + accentSubtleBlue: `rgb(${darkSpectrum.blue0})`, + accentBoldBlue: `rgb(${darkSpectrum.blue60})`, + accentSubtlePurple: `rgb(${darkSpectrum.purple0})`, + accentBoldPurple: `rgb(${darkSpectrum.purple80})`, + accentSubtleYellow: `rgb(${darkSpectrum.yellow0})`, + accentBoldYellow: `rgb(${darkSpectrum.yellow30})`, + accentSubtleRed: `rgb(${darkSpectrum.red0})`, + accentBoldRed: `rgb(${darkSpectrum.red60})`, + accentSubtleGray: `rgb(${darkSpectrum.gray10})`, + accentBoldGray: `rgb(${darkSpectrum.gray80})`, + // Transparent + transparent: `rgba(${darkSpectrum.gray100},0)`, + }, + space: { + '0': 0, + '0.25': 1, + '0.5': 2, + '0.75': 3, + '1': 4, + '1.5': 6, + '2': 8, + '3': 12, + '4': 16, + '5': 20, + '6': 24, + '7': 28, + '8': 32, + '9': 36, + '10': 40, + }, + iconSize: { + xs: 8, + s: 12, + m: 16, + l: 20, + }, + avatarSize: { + s: 12, + m: 16, + l: 20, + xl: 32, + xxl: 36, + xxxl: 48, + }, + controlSize: { + checkboxSize: 16, + radioSize: 16, + switchWidth: 42, + switchHeight: 24, + switchThumbSize: 22, + tileSize: 64, + }, + borderRadius: { + '0': 0, + '100': 4, + '200': 6, + '300': 8, + '400': 12, + '500': 16, + '600': 24, + '700': 32, + '800': 40, + '900': 48, + '1000': 100000, + }, + borderWidth: { + '0': 0, + '100': 1, + '200': 2, + '300': 4, + '400': 6, + '500': 8, + }, + fontFamily: { + display1: 'var(--defaultFont-sans)', + display2: 'var(--defaultFont-sans)', + display3: 'var(--defaultFont-sans)', + title1: 'var(--defaultFont-sans)', + title2: 'var(--defaultFont-sans)', + title3: 'var(--defaultFont-sans)', + title4: 'var(--defaultFont-sans)', + headline: 'var(--defaultFont-sans)', + body: 'var(--defaultFont-sans)', + label1: 'var(--defaultFont-sans)', + label2: 'var(--defaultFont-sans)', + caption: 'var(--defaultFont-sans)', + legal: 'var(--defaultFont-sans)', + }, + fontSize: { + display1: 49, + display2: 35, + display3: 31, + title1: 20, + title2: 20, + title3: 14, + title4: 14, + headline: 12, + body: 12, + label1: 10, + label2: 10, + caption: 9, + legal: 9, + }, + fontWeight: { + display1: '400', + display2: '400', + display3: '400', + title1: '600', + title2: '400', + title3: '600', + title4: '400', + headline: '600', + body: '400', + label1: '600', + label2: '400', + caption: '600', + legal: '400', + }, + lineHeight: { + display1: 56, + display2: 40, + display3: 36, + title1: 24, + title2: 24, + title3: 20, + title4: 20, + headline: 16, + body: 16, + label1: 12, + label2: 16, + caption: 12, + legal: 12, + }, +} as const satisfies ThemeConfig; diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Accordion.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Accordion.tsx new file mode 100644 index 0000000000..3b1706e367 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Accordion.tsx @@ -0,0 +1,45 @@ +import React, { memo } from 'react'; + +import { Accordion } from '../../../../accordion/Accordion'; +import { AccordionItem } from '../../../../accordion/AccordionItem'; +import { Icon } from '../../../../icons/Icon'; +import { VStack } from '../../../../layout/VStack'; +import { Text } from '../../../../typography/Text'; + +export const AccordionExample = memo(() => { + return ( + + undefined}> + } + subtitle="Subtitle 1" + title="Accordion #1" + > + Accordion content one. + + } + subtitle="Subtitle 2" + title="Accordion #2" + > + Accordion content two. + + + undefined}> + } + subtitle="Alternative" + title="Accordion #3" + > + Second example with icon media. + + } title="Accordion #4"> + Title only second row. + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Avatar.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Avatar.tsx new file mode 100644 index 0000000000..c17f373218 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Avatar.tsx @@ -0,0 +1,17 @@ +import React, { memo } from 'react'; + +import { HStack } from '../../../../layout'; +import { Avatar } from '../../../../media/Avatar'; + +export const AvatarExample = memo(() => { + return ( + + + + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Banner.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Banner.tsx new file mode 100644 index 0000000000..85d8c6f257 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Banner.tsx @@ -0,0 +1,51 @@ +import React, { memo } from 'react'; + +import { Banner } from '../../../../banner/Banner'; +import { VStack } from '../../../../layout/VStack'; + +export const BannerExample = memo(() => { + return ( + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Button.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Button.tsx new file mode 100644 index 0000000000..bd2695cda7 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Button.tsx @@ -0,0 +1,68 @@ +import React, { memo } from 'react'; + +import { Button } from '../../../../buttons/Button'; +import { IconButton } from '../../../../buttons/IconButton'; +import { HStack } from '../../../../layout/HStack'; +import { VStack } from '../../../../layout/VStack'; +import { Text } from '../../../../typography/Text'; +import { buttonVariants } from '../themeVars'; + +export const ButtonExample = memo(() => { + return ( + + Regular + {buttonVariants.map((variant) => ( + + + + + ))} + + + + + + Compact + {buttonVariants.map((variant) => ( + + + + + ))} + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Coachmark.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Coachmark.tsx new file mode 100644 index 0000000000..14ec14945c --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Coachmark.tsx @@ -0,0 +1,15 @@ +import React, { memo } from 'react'; + +import { Button } from '../../../../buttons/Button'; +import { Coachmark } from '../../../../coachmark/Coachmark'; + +export const CoachmarkExample = memo(() => { + return ( + Got it} + content="You can now trade directly from your portfolio page." + onClose={() => undefined} + title="New feature" + /> + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Controls.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Controls.tsx new file mode 100644 index 0000000000..124ecfe4ec --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Controls.tsx @@ -0,0 +1,26 @@ +import { memo, useState } from 'react'; + +import { Checkbox } from '../../../../controls/Checkbox'; +import { Radio } from '../../../../controls/Radio'; +import { Switch } from '../../../../controls/Switch'; +import { HStack } from '../../../../layout'; + +export const ControlsExample = memo(() => { + const [isSwitchChecked, setIsSwitchChecked] = useState(false); + const [isCheckboxChecked, setIsCheckboxChecked] = useState(false); + const [isRadioChecked, setIsRadioChecked] = useState(true); + + return ( + + setIsSwitchChecked((v) => !v)}> + Switch + + setIsCheckboxChecked((v) => !v)}> + Checkbox + + setIsRadioChecked((v) => !v)}> + Radio + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx new file mode 100644 index 0000000000..85bc3aa554 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx @@ -0,0 +1,20 @@ +import React, { memo, useState } from 'react'; +import type { DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; + +import { DatePicker } from '../../../../dates/DatePicker'; + +export const DatePickerExample = memo(() => { + const [date, setDate] = useState(null); + const [error, setError] = useState(null); + + return ( + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DotCount.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DotCount.tsx new file mode 100644 index 0000000000..910a2bdf38 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/DotCount.tsx @@ -0,0 +1,20 @@ +import React, { memo } from 'react'; + +import { DotCount } from '../../../../dots/DotCount'; +import { Icon } from '../../../../icons/Icon'; + +export const DotCountExample = memo(() => { + return ( + <> + + + + + + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Icon.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Icon.tsx new file mode 100644 index 0000000000..93a8eb3666 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Icon.tsx @@ -0,0 +1,30 @@ +import React, { memo } from 'react'; + +import { Icon } from '../../../../icons/Icon'; +import { HStack } from '../../../../layout/HStack'; +import { VStack } from '../../../../layout/VStack'; + +export const IconExample = memo(() => { + return ( + + + + + + + + + + + + + + + + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/InputChip.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/InputChip.tsx new file mode 100644 index 0000000000..2f79116c7c --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/InputChip.tsx @@ -0,0 +1,16 @@ +import React, { memo } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; + +import { InputChip } from '../../../../chips/InputChip'; +import { RemoteImage } from '../../../../media/RemoteImage'; + +export const InputChipExample = memo(() => { + return ( + undefined} + start={} + > + ETH + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/ListCell.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/ListCell.tsx new file mode 100644 index 0000000000..87af94d9ea --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/ListCell.tsx @@ -0,0 +1,37 @@ +import React, { memo } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; + +import { ListCell } from '../../../../cells/ListCell'; +import { VStack } from '../../../../layout/VStack'; +import { RemoteImage } from '../../../../media/RemoteImage'; + +export const ListCellExample = memo(() => { + return ( + + } + onPress={() => undefined} + subtitle="BTC" + title="Bitcoin" + /> + } + onPress={() => undefined} + subtitle="ETH" + title="Ethereum" + /> + } + onPress={() => undefined} + subtitle="XRP" + title="XRP" + /> + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Search.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Search.tsx new file mode 100644 index 0000000000..20e5bb82cc --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Search.tsx @@ -0,0 +1,16 @@ +import React, { memo, useState } from 'react'; + +import { SearchInput } from '../../../../controls/SearchInput'; +import { VStack } from '../../../../layout/VStack'; + +export const SearchExample = memo(() => { + const [value, setValue] = useState(''); + const [compactValue, setCompactValue] = useState(''); + + return ( + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx new file mode 100644 index 0000000000..e608222ac3 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx @@ -0,0 +1,15 @@ +import React, { memo, useState } from 'react'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; + +import { HStack } from '../../../../layout'; +import { SegmentedTabs } from '../../../../tabs/SegmentedTabs'; + +import { segmentedTabs } from './constants'; + +export const SegmentedTabsExample = memo(() => { + const [activeTab, setActiveTab] = useState | null>( + segmentedTabs[0], + ); + + return ; +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx new file mode 100644 index 0000000000..e7046c6276 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Select.tsx @@ -0,0 +1,23 @@ +import React, { memo, useState } from 'react'; + +import { Select } from '../../../../alpha/select/Select'; +import { VStack } from '../../../../layout/VStack'; + +import { stickerSheetSelectOptions } from './constants'; + +export const SelectExample = memo(() => { + const [value, setValue] = useState('btc'); + const [secondaryValue, setSecondaryValue] = useState(null); + + return ( + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SelectChip.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SelectChip.tsx new file mode 100644 index 0000000000..285b0ec1ed --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/SelectChip.tsx @@ -0,0 +1,16 @@ +import React, { memo, useState } from 'react'; + +import { SelectChip } from '../../../../chips/SelectChip'; +import { SelectOption } from '../../../../controls/SelectOption'; + +export const SelectChipExample = memo(() => { + const [value, setValue] = useState('Balance'); + + return ( + + + + + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Tag.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Tag.tsx new file mode 100644 index 0000000000..793b7070bc --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/Tag.tsx @@ -0,0 +1,27 @@ +import React, { memo } from 'react'; + +import { HStack } from '../../../../layout/HStack'; +import { VStack } from '../../../../layout/VStack'; +import { Tag } from '../../../../tag/Tag'; +import { tagColorSchemes } from '../themeVars'; + +export const TagExample = memo(() => { + return ( + + + primary + primary + + {tagColorSchemes.map((colorScheme) => ( + + + {colorScheme} + + + {colorScheme} + + + ))} + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/TextInput.tsx b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/TextInput.tsx new file mode 100644 index 0000000000..e95c8c2c11 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/TextInput.tsx @@ -0,0 +1,44 @@ +import { memo, useState } from 'react'; +import { TextInput } from '@coinbase/cds-mobile/controls/TextInput'; + +import { InputIconButton } from '../../../../controls'; + +export const TextInputExample = memo(() => { + const [value, setValue] = useState(''); + + return ( + <> + + + + } + label="Label" + labelVariant="inside" + onChangeText={setValue} + placeholder="Input with icon button" + value={value} + /> + + ); +}); diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/constants.ts b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/constants.ts new file mode 100644 index 0000000000..0d2b2f7a5a --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/examples/constants.ts @@ -0,0 +1,20 @@ +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; + +export const stickerSheetSelectOptions = [ + { value: null, label: 'Clear' }, + { value: 'btc', label: 'Bitcoin' }, + { value: 'eth', label: 'Ethereum' }, + { value: 'sol', label: 'Solana' }, +]; + +export const segmentedTabs: TabValue<'buy' | 'sell' | 'convert'>[] = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, +]; + +export const tabNavigationTabs = [ + { id: 'overview', label: 'Overview' }, + { id: 'activity', label: 'Activity' }, + { id: 'details', label: 'Details' }, +] as const; diff --git a/packages/mobile/src/system/__stories__/componentConfigStickerSheet/themeVars.ts b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/themeVars.ts new file mode 100644 index 0000000000..17c593bdf3 --- /dev/null +++ b/packages/mobile/src/system/__stories__/componentConfigStickerSheet/themeVars.ts @@ -0,0 +1,10 @@ +export const buttonVariants = [ + 'primary', + 'secondary', + 'tertiary', + 'positive', + 'negative', + 'foregroundMuted', +] as const; + +export const tagColorSchemes = ['blue', 'green', 'yellow', 'purple', 'red', 'gray'] as const; diff --git a/packages/mobile/src/system/__tests__/ComponentConfigProvider.test.tsx b/packages/mobile/src/system/__tests__/ComponentConfigProvider.test.tsx new file mode 100644 index 0000000000..b73453161a --- /dev/null +++ b/packages/mobile/src/system/__tests__/ComponentConfigProvider.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render, screen } from '@testing-library/react-native'; + +import type { ButtonProps } from '../../buttons'; +import type { ComponentConfig } from '../../core/componentConfig'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; +import { DefaultThemeProvider } from '../../utils/testHelpers'; +import { ComponentConfigProvider } from '../ComponentConfigProvider'; + +const ButtonSpy = ({ testID, ...props }: Record) => { + const mergedProps = useComponentConfig('Button', props); + return {JSON.stringify(mergedProps)}; +}; + +const getProps = (testID: string) => { + const el = screen.getByTestId(testID); + return JSON.parse(el.props.children as string); +}; + +describe('ComponentConfigProvider (mobile)', () => { + it('returns local props unchanged when no provider is present', () => { + render( + + + , + ); + expect(getProps('btn')).toEqual({ variant: 'primary' }); + }); + + it('merges static config with local props', () => { + const config: ComponentConfig = { + Button: { variant: 'secondary', compact: true }, + }; + render( + + + + + , + ); + const props = getProps('btn'); + expect(props.variant).toBe('secondary'); + expect(props.compact).toBe(true); + }); + + it('local props override config defaults', () => { + const config: ComponentConfig = { + Button: { variant: 'secondary' }, + }; + render( + + + + + , + ); + expect(getProps('btn').variant).toBe('primary'); + }); + + it('supports functional config resolvers', () => { + const config: ComponentConfig = { + Button: (props: ButtonProps) => ({ + borderRadius: props.compact ? 700 : 900, + }), + }; + render( + + + + + , + ); + expect(getProps('btn').borderRadius).toBe(700); + }); + + it('nested providers are isolated and do not inherit parent config', () => { + const parentConfig: ComponentConfig = { + Button: { variant: 'secondary' }, + }; + const childConfig: ComponentConfig = {}; + render( + + + + + + + + , + ); + expect(getProps('parent-btn').variant).toBe('secondary'); + expect(getProps('child-btn')).toEqual({}); + }); +}); diff --git a/packages/mobile/src/system/index.ts b/packages/mobile/src/system/index.ts index d385b96b8e..6f98521dad 100644 --- a/packages/mobile/src/system/index.ts +++ b/packages/mobile/src/system/index.ts @@ -1,4 +1,5 @@ export * from './AndroidNavigationBar'; +export * from './ComponentConfigProvider'; export * from './EventHandlerProvider'; export * from './Interactable'; export * from './Pressable'; diff --git a/packages/mobile/src/tabs/DefaultTab.tsx b/packages/mobile/src/tabs/DefaultTab.tsx new file mode 100644 index 0000000000..82173dc3ca --- /dev/null +++ b/packages/mobile/src/tabs/DefaultTab.tsx @@ -0,0 +1,111 @@ +import React, { forwardRef, memo, useCallback, useMemo } from 'react'; +import { + type GestureResponderEvent, + Pressable, + type PressableProps, + type StyleProp, + type View, + type ViewStyle, +} from 'react-native'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common'; +import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; + +import { DotCount, type DotCountBaseProps } from '../dots/DotCount'; +import { useTheme } from '../hooks/useTheme'; +import { HStack } from '../layout'; +import { Text } from '../typography/Text'; + +import type { TabComponentProps } from './Tabs'; + +/** Optional dot count and a11y overrides for the default tab row. */ +export type DefaultTabLabelProps = Partial> & + Pick; + +export type DefaultTabProps = Omit< + PressableProps, + 'children' | 'onPress' | 'style' +> & + TabComponentProps & DefaultTabLabelProps> & { + /** Callback that is fired when the tab is pressed, after the active tab updates. */ + onPress?: (id: TabId, event: GestureResponderEvent) => void; + style?: StyleProp; + }; + +type DefaultTabComponent = ( + props: DefaultTabProps & { ref?: React.ForwardedRef }, +) => React.ReactElement; + +const DefaultTabComponent = memo( + forwardRef( + ( + { + id, + label, + disabled: disabledProp, + onPress, + count, + max, + accessibilityLabel, + style, + testID, + ...props + }: DefaultTabProps, + ref: React.ForwardedRef, + ) => { + const theme = useTheme(); + const { + activeTab, + updateActiveTab, + disabled: allTabsDisabled, + } = useTabsContext & DefaultTabLabelProps>(); + const isActive = activeTab?.id === id; + const isDisabled = disabledProp || allTabsDisabled; + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + updateActiveTab(id); + onPress?.(id, event); + }, + [id, onPress, updateActiveTab], + ); + + const labelPaddingStyle = useMemo( + () => ({ + paddingTop: theme.space[2], + paddingBottom: theme.space[2] - 2, + }), + [theme.space], + ); + + return ( + + + + {label} + + {!!count && } + + + ); + }, + ), +); + +DefaultTabComponent.displayName = 'DefaultTab'; + +export const DefaultTab = DefaultTabComponent as DefaultTabComponent; diff --git a/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx new file mode 100644 index 0000000000..50cbd3e266 --- /dev/null +++ b/packages/mobile/src/tabs/DefaultTabsActiveIndicator.tsx @@ -0,0 +1,58 @@ +import { memo, useEffect } from 'react'; +import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'; + +import { Box } from '../layout'; + +import { type TabsActiveIndicatorProps, tabsSpringConfig } from './Tabs'; + +/** + * Default underline-style indicator for mobile `Tabs`. Pass as + * `TabsActiveIndicatorComponent={DefaultTabsActiveIndicator}` with `TabComponent={DefaultTab}`. + */ +const AnimatedBox = Animated.createAnimatedComponent(Box); + +export const DefaultTabsActiveIndicator = memo( + ({ + activeTabRect, + background = 'bgPrimary', + style, + testID, + ...props + }: TabsActiveIndicatorProps) => { + const { width, x } = activeTabRect; + const rect = useSharedValue({ width, x }); + + useEffect(() => { + if (!width) return; + rect.value = withSpring({ x, width }, tabsSpringConfig); + }, [rect, width, x]); + + const animatedBoxStyle = useAnimatedStyle( + () => ({ + transform: [{ translateX: rect.value.x }], + width: rect.value.width, + }), + [], + ); + + if (!width) return null; + + return ( + + ); + }, +); + +DefaultTabsActiveIndicator.displayName = 'DefaultTabsActiveIndicator'; diff --git a/packages/mobile/src/tabs/SegmentedTab.tsx b/packages/mobile/src/tabs/SegmentedTab.tsx index f414bafd50..452bb42ce8 100644 --- a/packages/mobile/src/tabs/SegmentedTab.tsx +++ b/packages/mobile/src/tabs/SegmentedTab.tsx @@ -1,8 +1,6 @@ import React, { forwardRef, memo, useCallback, useMemo } from 'react'; import { type GestureResponderEvent, - Pressable, - type PressableProps, type StyleProp, type View, type ViewStyle, @@ -13,15 +11,17 @@ import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import { type TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box } from '../layout'; +import { Pressable, type PressableBaseProps, type PressableProps } from '../system/Pressable'; import { Text, type TextBaseProps } from '../typography/Text'; import { tabsSpringConfig } from './Tabs'; -export type SegmentedTabProps = TabValue & +export type SegmentedTabBaseProps = TabValue & Pick & - Omit & { + Omit & { /** * Text color when the SegmentedTab is active. * @default negativeForeground @@ -32,21 +32,29 @@ export type SegmentedTabProps = TabValue & * @default foreground */ color?: ThemeVars.Color; + }; + +export type SegmentedTabProps = SegmentedTabBaseProps & + Omit & { /** Callback that is fired when the SegmentedTab is pressed. */ - onPress?: (id: string, event: GestureResponderEvent) => void; + onPress?: (id: TabId, event: GestureResponderEvent) => void; style?: StyleProp; }; const AnimatedTextHeadline = Animated.createAnimatedComponent(Text); -type SegmentedTabFC = ( - props: SegmentedTabProps & { ref?: React.ForwardedRef }, +type SegmentedTabFC = ( + props: SegmentedTabProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const SegmentedTabComponent = memo( forwardRef( - ( - { + ( + _props: SegmentedTabProps, + ref: React.ForwardedRef, + ) => { + const mergedProps = useComponentConfig('SegmentedTab', _props); + const { id, label, disabled: disabledProp, @@ -63,10 +71,8 @@ const SegmentedTabComponent = memo( fontWeight, lineHeight, ...props - }: SegmentedTabProps, - ref: React.ForwardedRef, - ) => { - const { activeTab, updateActiveTab, disabled: allTabsDisabled } = useTabsContext(); + } = mergedProps; + const { activeTab, updateActiveTab, disabled: allTabsDisabled } = useTabsContext(); const isActive = activeTab?.id === id; const isDisabled = disabledProp || allTabsDisabled; diff --git a/packages/mobile/src/tabs/SegmentedTabs.tsx b/packages/mobile/src/tabs/SegmentedTabs.tsx index 285f1baa85..a3de5d084c 100644 --- a/packages/mobile/src/tabs/SegmentedTabs.tsx +++ b/packages/mobile/src/tabs/SegmentedTabs.tsx @@ -1,42 +1,62 @@ import React, { forwardRef, memo } from 'react'; -import type { View } from 'react-native'; +import type { StyleProp, View, ViewStyle } from 'react-native'; + +import { useComponentConfig } from '../hooks/useComponentConfig'; import { SegmentedTab } from './SegmentedTab'; import { SegmentedTabsActiveIndicator } from './SegmentedTabsActiveIndicator'; -import { Tabs, type TabsProps } from './Tabs'; +import { Tabs, type TabsBaseProps, type TabsProps } from './Tabs'; + +// We do Partial/Pick to allow TabComponent and TabsActiveIndicatorComponent to be optional +// We grab 'tabs' from the Omit allowing it to stay required -export type SegmentedTabsProps = Partial< - Pick, 'TabComponent' | 'TabsActiveIndicatorComponent'> +export type SegmentedTabsBaseProps = Partial< + Pick, 'TabComponent' | 'TabsActiveIndicatorComponent'> > & - Omit, 'TabComponent' | 'TabsActiveIndicatorComponent'>; + Omit, 'TabComponent' | 'TabsActiveIndicatorComponent' | 'styles'>; + +export type SegmentedTabsProps = SegmentedTabsBaseProps & + Partial, 'TabComponent' | 'TabsActiveIndicatorComponent'>> & + Omit, 'TabComponent' | 'TabsActiveIndicatorComponent' | 'styles'> & { + /** Custom styles for individual elements of the SegmentedTabs component */ + styles?: { + /** Root container element */ + root?: StyleProp; + /** Tab element */ + tab?: StyleProp; + /** Active indicator element */ + activeIndicator?: StyleProp; + }; + }; -type SegmentedTabsFC = ( - props: SegmentedTabsProps & { ref?: React.ForwardedRef }, +type SegmentedTabsFC = ( + props: SegmentedTabsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const SegmentedTabsComponent = memo( forwardRef( - ( - { + (_props: SegmentedTabsProps, ref: React.ForwardedRef) => { + const mergedProps = useComponentConfig('SegmentedTabs', _props); + const { TabComponent = SegmentedTab, TabsActiveIndicatorComponent = SegmentedTabsActiveIndicator, activeBackground = 'bgInverse', background = 'bgSecondary', - borderRadius = 1000, + borderRadius = 700, ...props - }: SegmentedTabsProps, - ref: React.ForwardedRef, - ) => ( - - ), + } = mergedProps; + return ( + + ); + }, ), ); diff --git a/packages/mobile/src/tabs/TabIndicator.tsx b/packages/mobile/src/tabs/TabIndicator.tsx index c21aa1c0fa..8ea545eec7 100644 --- a/packages/mobile/src/tabs/TabIndicator.tsx +++ b/packages/mobile/src/tabs/TabIndicator.tsx @@ -19,6 +19,8 @@ export type TabIndicatorProps = SharedProps & { background?: ThemeVars.Color; }; +/** @deprecated Use DefaultTabsActiveIndicator instead. This will be removed in a future major release. */ +/** @deprecationExpectedRemoval v10 */ export const TabIndicator = memo( forwardRef( ( diff --git a/packages/mobile/src/tabs/TabLabel.tsx b/packages/mobile/src/tabs/TabLabel.tsx index 73475aa465..923b7fe2c4 100644 --- a/packages/mobile/src/tabs/TabLabel.tsx +++ b/packages/mobile/src/tabs/TabLabel.tsx @@ -34,6 +34,8 @@ export type TabLabelBaseProps = SharedProps & export type TabLabelProps = TabLabelBaseProps & TextProps; +/** @deprecated Use DefaultTab instead. This will be removed in a future major release. */ +/** @deprecationExpectedRemoval v10 */ export const TabLabel = memo( ({ active, variant = 'primary', count = 0, max, ...props }: TabLabelProps) => { const theme = useTheme(); diff --git a/packages/mobile/src/tabs/TabNavigation.tsx b/packages/mobile/src/tabs/TabNavigation.tsx index 812d130569..5c680058cf 100644 --- a/packages/mobile/src/tabs/TabNavigation.tsx +++ b/packages/mobile/src/tabs/TabNavigation.tsx @@ -15,10 +15,14 @@ import { Pressable } from '../system/Pressable'; import { TabIndicator } from './TabIndicator'; import { TabLabel } from './TabLabel'; -export type TabProps = SharedProps & +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ +export type TabProps = SharedProps & Partial> & { /** The id should be a meaningful and useful identifier like "watchlist" or "forSale" */ - id: T; + id: TabId; /** Define a label for this Tab */ label: React.ReactNode; /** See the Tabs TDD to understand which variant should be used. @@ -30,11 +34,15 @@ export type TabProps = SharedProps & /** Full length accessibility label when the child text is not descriptive enough. */ accessibilityLabel?: string; /** Callback to fire when pressed */ - onPress?: (id: T) => void; + onPress?: (id: TabId) => void; /** Render a custom Component for the Tab */ Component?: (props: CustomTabProps) => React.ReactNode; }; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ export type CustomTabProps = Pick & { /** * @default false @@ -45,17 +53,21 @@ export type CustomTabProps = Pick & { label?: React.ReactNode; }; -export type TabNavigationBaseProps = BoxBaseProps & +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ +export type TabNavigationBaseProps = BoxBaseProps & Pick & Pick & { /** The active tabId * @default tabs[0].id */ - value?: T; + value?: TabId; /** Children should be TabLabels. If you only have one child, don't use tabs 🤪 */ - tabs: TabProps[]; + tabs: TabProps[]; /** Use the onChange handler to deal with any side effects, ie event tracking or showing a tooltip */ - onChange: ((tabId: T) => void) | React.Dispatch>; + onChange: ((tabId: TabId) => void) | React.Dispatch>; /** This should always match the background color of the parent container * @default: 'bg' */ @@ -88,10 +100,15 @@ export type TabNavigationBaseProps = BoxB id?: string; }; -export type TabNavigationProps = TabNavigationBaseProps; +/** + * @deprecated Use Tabs instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v10 + */ +export type TabNavigationProps = + TabNavigationBaseProps; -type TabNavigationFC = ( - props: TabNavigationProps & { ref?: React.ForwardedRef }, +type TabNavigationFC = ( + props: TabNavigationProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const TabNavigationComponent = memo( @@ -252,7 +269,8 @@ const TabNavigationComponent = memo( /** * TabNavigation renders a horizontal, tab-based navigation bar. * This component has a opinionated default style, but allows for customization through custom Component props. - * @deprecated Use `Tabs` instead. + * @deprecated Use `Tabs` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 */ export const TabNavigation = TabNavigationComponent as TabNavigationFC; diff --git a/packages/mobile/src/tabs/Tabs.tsx b/packages/mobile/src/tabs/Tabs.tsx index e4630bdd81..2e632e4c49 100644 --- a/packages/mobile/src/tabs/Tabs.tsx +++ b/packages/mobile/src/tabs/Tabs.tsx @@ -1,5 +1,5 @@ import React, { forwardRef, memo, useCallback, useImperativeHandle, useRef, useState } from 'react'; -import { View } from 'react-native'; +import { type StyleProp, View, type ViewStyle } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, @@ -18,9 +18,13 @@ import { import { accessibleOpacityDisabled } from '@coinbase/cds-common/tokens/interactable'; import { defaultRect, type Rect } from '@coinbase/cds-common/types/Rect'; -import type { BoxProps, HStackProps } from '../layout'; +import { useComponentConfig } from '../hooks/useComponentConfig'; +import type { BoxBaseProps, BoxProps, HStackProps } from '../layout'; import { Box, HStack } from '../layout'; +import { DefaultTab } from './DefaultTab'; +import { DefaultTabsActiveIndicator } from './DefaultTabsActiveIndicator'; + const AnimatedBox = Animated.createAnimatedComponent(Box); type TabContainerProps = { @@ -48,54 +52,93 @@ export type TabsActiveIndicatorProps = { activeTabRect: Rect; } & BoxProps; -export type TabComponent = React.FC>; +export type TabComponentProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit & { + id: TabId; + style?: StyleProp; +}; + +export type TabComponent< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = React.FC>; export type TabsActiveIndicatorComponent = React.FC; -export type TabsBaseProps = { - /** The array of tabs data. Each tab may optionally define a custom Component to render. */ - tabs: (TabValue & { Component?: TabComponent })[]; - /** The default Component to render each tab. */ - TabComponent: TabComponent; - /** The default Component to render the tabs active indicator. */ - TabsActiveIndicatorComponent: TabsActiveIndicatorComponent; - /** Background color passed to the TabsActiveIndicatorComponent. */ - activeBackground?: ThemeVars.Color; - /** Optional callback to receive the active tab element. */ - onActiveTabElementChange?: (element: View | null) => void; -} & Omit, 'tabs'>; - -export type TabsProps = TabsBaseProps & Omit; - -type TabsFC = ( - props: TabsProps & { ref?: React.ForwardedRef }, +export type TabsBaseProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = Omit & + Omit, 'tabs'> & { + /** The array of tabs data. Each tab may optionally define a custom Component to render. */ + tabs: (TTab & { Component?: TabComponent })[]; + /** The default Component to render each tab. */ + TabComponent?: TabComponent; + /** The default Component to render the tabs active indicator. */ + TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent; + /** Background color passed to the TabsActiveIndicatorComponent. */ + activeBackground?: ThemeVars.Color; + /** Optional callback to receive the active tab element. */ + onActiveTabElementChange?: (element: View | null) => void; + }; + +export type TabsProps< + TabId extends string = string, + TTab extends TabValue = TabValue, +> = TabsBaseProps & + Omit & { + /** Custom styles for individual elements of the Tabs component */ + styles?: { + /** Root container element */ + root?: StyleProp; + /** Tab element */ + tab?: StyleProp; + /** Active indicator element */ + activeIndicator?: StyleProp; + }; + }; + +type TabsFC = = TabValue>( + props: TabsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const TabsComponent = memo( forwardRef( - ( - { + = TabValue>( + _props: TabsProps, + ref: React.ForwardedRef, + ) => { + const mergedProps = useComponentConfig('Tabs', _props); + const { tabs, - TabComponent, - TabsActiveIndicatorComponent, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, activeBackground, activeTab, disabled, onChange, + styles, + style, role = 'tablist', position = 'relative', alignSelf = 'flex-start', opacity, onActiveTabElementChange, + borderRadius, + borderTopLeftRadius, + borderTopRightRadius, + borderBottomLeftRadius, + borderBottomRightRadius, + testID, ...props - }: TabsProps, - ref: React.ForwardedRef, - ) => { + } = mergedProps; const tabsContainerRef = useRef(null); useImperativeHandle(ref, () => tabsContainerRef.current as View, []); // merge internal ref to forwarded ref const refMap = useRefMap(); - const api = useTabs({ tabs, activeTab, disabled, onChange }); + const api = useTabs({ tabs, activeTab, disabled, onChange }); const [activeTabRect, setActiveTabRect] = useState(defaultRect); const previousActiveRef = useRef(activeTab); @@ -127,22 +170,36 @@ const TabsComponent = memo( }> - {tabs.map(({ id, Component: CustomTabComponent, disabled: tabDisabled, ...props }) => { + {tabs.map(({ id, Component: CustomTabComponent, ...props }) => { const RenderedTab = CustomTabComponent ?? TabComponent; return ( - + ); })} @@ -160,10 +217,12 @@ export const Tabs = TabsComponent as TabsFC; export const TabsActiveIndicator = ({ activeTabRect, position = 'absolute', + style, + testID = 'tabs-active-indicator', ...props }: TabsActiveIndicatorProps) => { const previousActiveTabRect = useRef(activeTabRect); - const newActiveTabRect = { x: activeTabRect.x, width: activeTabRect.width }; + const newActiveTabRect = { x: activeTabRect.x, y: activeTabRect.y, width: activeTabRect.width }; const animatedTabRect = useSharedValue(newActiveTabRect); const isFirstRenderWithWidth = previousActiveTabRect.current.width === 0 && activeTabRect.width > 0; @@ -177,7 +236,7 @@ export const TabsActiveIndicator = ({ const animatedBoxStyle = useAnimatedStyle( () => ({ - transform: [{ translateX: animatedTabRect.value.x }], + transform: [{ translateX: animatedTabRect.value.x }, { translateY: animatedTabRect.value.y }], width: animatedTabRect.value.width, }), [animatedTabRect], @@ -189,8 +248,8 @@ export const TabsActiveIndicator = ({ height={activeTabRect.height} position={position} role="none" - style={animatedBoxStyle} - testID="tabs-active-indicator" + style={[animatedBoxStyle, style]} + testID={testID} {...props} /> ); diff --git a/packages/mobile/src/tabs/__figma__/SegmentedTabs.figma.tsx b/packages/mobile/src/tabs/__figma__/SegmentedTabs.figma.tsx index fe9973255f..daaa9c5c79 100644 --- a/packages/mobile/src/tabs/__figma__/SegmentedTabs.figma.tsx +++ b/packages/mobile/src/tabs/__figma__/SegmentedTabs.figma.tsx @@ -8,7 +8,7 @@ figma.connect( SegmentedTabs, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=20859-2979&m=dev', { - imports: ["import { SegmentedTabs } from '@coinbase/cds-mobile/tabs/SegmentedTabs';"], + imports: ["import { SegmentedTabs } from '@coinbase/cds-mobile/tabs/SegmentedTabs'"], variant: { tabs: '2 tabs' }, props: { activeTab: figma.enum('active state', { @@ -34,7 +34,7 @@ figma.connect( SegmentedTabs, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=20859-2979&m=dev', { - imports: ["import { SegmentedTabs } from '@coinbase/cds-mobile/tabs/SegmentedTabs';"], + imports: ["import { SegmentedTabs } from '@coinbase/cds-mobile/tabs/SegmentedTabs'"], variant: { tabs: '3 tabs' }, props: { activeTab: figma.enum('active state', { @@ -62,7 +62,7 @@ figma.connect( SegmentedTabs, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=20859-3073&m=dev', { - imports: ["import { SegmentedTab } from '@coinbase/cds-mobile/tabs/SegmentedTab';"], + imports: ["import { SegmentedTab } from '@coinbase/cds-mobile/tabs/SegmentedTab'"], props: { id: figma.string('title'), label: figma.string('title'), diff --git a/packages/mobile/src/tabs/__figma__/TabNavigation.figma.tsx b/packages/mobile/src/tabs/__figma__/TabNavigation.figma.tsx index 446cedad78..b025b3f334 100644 --- a/packages/mobile/src/tabs/__figma__/TabNavigation.figma.tsx +++ b/packages/mobile/src/tabs/__figma__/TabNavigation.figma.tsx @@ -8,7 +8,7 @@ figma.connect( TabNavigation, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=240-8930&m=dev', { - imports: ["import { TabNavigation } from '@coinbase/cds-mobile/tabs/TabNavigation';"], + imports: ["import { TabNavigation } from '@coinbase/cds-mobile/tabs/TabNavigation'"], props: { tab1: figma.nestedProps('1 Primary Tab', { count: figma.boolean('dot count', { diff --git a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx index cc13d19f1f..2ccaadadb4 100644 --- a/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/SegmentedTabs.stories.tsx @@ -1,16 +1,25 @@ -import React, { useCallback, useState } from 'react'; +import React, { memo, useCallback, useEffect, useState } from 'react'; import { Pressable, ScrollView } from 'react-native'; +import { + interpolateColor, + runOnJS, + useAnimatedReaction, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import { useTabsContext } from '@coinbase/cds-common/tabs/TabsContext'; import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { useTheme } from '../../hooks/useTheme'; +import { Icon, type IconProps } from '../../icons/Icon'; import { Box } from '../../layout'; import { Text } from '../../typography/Text'; import { SegmentedTab } from '../SegmentedTab'; import { SegmentedTabs, type SegmentedTabsProps } from '../SegmentedTabs'; import type { TabProps } from '../TabNavigation'; import type { TabComponent, TabsActiveIndicatorProps } from '../Tabs'; -import { TabsActiveIndicator } from '../Tabs'; +import { TabsActiveIndicator, tabsSpringConfig } from '../Tabs'; const CustomActiveIndicator = ({ activeTabRect, @@ -56,6 +65,39 @@ const CustomSegmentedTabColor: TabComponent = (props) => ( const CustomSegmentedTabFont: TabComponent = (props) => ; +type ColoredIconProps = { + tabId: string; + name: IconProps['name']; +}; + +const ColoredIcon = memo(({ tabId, name }: ColoredIconProps) => { + const { activeTab } = useTabsContext(); + const isActive = activeTab?.id === tabId; + const theme = useTheme(); + + const progress = useSharedValue(isActive ? 1 : 0); + const [color, setColor] = useState(isActive ? theme.color.fgInverse : theme.color.fg); + + useEffect(() => { + progress.value = withSpring(isActive ? 1 : 0, tabsSpringConfig); + }, [isActive, progress]); + + useAnimatedReaction( + () => interpolateColor(progress.value, [0, 1], [theme.color.fg, theme.color.fgInverse]), + (newColor) => { + runOnJS(setColor)(newColor); + }, + ); + + return ; +}); + +const iconSegments = [ + { id: 'buy', label: }, + { id: 'sell', label: }, + { id: 'convert', label: }, +]; + const basicSegments = [ { id: 'buy', label: 'Buy' }, { id: 'sell', label: 'Sell' }, @@ -99,19 +141,19 @@ const mixedCustomSegments = [ { id: 'convert', label: 'Convert', Component: CustomSegmentedTabColor }, ]; -type SegmentedTabsExampleProps = { +type SegmentedTabsExampleProps = { title: string; - defaultActiveTab: TabValue | null; -} & Omit, 'activeTab' | 'onChange'>; + defaultActiveTab: TabValue | null; +} & Omit, 'activeTab' | 'onChange'>; -const SegmentedTabsExample = ({ +const SegmentedTabsExample = ({ title, defaultActiveTab, ...props -}: SegmentedTabsExampleProps) => { - const [activeTab, updateActiveTab] = useState | null>(defaultActiveTab); +}: SegmentedTabsExampleProps) => { + const [activeTab, updateActiveTab] = useState | null>(defaultActiveTab); const handleChange = useCallback( - (activeTab: TabValue | null) => updateActiveTab(activeTab), + (activeTab: TabValue | null) => updateActiveTab(activeTab), [], ); @@ -192,7 +234,57 @@ const SegmentedTabsScreen = () => ( tabs={basicSegments} title="Scaled" /> + + + + ); +const CustomStylesExample = () => { + const theme = useTheme(); + return ( + + ); +}; + +const IconLabelsExample = () => { + const theme = useTheme(); + return ( + + ); +}; + export default SegmentedTabsScreen; diff --git a/packages/mobile/src/tabs/__stories__/TabNavigation.stories.tsx b/packages/mobile/src/tabs/__stories__/TabNavigation.stories.tsx index 4aef4fa632..1ec9f6ca1d 100644 --- a/packages/mobile/src/tabs/__stories__/TabNavigation.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/TabNavigation.stories.tsx @@ -115,6 +115,15 @@ const TabNavigationScreen = () => { /> + + + ); }; diff --git a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx index 59925fd5d7..bc3d0fa7a6 100644 --- a/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx +++ b/packages/mobile/src/tabs/__stories__/Tabs.stories.tsx @@ -1,67 +1,187 @@ -import React, { useState } from 'react'; +import { useCallback, useState } from 'react'; +import { sampleTabs } from '@coinbase/cds-common/internal/data/tabs'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; import { gutter } from '@coinbase/cds-common/tokens/sizing'; +import { zIndex } from '@coinbase/cds-common/tokens/zIndex'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; -import { VStack } from '../../layout/VStack'; +import { VStack } from '../../layout'; +import { ThemeProvider } from '../../system/ThemeProvider'; +import { defaultTheme } from '../../themes/defaultTheme'; import { Text } from '../../typography/Text'; -import { TabNavigation, type TabNavigationProps, type TabProps } from '../TabNavigation'; - -const tabs: TabProps[] = [ - { - id: 'first_item', - label: 'First item', - onPress: console.warn, - }, - { - id: 'second_item', - label: 'Second item', - }, - { - id: 'third_item', - label: 'Third item', - onPress: console.warn, - }, - { - id: 'fourth_item', - label: 'Fourth item', - }, - { - id: 'fifth_item', - label: 'Fifth item', - }, +import { DefaultTab, type DefaultTabLabelProps } from '../DefaultTab'; +import { DefaultTabsActiveIndicator } from '../DefaultTabsActiveIndicator'; +import { + type TabComponent, + Tabs, + TabsActiveIndicator, + type TabsActiveIndicatorComponent, + type TabsActiveIndicatorProps, + type TabsProps, +} from '../Tabs'; + +type TradingAction = 'buy' | 'sell' | 'convert'; + +type TabRowWithTestId = TabValue & { testID?: string }; + +const basicTabs: TabRowWithTestId[] = [ + { id: 'buy', label: 'Buy', testID: 'buy-tab' }, + { id: 'sell', label: 'Sell', testID: 'sell-tab' }, + { id: 'convert', label: 'Convert', testID: 'convert-tab' }, ]; -// TODO update once _Tabs_ component is complete -const TabScreen = () => { - const [activeTabOne, setActiveTabOne] = useState(tabs[0].id); +const longTabs = sampleTabs.slice(0, 9); + +const tabsWithDisabled = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell', disabled: true }, + { id: 'convert', label: 'Convert' }, +]; + +const typedTabs: TabValue[] = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, +]; + +type TradingTab = TabValue & DefaultTabLabelProps; +const tabsWithDotCounts: TradingTab[] = basicTabs.map((tab, index) => + index === 0 ? { ...tab, count: 3, max: 99 } : tab, +); + +const CustomSpringIndicator = (props: TabsActiveIndicatorProps) => ( + +); + +type TabsExampleProps = TabValue> = { + title: string; + defaultActiveTab: TTab | null; + TabComponent?: TabComponent; + TabsActiveIndicatorComponent?: TabsActiveIndicatorComponent; +} & Omit< + TabsProps, + 'activeTab' | 'onChange' | 'TabComponent' | 'TabsActiveIndicatorComponent' +>; + +const TabsExample = = TabValue>({ + title, + defaultActiveTab, + TabComponent = DefaultTab, + TabsActiveIndicatorComponent = DefaultTabsActiveIndicator, + ...props +}: TabsExampleProps) => { + const [activeTab, setActiveTab] = useState(defaultActiveTab); + const handleChange = useCallback((next: TTab | null) => setActiveTab(next), []); return ( - - - - - Static preview - - {activeTabOne} - - - - - + + + ); +}; + +const panelTabs = sampleTabs.slice(0, 3); + +const TabsWithPanelsExample = () => { + const [activeTab, setActiveTab] = useState | null>(panelTabs[0]); + + return ( + + + + Pair tab buttons with content regions that follow the active tab (see panel below). + + - - Static preview - - {activeTabOne} - - - - + {panelTabs.map((tab) => + activeTab?.id === tab.id ? ( + + Panel: {tab.label} + Content for this tab. + + ) : null, + )} + + ); }; -export default TabScreen; +const DefaultTabsScreen = () => ( + + + + + + + + + + + + + + + + +); + +export default DefaultTabsScreen; diff --git a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx index 7c7ad2ccf5..5b461814c5 100644 --- a/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx +++ b/packages/mobile/src/tabs/__tests__/SegmentedTabs.test.tsx @@ -89,10 +89,10 @@ describe('SegmentedTabs', () => { }); jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, - transform: [{ translateX: 0 }], + transform: [{ translateX: 0 }, { translateY: 0 }], }); }); @@ -129,10 +129,10 @@ describe('SegmentedTabs', () => { jest.advanceTimersByTime(300); - expect(screen.getByTestId('tabs-active-indicator')).toHaveAnimatedStyle({ + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ width: 68, height: 40, - transform: [{ translateX: 68 }], + transform: [{ translateX: 68 }, { translateY: 0 }], }); }); @@ -180,4 +180,106 @@ describe('SegmentedTabs', () => { ); expect(ref.current).toBeInstanceOf(View); }); + + it('positions indicator correctly with horizontal padding', () => { + const mockPaddedData: ReturnType = { + refs: { current: {} }, + registerRef: NoopFn, + getRef: jest.fn(() => ({ + measureLayout: jest.fn((_, callback: MeasureOnSuccessCallback) => { + callback(20, 0, 68, 40, 0, 0); + }), + })), + }; + mockUseRefMap(mockPaddedData); + + render( + + + + + , + ); + + const tabsContainer = screen.getByTestId(TEST_ID); + fireEvent(tabsContainer, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 350, height: 40 } }, + }); + + jest.advanceTimersByTime(300); + + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ + width: 68, + height: 40, + transform: [{ translateX: 20 }, { translateY: 0 }], + }); + }); + + it('positions indicator correctly with vertical padding', () => { + const mockVerticalPaddedData: ReturnType = { + refs: { current: {} }, + registerRef: NoopFn, + getRef: jest.fn(() => ({ + measureLayout: jest.fn((_, callback: MeasureOnSuccessCallback) => { + callback(0, 8, 68, 40, 0, 0); + }), + })), + }; + mockUseRefMap(mockVerticalPaddedData); + + render( + + + + + , + ); + + const tabsContainer = screen.getByTestId(TEST_ID); + fireEvent(tabsContainer, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 350, height: 56 } }, + }); + + jest.advanceTimersByTime(300); + + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ + width: 68, + height: 40, + transform: [{ translateX: 0 }, { translateY: 8 }], + }); + }); + + it('positions indicator correctly with both horizontal and vertical padding', () => { + const mockBothPaddedData: ReturnType = { + refs: { current: {} }, + registerRef: NoopFn, + getRef: jest.fn(() => ({ + measureLayout: jest.fn((_, callback: MeasureOnSuccessCallback) => { + callback(20, 8, 68, 40, 0, 0); + }), + })), + }; + mockUseRefMap(mockBothPaddedData); + + render( + + + + + , + ); + + const tabsContainer = screen.getByTestId(TEST_ID); + fireEvent(tabsContainer, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 350, height: 56 } }, + }); + + jest.advanceTimersByTime(300); + + expect(screen.getByTestId(`${TEST_ID}-active-indicator`)).toHaveAnimatedStyle({ + width: 68, + height: 40, + transform: [{ translateX: 20 }, { translateY: 8 }], + }); + }); }); diff --git a/packages/mobile/src/tabs/index.ts b/packages/mobile/src/tabs/index.ts index 498c1d29a4..4d05fca873 100644 --- a/packages/mobile/src/tabs/index.ts +++ b/packages/mobile/src/tabs/index.ts @@ -1,3 +1,5 @@ +export * from './DefaultTab'; +export * from './DefaultTabsActiveIndicator'; export * from './SegmentedTabs'; export * from './TabIndicator'; export * from './TabLabel'; diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index b6c4d3ba75..3c4714a03b 100644 --- a/packages/mobile/src/tag/Tag.tsx +++ b/packages/mobile/src/tag/Tag.tsx @@ -8,6 +8,7 @@ import { tagHorizontalSpacing, } from '@coinbase/cds-common/tokens/tags'; import type { + IconName, SharedAccessibilityProps, SharedProps, TagColorScheme, @@ -15,7 +16,9 @@ import type { TagIntent, } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; +import { Icon } from '../icons/Icon'; import { Box, type BoxProps } from '../layout'; import { Text } from '../typography/Text'; @@ -44,56 +47,88 @@ export type TagBaseProps = SharedProps & color?: ThemeVars.SpectrumColor; /** Setting a custom max width for this tag will enable text truncation */ maxWidth?: BoxProps['maxWidth']; + /** Set the start node */ + start?: React.ReactNode; + /** Icon to render at the start of the tag. */ + startIcon?: IconName; + /** Whether the start icon is active */ + startIconActive?: boolean; + /** Set the end node */ + end?: React.ReactNode; + /** Icon to render at the end of the tag. */ + endIcon?: IconName; + /** Whether the end icon is active */ + endIconActive?: boolean; }; export type TagProps = TagBaseProps & Omit; export const Tag = memo( - forwardRef( - ( - { - children, - intent = 'informational', - emphasis = intent === 'informational' ? 'low' : 'high', - colorScheme = 'blue', - background: customBackground, - color: customColor, - alignItems = 'center', - justifyContent = 'center', - testID = 'cds-tag', - ...props - }: TagProps, - forwardedRef: React.ForwardedRef, - ) => { - const theme = useTheme(); - const { background, foreground } = tagEmphasisColorMap[emphasis][colorScheme]; - const backgroundColor = `rgb(${theme.spectrum[customBackground ?? background]})`; - const color = `rgb(${theme.spectrum[customColor ?? foreground]})`; + forwardRef((_props: TagProps, forwardedRef: React.ForwardedRef) => { + const mergedProps = useComponentConfig('Tag', _props); + const { + children, + intent = 'informational', + emphasis = intent === 'informational' ? 'low' : 'high', + colorScheme = 'blue', + background: customBackground, + color: customColor, + start, + startIcon, + startIconActive, + end, + endIcon, + endIconActive, + alignItems = 'center', + flexDirection = 'row', + gap = 0.5, + justifyContent = 'center', + paddingY = 0.25, + testID = 'cds-tag', + ...props + } = mergedProps; + const theme = useTheme(); + const { background, foreground } = tagEmphasisColorMap[emphasis][colorScheme]; + const backgroundColor = `rgb(${theme.spectrum[customBackground ?? background]})`; + const color = `rgb(${theme.spectrum[customColor ?? foreground]})`; - return ( - + {start ? ( + start + ) : startIcon ? ( + + ) : null} + + - - {children} - - - ); - }, - ), + {children} + + + {end ? ( + end + ) : endIcon ? ( + + ) : null} + + ); + }), ); diff --git a/packages/mobile/src/tag/__figma__/Tag.figma.tsx b/packages/mobile/src/tag/__figma__/Tag.figma.tsx index 87557b77a4..24067e39be 100644 --- a/packages/mobile/src/tag/__figma__/Tag.figma.tsx +++ b/packages/mobile/src/tag/__figma__/Tag.figma.tsx @@ -7,7 +7,7 @@ figma.connect( Tag, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=68%3A996', { - imports: ["import { Tag } from '@coinbase/cds-mobile/tag/Tag';"], + imports: ["import { Tag } from '@coinbase/cds-mobile/tag/Tag'"], variant: { intent: 'informational' }, props: { emphasis: figma.enum('emphasis', { @@ -41,7 +41,7 @@ figma.connect( Tag, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=68%3A996', { - imports: ["import { Tag } from '@coinbase/cds-mobile/tag/Tag';"], + imports: ["import { Tag } from '@coinbase/cds-mobile/tag/Tag'"], variant: { intent: 'promotional' }, props: { emphasis: figma.enum('emphasis', { diff --git a/packages/mobile/src/tag/__stories__/Tag.stories.tsx b/packages/mobile/src/tag/__stories__/Tag.stories.tsx index d422536d13..cff48c4bec 100644 --- a/packages/mobile/src/tag/__stories__/Tag.stories.tsx +++ b/packages/mobile/src/tag/__stories__/Tag.stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import startCase from 'lodash/startCase'; import { Example, ExampleScreen } from '../../examples/ExampleScreen'; +import { Icon } from '../../icons/Icon'; import { Tag, type TagBaseProps } from '../Tag'; type TagPropConfig = { @@ -87,6 +88,35 @@ const TagScreen = () => { })} ))} + + + Start icon + + + End icon + + + Both icons + + + Promotional with icons + + + + }> + Custom start node + + }> + Custom end node + + } + start={} + > + Both custom nodes + + ); }; diff --git a/packages/mobile/src/tag/__tests__/Tag.test.tsx b/packages/mobile/src/tag/__tests__/Tag.test.tsx index 7e65aef05d..ccea43ac4c 100644 --- a/packages/mobile/src/tag/__tests__/Tag.test.tsx +++ b/packages/mobile/src/tag/__tests__/Tag.test.tsx @@ -1,4 +1,4 @@ -import { Text } from 'react-native'; +import { Text, View } from 'react-native'; import { tagColorMap, tagEmphasisColorMap } from '@coinbase/cds-common/tokens/tags'; import { render, screen } from '@testing-library/react-native'; @@ -106,6 +106,52 @@ describe('Tag', () => { }); }); + it('renders with a startIcon', () => { + render( + + + Tag + + , + ); + expect(screen.getByTestId(TEST_ID)).toBeDefined(); + expect(screen.getByText('Tag')).toBeDefined(); + }); + + it('renders with an endIcon', () => { + render( + + + Tag + + , + ); + expect(screen.getByTestId(TEST_ID)).toBeDefined(); + expect(screen.getByText('Tag')).toBeDefined(); + }); + + it('renders with a custom start node', () => { + render( + + } testID={TEST_ID}> + Tag + + , + ); + expect(screen.getByTestId('custom-start')).toBeDefined(); + }); + + it('renders with a custom end node', () => { + render( + + } testID={TEST_ID}> + Tag + + , + ); + expect(screen.getByTestId('custom-end')).toBeDefined(); + }); + it('verifies tagColorMap maps correctly to tagEmphasisColorMap for backward compatibility', () => { expect(tagColorMap.informational).toEqual(tagEmphasisColorMap.low); expect(tagColorMap.promotional).toEqual(tagEmphasisColorMap.high); diff --git a/packages/mobile/src/themes/coinbaseDenseTheme.ts b/packages/mobile/src/themes/coinbaseDenseTheme.ts index 5d403123fa..efa7c5d06f 100644 --- a/packages/mobile/src/themes/coinbaseDenseTheme.ts +++ b/packages/mobile/src/themes/coinbaseDenseTheme.ts @@ -4,7 +4,10 @@ import { coinbaseTheme } from './coinbaseTheme'; export const coinbaseDenseThemeId = 'coinbase-dense'; -/** @deprecated This theme was created to test backwards compatibility, it is not officially supported by CDS. Please copy it into your own repo and modify it as needed. Do not import it directly from CDS. */ +/** + * @deprecated This theme was created to test backwards compatibility, it is not officially supported by CDS. Please copy it into your own repo and modify it as needed. Do not import it directly from CDS. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ export const coinbaseDenseTheme = { ...coinbaseTheme, id: coinbaseDenseThemeId, diff --git a/packages/mobile/src/tour/DefaultTourStepArrow.tsx b/packages/mobile/src/tour/DefaultTourStepArrow.tsx index a31f3a2560..c330dc9dac 100644 --- a/packages/mobile/src/tour/DefaultTourStepArrow.tsx +++ b/packages/mobile/src/tour/DefaultTourStepArrow.tsx @@ -1,15 +1,16 @@ import React, { forwardRef, memo, useMemo } from 'react'; -import type { View, ViewStyle } from 'react-native'; -import type { TourStepArrowComponentProps } from '@coinbase/cds-common/tour/useTour'; +import type { StyleProp, View, ViewStyle } from 'react-native'; import { Box } from '../layout/Box'; +import type { TourStepArrowComponentProps } from './Tour'; + export const DefaultTourStepArrow = memo( forwardRef(({ placement, arrow, style }, ref) => { const width = 24; const height = 24; const hideArrow = (arrow?.centerOffset ?? 0) > 0; - const arrowStyles: ViewStyle = useMemo(() => { + const arrowStyles: StyleProp = useMemo(() => { const arrowStyle: ViewStyle = { position: 'absolute', transform: 'rotate(45deg)', @@ -22,8 +23,9 @@ export const DefaultTourStepArrow = memo( if (placement.includes('bottom')) arrowStyle.top = 0.5 * -height; if (placement.includes('left')) arrowStyle.right = 0.5 * -width; if (placement.includes('right')) arrowStyle.left = 0.5 * -width; - return { ...arrowStyle, ...style }; + return [arrowStyle, style]; }, [arrow, placement, style, width, height, hideArrow]); + return ( & { + centerOffset: number; + alignmentOffset?: number; + }; + placement: Placement; + style?: StyleProp; +}; + +// ------------ SUBCOMPONENT TYPES ------------ +export type TourStepArrowComponent = React.ForwardRefExoticComponent< + TourStepArrowComponentProps & { ref?: React.Ref } +>; + export type TourMaskComponentProps = { /** * The active TourStep's target element. @@ -54,80 +68,111 @@ export type TourMaskComponentProps = { export type TourMaskComponent = React.FC; -export type TourProps = TourOptions & { - children?: React.ReactNode; - /** - * The Component to render as a tour overlay and mask. - * @default DefaultTourMask - */ - TourMaskComponent?: TourMaskComponent; - /** - * The default Component to render for each TourStep arrow element. - * @default DefaultTourStepArrow - */ - TourStepArrowComponent?: TourStepArrowComponent; - /** - * Hide overlay when tour is active - * @default false - */ - hideOverlay?: boolean; - /** - * Configures `@floating-ui` offset options for Tour Step component. See https://floating-ui.com/docs/offset. - */ - tourStepOffset?: OffsetOptions; - /** - * Configures `@floating-ui` autoPlacement options for Tour Step component. See https://floating-ui.com/docs/autoplacement. - * @default 24 - */ - tourStepAutoPlacement?: AutoPlacementOptions; - /** - * Configures `@floating-ui` shift options for Tour Step component. See https://floating-ui.com/docs/shift. - */ - tourStepShift?: ShiftOptions; - /** - * Padding to add around the edges of the TourMask's content mask. - */ - tourMaskPadding?: string | number; - /** - * Corner radius for the TourMask's content mask. Uses SVG rect element's `rx` and `ry` - * attributes https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx. - */ - tourMaskBorderRadius?: string | number; -} & Pick & - SharedProps; - -type TourFC = (props: TourProps) => React.ReactNode; - -const TourComponent = ({ - steps, - activeTourStep, - tourStepOffset = 24, - tourStepShift, - onChange, - TourMaskComponent = DefaultTourMask, - TourStepArrowComponent = DefaultTourStepArrow, - children, - hideOverlay, - tourMaskPadding, - tourMaskBorderRadius, - accessibilityLabel, - accessibilityLabelledBy, - id, - testID, -}: TourProps) => { +export type TourBaseProps = SharedProps & + TourOptions & + Pick & { + children?: React.ReactNode; + /** + * The Component to render as a tour overlay and mask. + * @default DefaultTourMask + */ + TourMaskComponent?: TourMaskComponent; + /** + * The default Component to render for each TourStep arrow element. + * @default DefaultTourStepArrow + */ + TourStepArrowComponent?: TourStepArrowComponent; + /** + * Hide overlay when tour is active + */ + hideOverlay?: boolean; + /** + * Configures `@floating-ui` offset options for Tour Step component. See https://floating-ui.com/docs/offset. + */ + tourStepOffset?: OffsetOptions; + /** + * Configures `@floating-ui` autoPlacement options for Tour Step component. See https://floating-ui.com/docs/autoplacement. + * @default 24 + */ + tourStepAutoPlacement?: AutoPlacementOptions; + /** + * Configures `@floating-ui` shift options for Tour Step component. See https://floating-ui.com/docs/shift. + */ + tourStepShift?: ShiftOptions; + /** + * Padding to add around the edges of the TourMask's content mask. + */ + tourMaskPadding?: string | number; + /** + * Corner radius for the TourMask's content mask. Uses SVG rect element's `rx` and `ry` + * attributes https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/rx. + */ + tourMaskBorderRadius?: string | number; + /** Custom styles for individual elements of the Tour component */ + styles?: { + /** Root element */ + root?: StyleProp; + /** The opaque overlay/mask that emphasizes current step */ + mask?: StyleProp; + /** A step's arrow element */ + stepArrow?: StyleProp; + /** A step element's positioned container */ + stepContainer?: StyleProp; + }; + }; + +export type TourProps = TourBaseProps; + +type TourFC = (props: TourProps) => React.ReactNode; + +const TourComponent = (_props: TourProps) => { + const mergedProps = useComponentConfig('Tour', _props); + const { + steps, + activeTourStep, + tourStepOffset = 24, + tourStepShift, + onChange, + TourMaskComponent = DefaultTourMask, + TourStepArrowComponent = DefaultTourStepArrow, + children, + hideOverlay, + tourMaskPadding, + tourMaskBorderRadius, + styles, + accessibilityLabel, + accessibilityLabelledBy, + id, + testID, + } = mergedProps; const theme = useTheme(); const defaultTourStepOffset = theme.space[3]; const defaultTourStepShiftPadding = theme.space[4]; const tourStepArrowRef = useRef(null); const RenderedTourStep = activeTourStep?.Component; - const RenderedTourStepArrow = activeTourStep?.ArrowComponent ?? TourStepArrowComponent; + // activeTourStep.ArrowComponent references old, deprecated type in cds-common + const RenderedTourStepArrow = + (activeTourStep?.ArrowComponent as TourStepArrowComponent) ?? TourStepArrowComponent; const [animation, animationApi] = useSpring( () => ({ from: { opacity: 0 }, config: springConfig.slow }), [], ); + // StyleSheet.flatten is needed because styles?.mask/stepContainer are StyleProp, + // which may be arrays. Unlike RN's Animated.View, react-spring's animated.View only accepts + // plain style objects, so we must flatten before merging with the spring animation values. + const maskStyles = useMemo( + () => ({ ...animation, ...StyleSheet.flatten(styles?.mask) }) as typeof animation, + [animation, styles?.mask], + ); + + const stepContainerStyles = useMemo( + () => ({ ...animation, ...StyleSheet.flatten(styles?.stepContainer) }) as typeof animation, + [animation, styles?.stepContainer], + ); + const { refs, floatingStyles, @@ -143,7 +188,7 @@ const TourComponent = ({ }); const handleChange = useCallback( - (tourStep: TourStepValue | null) => { + (tourStep: TourStepValue | null) => { void animationApi.start({ to: { opacity: 0 }, config: springConfig.stiff, @@ -155,7 +200,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 @@ -204,10 +249,11 @@ const TourComponent = ({ animationType="none" id={id} presentationStyle="overFullScreen" + style={styles?.root} testID={testID} > {!(activeTourStep.hideOverlay ?? hideOverlay) && !!activeTourStepTarget && ( - + ({ )} - + diff --git a/packages/mobile/src/tour/TourStep.tsx b/packages/mobile/src/tour/TourStep.tsx index af91763f0c..7ddaece703 100644 --- a/packages/mobile/src/tour/TourStep.tsx +++ b/packages/mobile/src/tour/TourStep.tsx @@ -1,11 +1,12 @@ import React, { useCallback } from 'react'; -import { View } from 'react-native'; +import { type StyleProp, View, type ViewStyle } from 'react-native'; import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; type TourStepProps = { /** The id of the corresponding tour step data */ id: string; children?: React.ReactNode; + style?: StyleProp; }; /** @@ -13,14 +14,14 @@ type TourStepProps = { * in the tour. The active tour step content will be positioned relative to the target element when it * is rendered. */ -export const TourStep = ({ id, children }: TourStepProps) => { +export const TourStep = ({ id, children, ...props }: TourStepProps) => { const { activeTourStep, setActiveTourStepTarget } = useTourContext(); const refCallback = useCallback( (ref: View) => activeTourStep?.id === id && ref && setActiveTourStepTarget(ref), [activeTourStep, id, setActiveTourStepTarget], ); return ( - + {children} ); diff --git a/packages/mobile/src/tour/__stories__/Tour.stories.tsx b/packages/mobile/src/tour/__stories__/Tour.stories.tsx index e5ba4a9a17..2b413a93d7 100644 --- a/packages/mobile/src/tour/__stories__/Tour.stories.tsx +++ b/packages/mobile/src/tour/__stories__/Tour.stories.tsx @@ -15,7 +15,7 @@ import { ProgressBar } from '../../visualizations'; import { Tour } from '../Tour'; import { TourStep } from '../TourStep'; -const TourExamples = ({ +const TourExamples = ({ step2Ref, step3Ref, step4Ref, @@ -24,7 +24,7 @@ const TourExamples = ({ step2Ref: React.RefObject; step3Ref: React.RefObject; step4Ref: React.RefObject; - ids: T[]; + ids: TourStepId[]; }) => { const { startTour } = useTourContext(); const handleClick = useCallback(() => startTour(), [startTour]); diff --git a/packages/mobile/src/tour/__tests__/Tour.test.tsx b/packages/mobile/src/tour/__tests__/Tour.test.tsx index 1d1ad49a01..e8976345c4 100644 --- a/packages/mobile/src/tour/__tests__/Tour.test.tsx +++ b/packages/mobile/src/tour/__tests__/Tour.test.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { Button, Text } from 'react-native'; +import { Button, Text, View } from 'react-native'; import { useTourContext } from '@coinbase/cds-common/tour/TourContext'; import { fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { DefaultThemeProvider } from '../../utils/testHelpers'; import { Tour, type TourProps } from '../Tour'; +import { TourStep } from '../TourStep'; const StepOne = () => { const { goNextTourStep } = useTourContext(); @@ -105,4 +106,42 @@ describe('Tour', () => { expect(screen.getByText('Step 2')).toBeTruthy(); }); + + describe('styles', () => { + it('applies styles.stepArrow to the arrow element', () => { + render( + + + , + ); + const arrowEl = screen.getByTestId('tour-step-arrow'); + expect(arrowEl).toHaveStyle({ backgroundColor: 'blue' }); + }); + + it('applies styles.stepContainer to the step container element', () => { + render( + + + , + ); + const stepContainerEl = screen.getByTestId('tour-step-container'); + expect(stepContainerEl).toHaveStyle({ borderRadius: 8 }); + }); + + it('applies styles.mask to the mask element', async () => { + render( + + + + + + + , + ); + await waitFor(() => { + const maskEl = screen.getByTestId('tour-mask'); + expect(maskEl).toHaveStyle({ backgroundColor: 'blue' }); + }); + }); + }); }); diff --git a/packages/mobile/src/typography/Link.tsx b/packages/mobile/src/typography/Link.tsx index 5c6b85bd81..325a8cfe17 100644 --- a/packages/mobile/src/typography/Link.tsx +++ b/packages/mobile/src/typography/Link.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback } from 'react'; import type { GestureResponderEvent } from 'react-native'; import type { SharedProps } from '@coinbase/cds-common'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useWebBrowserOpener } from '../hooks/useWebBrowserOpener'; import { Text, type TextBaseProps, type TextProps } from './Text'; @@ -41,8 +42,9 @@ export type LinkBaseProps = SharedProps & export type LinkProps = LinkBaseProps & TextProps; -export const Link = memo( - ({ +export const Link = memo((_props: LinkProps) => { + const mergedProps = useComponentConfig('Link', _props); + const { children, to, color = 'fgPrimary', @@ -55,38 +57,37 @@ export const Link = memo( accessibilityLabel, testID, ...props - }: LinkProps) => { - const openUrl = useWebBrowserOpener(); + } = mergedProps; + const openUrl = useWebBrowserOpener(); - const openUrlOnPress = useCallback( - (event: GestureResponderEvent) => { - onPress?.(event); - if (to === undefined) return; - void openUrl(to, { - forceOpenOutsideApp, - preventRedirectionIntoApp, - readerMode, - }); - }, - [openUrl, to, onPress, forceOpenOutsideApp, preventRedirectionIntoApp, readerMode], - ); + const openUrlOnPress = useCallback( + (event: GestureResponderEvent) => { + onPress?.(event); + if (to === undefined) return; + void openUrl(to, { + forceOpenOutsideApp, + preventRedirectionIntoApp, + readerMode, + }); + }, + [openUrl, to, onPress, forceOpenOutsideApp, preventRedirectionIntoApp, readerMode], + ); - return ( - - {children} - - ); - }, -); + return ( + + {children} + + ); +}); Link.displayName = 'Link'; diff --git a/packages/mobile/src/typography/Text.tsx b/packages/mobile/src/typography/Text.tsx index 090ed85c04..0a58d183b2 100644 --- a/packages/mobile/src/typography/Text.tsx +++ b/packages/mobile/src/typography/Text.tsx @@ -64,7 +64,10 @@ export type TextBaseProps = StyleProps & { dangerouslySetColor?: TextStyle['color']; /** @danger This is a migration escape hatch. It is not intended to be used normally. */ dangerouslySetBackground?: TextStyle['backgroundColor']; - /** @deprecated Do not use this prop. This is a migration escape hatch and will be removed in the next major version of CDS. */ + /** + * @deprecated Do not use this prop, it is a migration escape hatch. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + */ renderEmptyNode?: boolean; /** Used to locate this element in unit and end-to-end tests. */ testID?: string; diff --git a/packages/mobile/src/typography/__figma__/Link.figma.tsx b/packages/mobile/src/typography/__figma__/Link.figma.tsx index 5ac0f58002..ca23643d98 100644 --- a/packages/mobile/src/typography/__figma__/Link.figma.tsx +++ b/packages/mobile/src/typography/__figma__/Link.figma.tsx @@ -6,7 +6,7 @@ figma.connect( Link, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=324-14982&m=dev', { - imports: ["import { Link } from '@coinbase/cds-mobile/typography/Link';"], + imports: ["import { Link } from '@coinbase/cds-mobile/typography/Link'"], props: { children: figma.string('string'), color: figma.enum('variant', { diff --git a/packages/mobile/src/utils/__tests__/mergeComponentProps.test.ts b/packages/mobile/src/utils/__tests__/mergeComponentProps.test.ts new file mode 100644 index 0000000000..5a4ae02c9d --- /dev/null +++ b/packages/mobile/src/utils/__tests__/mergeComponentProps.test.ts @@ -0,0 +1,91 @@ +import { mergeComponentProps } from '../mergeComponentProps'; + +describe('mergeComponentProps (mobile)', () => { + describe('edge cases', () => { + it('returns source when target is undefined', () => { + const source = { variant: 'primary' }; + const result = mergeComponentProps(undefined, source); + expect(result).toBe(source); + }); + + it('returns target when source is undefined', () => { + const target = { variant: 'primary' }; + const result = mergeComponentProps(target, undefined); + expect(result).toBe(target); + }); + + it('returns source when both are undefined', () => { + const result = mergeComponentProps(undefined, undefined); + expect(result).toBeUndefined(); + }); + + it('handles empty objects', () => { + const result = mergeComponentProps({}, {}); + expect(result).toEqual({}); + }); + }); + + describe('override behavior', () => { + it('keeps BaseProps defaults while allowing local overrides', () => { + const target = { + compact: false, + variant: 'secondary', + height: 32, + font: 'headline', + }; + const source = { + compact: true, + variant: 'primary', + }; + const result = mergeComponentProps(target, source); + + expect(result).toEqual({ + compact: true, + variant: 'primary', + height: 32, + font: 'headline', + }); + }); + + it('overrides target with source props', () => { + const target = { variant: 'primary', size: 'm' }; + const source = { variant: 'secondary', compact: true }; + const result = mergeComponentProps(target, source); + + expect(result).toEqual({ + variant: 'secondary', + size: 'm', + compact: true, + }); + }); + + it('keeps target props when source does not provide values', () => { + const target = { variant: 'primary', size: 'm', disabled: true }; + const source = { variant: 'secondary' }; + const result = mergeComponentProps(target, source); + + expect(result).toEqual({ + variant: 'secondary', + size: 'm', + disabled: true, + }); + }); + + it('overrides style and styles instead of merging', () => { + const target = { + style: { color: 'red', fontSize: 14 }, + styles: { root: { color: 'red' }, label: { fontSize: 14 } }, + }; + const source = { + style: { color: 'blue' }, + styles: { root: { color: 'blue' } }, + }; + const result = mergeComponentProps(target, source); + + expect(result).toEqual({ + style: { color: 'blue' }, + styles: { root: { color: 'blue' } }, + }); + }); + }); +}); diff --git a/packages/mobile/src/utils/mergeComponentProps.ts b/packages/mobile/src/utils/mergeComponentProps.ts new file mode 100644 index 0000000000..49a82ac29a --- /dev/null +++ b/packages/mobile/src/utils/mergeComponentProps.ts @@ -0,0 +1,42 @@ +/** + * The result of merging two sets of props + */ +export type MergedProps = Source & Target; + +/** + * Merges two sets of component props where source overrides target. + * + * This merge is shallow by design and applies to any BaseProps keys, not only + * style-like props. This allows component config defaults such as `compact`, + * `variant`, `height`, and `font` to flow through alongside style props. + * + * @param target - Base set of props (e.g., from component config defaults) + * @param source - Overriding set of props (e.g., from local component props) + * @returns Merged props with source values taking precedence + * + * @example + * ```tsx + * const merged = mergeComponentProps( + * { compact: false, variant: 'secondary', height: 32, font: 'headline' }, + * { compact: true, variant: 'primary' } + * ); + * // Result: { + * // compact: true, // local override + * // variant: 'primary', // local override + * // height: 32, // preserved from defaults + * // font: 'headline' // preserved from defaults + * // } + * ``` + */ +export function mergeComponentProps< + Target extends Record, + Source extends Record, +>(target: Target | undefined, source: Source | undefined): MergedProps { + if (!target) return source as MergedProps; + if (!source) return target as MergedProps; + + return { + ...target, + ...source, + } as MergedProps; +} 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 1cbe6dc7f7..5787665d2d 100644 --- a/packages/mobile/src/visualizations/ProgressBar.tsx +++ b/packages/mobile/src/visualizations/ProgressBar.tsx @@ -9,11 +9,11 @@ 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 { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box, HStack } from '../layout'; import type { HintMotionBaseProps } from '../motion/types'; @@ -22,7 +22,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 * */ @@ -47,140 +47,121 @@ export type ProgressBaseProps = SharedProps & }; export type ProgressBarProps = ProgressBaseProps & { - /** - * Custom styles for the progress bar root. - */ style?: StyleProp; - /** - * Custom styles for the progress bar. - */ + /** Custom styles for individual elements of the ProgressBar component */ styles?: { - /** - * Custom styles for the progress bar root. - */ + /** Root element */ root?: StyleProp; - /** - * Custom styles for the progress bar. - */ + /** Progress fill element */ progress?: StyleProp; }; }; export const ProgressBar = memo( - forwardRef( - ( - { - weight = 'normal', - progress, - color = 'bgPrimary', - disabled, - disableAnimateOnMount, - testID, - accessibilityLabel, - style, - styles, - onAnimationEnd, - onAnimationStart, - }: ProgressBarProps, - forwardedRef: React.ForwardedRef, - ) => { - const theme = useTheme(); - const height = useProgressSize(weight); + forwardRef((_props: ProgressBarProps, forwardedRef: React.ForwardedRef) => { + const mergedProps = useComponentConfig('ProgressBar', _props); + const { + weight = 'normal', + progress = 0, + color = 'bgPrimary', + disabled, + disableAnimateOnMount, + testID, + accessibilityLabel, + style, + styles, + onAnimationEnd, + onAnimationStart, + } = mergedProps; + const theme = useTheme(); + const height = getProgressSize(weight); - const { getPreviousValue: getPreviousPercent, addPreviousValue: addPreviousPercent } = - usePreviousValues([disableAnimateOnMount ? progress : 0]); + const animatedProgress = useRef(new Animated.Value(disableAnimateOnMount ? progress : 0)); - addPreviousPercent(progress); - const previousPercent = getPreviousPercent() ?? 0; + const [trackWidth, setTrackWidth] = useState(-1); + useEffect(() => { + if (trackWidth > -1) { + onAnimationStart?.(); - const animatedProgress = useRef(new Animated.Value(previousPercent)); + Animated.timing( + animatedProgress.current, + convertMotionConfig({ + toValue: progress, + ...animateProgressBaseSpec, + useNativeDriver: true, + }), + )?.start(({ finished }) => { + if (finished) onAnimationEnd?.(); + }); + } + }, [progress, trackWidth, onAnimationEnd, onAnimationStart]); - const [innerWidth, setInnerWidth] = useState(-1); + const handleLayout = useCallback((event: LayoutChangeEvent) => { + setTrackWidth(event.nativeEvent.layout.width); + }, []); - useEffect(() => { - if (innerWidth > -1) { - onAnimationStart?.(); - - Animated.timing( - animatedProgress.current, - convertMotionConfig({ - toValue: progress, - ...animateProgressBaseSpec, - useNativeDriver: true, - }), - )?.start(({ finished }) => { - if (finished) onAnimationEnd?.(); - }); - } - }, [progress, animatedProgress, innerWidth, onAnimationStart, onAnimationEnd]); - - const handleLayout = useCallback((event: LayoutChangeEvent) => { - setInnerWidth(event.nativeEvent.layout.width); - }, []); - - const rootStyle = useMemo(() => { - const justifyContent = I18nManager.isRTL ? ('flex-end' as const) : ('flex-start' as const); - return [ - { - borderRadius: 200, - backgroundColor: theme.color.bgLine, - height, - overflow: 'hidden' as const, - alignItems: 'center' as const, - justifyContent, - }, - style, - styles?.root, - ]; - }, [style, styles?.root, theme.color.bgLine, height]); + const trackStyle = useMemo(() => { + const justifyContent = I18nManager.isRTL ? ('flex-end' as const) : ('flex-start' as const); + return [ + { + borderRadius: 200, + backgroundColor: theme.color.bgLine, + height, + overflow: 'hidden' as const, + alignItems: 'center' as const, + justifyContent, + }, + style, + styles?.root, + ]; + }, [style, styles?.root, theme.color.bgLine, height]); - const progressStyle = useMemo( - () => [ - { - opacity: innerWidth > -1 ? 1 : 0, - transform: [ - { - translateX: animatedProgress.current.interpolate({ - inputRange: [0, 1], - outputRange: I18nManager.isRTL ? [innerWidth, 0] : [innerWidth * -1, 0], - }), - }, - ], - }, - styles?.progress, - ], - [innerWidth, styles?.progress], - ); + const progressStyle = useMemo( + () => [ + { + opacity: trackWidth > -1 ? 1 : 0, + transform: [ + { + translateX: animatedProgress.current.interpolate({ + inputRange: [0, 1], + outputRange: I18nManager.isRTL ? [trackWidth, 0] : [-trackWidth, 0], + }), + }, + ], + }, + styles?.progress, + ], + [trackWidth, styles?.progress], + ); - return ( - + - - - ); - }, - ), + height="100%" + justifyContent="center" + style={progressStyle} + testID="cds-progress-bar" + width="100%" + /> + + ); + }), ); diff --git a/packages/mobile/src/visualizations/ProgressBarWithFixedLabels.tsx b/packages/mobile/src/visualizations/ProgressBarWithFixedLabels.tsx index 5367c88095..4507378102 100644 --- a/packages/mobile/src/visualizations/ProgressBarWithFixedLabels.tsx +++ b/packages/mobile/src/visualizations/ProgressBarWithFixedLabels.tsx @@ -2,14 +2,15 @@ import React, { memo, useMemo } from 'react'; import { I18nManager, type StyleProp, type TextStyle, View, type ViewStyle } from 'react-native'; import type { PaddingProps, Placement } from '@coinbase/cds-common/types'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, VStack } from '../layout'; import { getProgressBarLabelParts, type ProgressBarLabel } from './getProgressBarLabelParts'; -import { type ProgressBarProps } from './ProgressBar'; +import { type ProgressBarProps, type ProgressBaseProps } from './ProgressBar'; import { ProgressTextLabel } from './ProgressTextLabel'; -export type ProgressBarWithFixedLabelsProps = Pick< - ProgressBarProps, +export type ProgressBarWithFixedLabelsBaseProps = Pick< + ProgressBaseProps, 'disableAnimateOnMount' | 'disabled' | 'testID' > & { /** Label that is pinned to the start of the container. If a number is used then it will format it as a percentage. */ @@ -21,29 +22,19 @@ export type ProgressBarWithFixedLabelsProps = Pick< * @default beside * */ labelPlacement?: Extract; - /** - * Custom styles for the progress bar with fixed labels root. - */ +}; + +export type ProgressBarWithFixedLabelsProps = ProgressBarWithFixedLabelsBaseProps & { style?: StyleProp; - /** - * Custom styles for the progress bar with fixed labels. - */ + /** Custom styles for individual elements of the ProgressBarWithFixedLabels component */ styles?: { - /** - * Custom styles for the progress bar with fixed labels root. - */ + /** Root element */ root?: StyleProp; - /** - * Custom styles for the label container. - */ + /** Label container element */ labelContainer?: StyleProp; - /** - * Custom styles for the start label. - */ + /** Start label element */ startLabel?: StyleProp; - /** - * Custom styles for the end label. - */ + /** End label element */ endLabel?: StyleProp; }; }; @@ -178,8 +169,9 @@ const ProgressBarFixedLabelContainer = memo( export const ProgressBarWithFixedLabels: React.FC< React.PropsWithChildren -> = memo( - ({ +> = memo((_props: React.PropsWithChildren) => { + const mergedProps = useComponentConfig('ProgressBarWithFixedLabels', _props); + const { startLabel, endLabel, labelPlacement = 'beside', @@ -189,64 +181,63 @@ export const ProgressBarWithFixedLabels: React.FC< testID, style, styles, - }) => { - const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + } = mergedProps; + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - const startLabelEl = typeof startLabel !== 'undefined' && ( - - + + + ); + + const endLabelEl = typeof endLabel !== 'undefined' && ( + + + + ); + + const leftEl = I18nManager.isRTL ? endLabelEl : startLabelEl; + const rightEl = I18nManager.isRTL ? startLabelEl : endLabelEl; + + return ( + + {labelPlacement === 'above' && ( + + )} + + + {labelPlacement === 'beside' && leftEl} + {children} + {labelPlacement === 'beside' && rightEl} - ); - const endLabelEl = typeof endLabel !== 'undefined' && ( - - - - ); - - const leftEl = I18nManager.isRTL ? endLabelEl : startLabelEl; - const rightEl = I18nManager.isRTL ? startLabelEl : endLabelEl; - - return ( - - {labelPlacement === 'above' && ( - - )} - - - {labelPlacement === 'beside' && leftEl} - {children} - {labelPlacement === 'beside' && rightEl} - - - {labelPlacement === 'below' && ( - - )} - - ); - }, -); + )} + + ); +}); diff --git a/packages/mobile/src/visualizations/ProgressBarWithFloatLabel.tsx b/packages/mobile/src/visualizations/ProgressBarWithFloatLabel.tsx index 0461cadb4c..3944c4c791 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,10 +8,10 @@ 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'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useLayout } from '../hooks/useLayout'; import { Box, VStack } from '../layout'; @@ -19,6 +19,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 +33,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 ( @@ -129,7 +114,7 @@ const ProgressBarFloatLabel = memo( }, ); -export type ProgressBarWithFloatLabelProps = Pick< +export type ProgressBarWithFloatLabelBaseProps = Pick< ProgressBaseProps, 'progress' | 'disableAnimateOnMount' | 'disabled' | 'testID' > & { @@ -140,33 +125,26 @@ export type ProgressBarWithFloatLabelProps = Pick< * @default above * */ labelPlacement?: Extract; - /** - * Custom styles for the progress bar with float label root. - */ +}; + +export type ProgressBarWithFloatLabelProps = ProgressBarWithFloatLabelBaseProps & { style?: StyleProp; - /** - * Custom styles for the progress bar with float label. - */ + /** Custom styles for individual elements of the ProgressBarWithFloatLabel component */ styles?: { - /** - * Custom styles for the progress bar with float label root. - */ + /** Root element */ root?: StyleProp; - /** - * Custom styles for the label container. - */ + /** Label container element */ labelContainer?: StyleProp; - /** - * Custom styles for the label. - */ + /** Label element */ label?: StyleProp; }; }; export const ProgressBarWithFloatLabel: React.FC< React.PropsWithChildren -> = memo( - ({ +> = memo((_props: React.PropsWithChildren) => { + const mergedProps = useComponentConfig('ProgressBarWithFloatLabel', _props); + const { label, labelPlacement = 'above', progress, @@ -176,26 +154,25 @@ export const ProgressBarWithFloatLabel: React.FC< testID, style, styles, - }) => { - const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - - const progressBarFloatLabel = ( - - ); + } = mergedProps; + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - return ( - - {labelPlacement === 'above' && progressBarFloatLabel} - {children} - {labelPlacement === 'below' && progressBarFloatLabel} - - ); - }, -); + const progressBarFloatLabel = ( + + ); + + return ( + + {labelPlacement === 'above' && progressBarFloatLabel} + {children} + {labelPlacement === 'below' && progressBarFloatLabel} + + ); +}); diff --git a/packages/mobile/src/visualizations/ProgressCircle.tsx b/packages/mobile/src/visualizations/ProgressCircle.tsx index 3f5801ee40..0e35fced43 100644 --- a/packages/mobile/src/visualizations/ProgressCircle.tsx +++ b/packages/mobile/src/visualizations/ProgressCircle.tsx @@ -1,15 +1,16 @@ 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 { 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'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Box, type BoxProps } from '../layout'; @@ -22,6 +23,7 @@ import { type CircleType = React.ComponentClass; const AnimatedCircle = Animated.createAnimatedComponent(Circle as CircleType); +const AnimatedSvg = Animated.createAnimatedComponent(Svg); export type ProgressCircleBaseProps = ProgressBaseProps & { /** @@ -29,7 +31,8 @@ export type ProgressCircleBaseProps = ProgressBaseProps & { */ hideContent?: boolean; /** - * @deprecated Use hideContent instead + * @deprecated Use hideContent instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 * Toggle used to hide the text rendered inside the circle. */ hideText?: boolean; @@ -44,40 +47,27 @@ 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 & { - /** - * Custom styles for the progress circle root. - */ style?: StyleProp; - /** - * Custom styles for the progress circle. - */ + /** Custom styles for individual elements of the ProgressCircle component */ styles?: { - /** - * Custom styles for the progress circle root. - */ + /** Root element */ root?: StyleProp; - /** - * Custom styles for the progress circle svg container. - */ + /** SVG container element */ svgContainer?: StyleProp; - /** - * Custom styles for the progress circle svg. - */ + /** SVG element */ svg?: StyleProp; - /** - * Custom styles for the text container. - */ + /** Text container element */ textContainer?: StyleProp; - /** - * Custom styles for the progress circle inner. - */ + /** Foreground progress circle element */ progress?: Partial; - /** - * Custom styles for the progress circle inner. - */ + /** Background circle element */ circle?: Partial; }; }; @@ -96,26 +86,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); @@ -144,7 +134,7 @@ const ProgressCircleInner = memo( return ( 0 ? 'round' : 'butt'} @@ -161,54 +151,78 @@ const ProgressCircleInner = memo( ); export const ProgressCircle = memo( - forwardRef( - ( - { - weight = 'normal', - progress, - // Default is empty string due to iOS VoiceOver repeating percentage multiple times when - // a11y label isn't specified - accessibilityLabel = '', - color = 'bgPrimary', - disabled, - disableAnimateOnMount, - testID, - hideContent, - hideText, - size, - contentNode, - style, - styles, - onAnimationEnd, - onAnimationStart, - }: ProgressCircleProps, - forwardedRef: React.ForwardedRef, - ) => { - const theme = useTheme(); - const strokeWidth = useProgressSize(weight); + forwardRef((_props: ProgressCircleProps, forwardedRef: React.ForwardedRef) => { + const mergedProps = useComponentConfig('ProgressCircle', _props); + const { + indeterminate, + weight = 'normal', + progress = indeterminate ? 0.75 : 0, + // Default is empty string due to iOS VoiceOver repeating percentage multiple times when + // a11y label isn't specified + accessibilityLabel = indeterminate ? 'Loading' : '', + color = indeterminate ? 'fgMuted' : 'bgPrimary', + disabled, + disableAnimateOnMount = indeterminate ? true : false, + testID, + hideContent, + hideText, + size, + contentNode, + style, + styles, + onAnimationEnd, + onAnimationStart, + } = mergedProps; + const theme = useTheme(); + const strokeWidth = getProgressSize(weight); + + const visSize = size ?? '100%'; + + const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); - const visSize = size ?? '100%'; + const textContainerStyle = useMemo( + () => [{ padding: strokeWidth }, styles?.textContainer], + [strokeWidth, styles?.textContainer], + ); - const rootStyle = useMemo(() => [style, styles?.root], [style, styles?.root]); + const animatedRotate = useRef(new Animated.Value(0)); - const textContainerStyle = useMemo( - () => [{ padding: strokeWidth }, styles?.textContainer], - [strokeWidth, styles?.textContainer], + 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) => ( + return ( + + {({ width, height, circleSize }: VisualizationContainerDimension) => { + return ( - - - - - - - - {!hideText && !hideContent && ( - - {/* We clip the content node to the circle to prevent the node from overflowing over the circle */} - - {contentNode ?? ( + + + + + + {!hideText && !hideContent && ( + + {/* We clip the content node to the circle to prevent the node from overflowing over the circle */} + + {contentNode ?? + (!indeterminate && ( - )} - + ))} - )} - + + )} - )} - - ); - }, - ), + ); + }} + + ); + }), ); + +const styleSheet = StyleSheet.create({ + svg: { + flexGrow: 0, + flexShrink: 0, + }, +}); diff --git a/packages/mobile/src/visualizations/__figma__/ProgressBar.figma.tsx b/packages/mobile/src/visualizations/__figma__/ProgressBar.figma.tsx index 22df0e4a19..b7bc2e7504 100644 --- a/packages/mobile/src/visualizations/__figma__/ProgressBar.figma.tsx +++ b/packages/mobile/src/visualizations/__figma__/ProgressBar.figma.tsx @@ -10,8 +10,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=64-746&m=dev', { imports: [ - "import { ProgressBar } from '@coinbase/cds-mobile/visualizations/ProgressBar';", - "import { ProgressBarWithFloatLabel } from '@coinbase/cds-mobile/visualizations/ProgressBarWithFloatLabel';", + "import { ProgressBar } from '@coinbase/cds-mobile/visualizations/ProgressBar'", + "import { ProgressBarWithFloatLabel } from '@coinbase/cds-mobile/visualizations/ProgressBarWithFloatLabel'", ], props: { weight: figma.enum('weight', { @@ -56,8 +56,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=64-746&m=dev', { imports: [ - "import { ProgressBar } from '@coinbase/cds-mobile/visualizations/ProgressBar';", - "import { ProgressBarWithFloatLabel } from '@coinbase/cds-mobile/visualizations/ProgressBarWithFloatLabel';", + "import { ProgressBar } from '@coinbase/cds-mobile/visualizations/ProgressBar'", + "import { ProgressBarWithFloatLabel } from '@coinbase/cds-mobile/visualizations/ProgressBarWithFloatLabel'", ], props: { weight: figma.enum('weight', { diff --git a/packages/mobile/src/visualizations/__figma__/ProgressCircle.figma.tsx b/packages/mobile/src/visualizations/__figma__/ProgressCircle.figma.tsx index 4ef0e63fec..29b74ac486 100644 --- a/packages/mobile/src/visualizations/__figma__/ProgressCircle.figma.tsx +++ b/packages/mobile/src/visualizations/__figma__/ProgressCircle.figma.tsx @@ -8,7 +8,7 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=64-917&m=dev', { imports: [ - "import { ProgressCircle } from '@coinbase/cds-mobile/visualizations/ProgressCircle';", + "import { ProgressCircle } from '@coinbase/cds-mobile/visualizations/ProgressCircle'", ], props: { hideText: figma.boolean('progress label', { 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..d8db07d081 100644 --- a/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx +++ b/packages/mobile/src/visualizations/__tests__/ProgressBar.test.tsx @@ -94,7 +94,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(); diff --git a/packages/mobile/src/visualizations/__tests__/ProgressCircle.test.tsx b/packages/mobile/src/visualizations/__tests__/ProgressCircle.test.tsx index 5f7d69d268..8ea7eaaf53 100644 --- a/packages/mobile/src/visualizations/__tests__/ProgressCircle.test.tsx +++ b/packages/mobile/src/visualizations/__tests__/ProgressCircle.test.tsx @@ -250,4 +250,33 @@ describe('ProgressCircle tests and passes a11y', () => { // Without disableAnimateOnMount, should start at full circumference (empty) and animate to target expect(innerCircle.props.strokeDashoffset._value).toEqual(circumference); }); + + it('handles floating-point precision for accessibilityValue', () => { + const size = 100; + // 0.07 * 100 = 7.000000000000001 in JavaScript + render( + + + , + ); + + const progressCircle = screen.getByTestId('mock-progress-circle'); + 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/ui-mobile-playground/CHANGELOG.md b/packages/ui-mobile-playground/CHANGELOG.md index 500d13c380..4810d7961a 100644 --- a/packages/ui-mobile-playground/CHANGELOG.md +++ b/packages/ui-mobile-playground/CHANGELOG.md @@ -8,6 +8,108 @@ All notable changes to this project will be documented in this file. +## 4.19.0 (4/20/2026 PST) + +#### 🚀 Updates + +- Update icon svg map. [[#629](https://github.com/coinbase/cds/pull/629)] + +## 4.18.0 (4/13/2026 PST) + +#### 🚀 Updates + +- Add route for PercentageBarChart. [[#550](https://github.com/coinbase/cds/pull/550)] + +## 4.17.0 (4/9/2026 PST) + +#### 🚀 Updates + +- Update svg map for latest icon release. + +## 4.16.0 (3/30/2026 PST) + +#### 🚀 Updates + +- Add mobile component config route. [[#507](https://github.com/coinbase/cds/pull/507)] + +## 4.15.2 (3/27/2026 PST) + +#### 🐞 Fixes + +- Fit lint errors. [[#528](https://github.com/coinbase/cds/pull/528)] + +## 4.15.1 (3/26/2026 PST) + +#### 🐞 Fixes + +- Remove detox dependency. [[#517](https://github.com/coinbase/cds/pull/517)] + +## 4.15.0 (3/23/2026 PST) + +#### 🚀 Updates + +- Add custom modal padding route. [[#534](https://github.com/coinbase/cds/pull/534)] + +## 4.14.0 (3/18/2026 PST) + +#### 🚀 Updates + +- Add a route for new Calendar component. [[#139](https://github.com/coinbase/cds/pull/139)] + +## 4.13.0 (3/17/2026 PST) + +#### 🚀 Updates + +- Update svg map for new illustrations. [[#511](https://github.com/coinbase/cds/pull/511)] + +## 4.12.0 (3/11/2026 PST) + +#### 🚀 Updates + +- Update mobile routes. [[#492](https://github.com/coinbase/cds/pull/492)] + +## 4.11.0 (3/10/2026 PST) + +#### 🚀 Updates + +- Add new route for Fallback component. [[#388](https://github.com/coinbase/cds/pull/388)] + +## 4.10.0 (3/9/2026 PST) + +#### 🚀 Updates + +- Update icons. [[#486](https://github.com/coinbase/cds/pull/486)] + +## 4.9.0 (2/20/2026 PST) + +#### 🚀 Updates + +- Add new mobile routes. [[#400](https://github.com/coinbase/cds/pull/400)] + +## 4.8.0 (2/6/2026 PST) + +#### 🚀 Updates + +- Add new tray design. [[#349](https://github.com/coinbase/cds/pull/349)] + +## 4.7.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Added routes for MediaCard, MessagingCard, and alpha DataCard. [[#329](https://github.com/coinbase/cds/pull/329)] + +## 4.6.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Add new screen. [[#366](https://github.com/coinbase/cds/pull/366)] + +## 4.5.12 (1/13/2026 PST) + +#### 🐞 Fixes + +- Regenerate routes. [[#302](https://github.com/coinbase/cds/pull/302)] + ## 4.5.11 (12/22/2025 PST) #### 🐞 Fixes diff --git a/packages/ui-mobile-playground/package.json b/packages/ui-mobile-playground/package.json index 11511913e4..f2ef6ffae6 100644 --- a/packages/ui-mobile-playground/package.json +++ b/packages/ui-mobile-playground/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/ui-mobile-playground", - "version": "4.5.11", + "version": "4.19.0", "description": "Mobile UI Components in a Playground", "repository": { "type": "git", @@ -56,7 +56,6 @@ "@react-navigation/native": "^6.1.6", "@react-navigation/stack": "^6.3.16", "@types/react": "^18.3.12", - "detox": "^20.14.8", "react-native-safe-area-context": "4.10.5" } } diff --git a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts b/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts index 7202df2dbe..6fcfbcd76f 100644 --- a/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts +++ b/packages/ui-mobile-playground/src/__generated__/iconSvgMap.ts @@ -229,6 +229,12 @@ export const svgMap: Record = { '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: "" }, @@ -307,6 +313,12 @@ export const svgMap: Record = { 'baseFeed-16-inactive': { content: "" }, 'baseFeed-24-active': { content: "" }, 'baseFeed-24-inactive': { content: "" }, + 'baseLock-12-active': { content: "" }, + 'baseLock-12-inactive': { content: "" }, + 'baseLock-16-active': { content: "" }, + 'baseLock-16-inactive': { content: "" }, + 'baseLock-24-active': { content: "" }, + 'baseLock-24-inactive': { content: "" }, 'baseNotification-12-active': { content: "" }, 'baseNotification-12-inactive': { content: "" }, 'baseNotification-16-active': { content: "" }, @@ -379,6 +391,12 @@ export const svgMap: Record = { '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: "" }, @@ -907,6 +925,12 @@ export const svgMap: Record = { 'collection-16-inactive': { content: "" }, 'collection-24-active': { content: "" }, 'collection-24-inactive': { content: "" }, + 'column-12-active': { content: "" }, + 'column-12-inactive': { content: "" }, + 'column-16-active': { content: "" }, + 'column-16-inactive': { content: "" }, + 'column-24-active': { content: "" }, + 'column-24-inactive': { content: "" }, 'comment-12-active': { content: "" }, 'comment-12-inactive': { content: "" }, 'comment-16-active': { content: "" }, @@ -1393,6 +1417,12 @@ export const svgMap: Record = { 'filter-16-inactive': { content: "" }, 'filter-24-active': { content: "" }, 'filter-24-inactive': { content: "" }, + 'filterLineStack-12-active': { content: "" }, + 'filterLineStack-12-inactive': { content: "" }, + 'filterLineStack-16-active': { content: "" }, + 'filterLineStack-16-inactive': { content: "" }, + 'filterLineStack-24-active': { content: "" }, + 'filterLineStack-24-inactive': { content: "" }, 'fingerprint-12-active': { content: "" }, 'fingerprint-12-inactive': { content: "" }, 'fingerprint-16-active': { content: "" }, @@ -1699,12 +1729,12 @@ export const svgMap: Record = { 'hurricane-16-inactive': { content: "" }, 'hurricane-24-active': { content: "" }, 'hurricane-24-inactive': { content: "" }, - 'ideal-12-active': { content: "" }, - 'ideal-12-inactive': { content: "" }, - 'ideal-16-active': { content: "" }, - 'ideal-16-inactive': { content: "" }, - 'ideal-24-active': { content: "" }, - 'ideal-24-inactive': { content: "" }, + 'ideal-12-active': { content: "" }, + 'ideal-12-inactive': { content: "" }, + 'ideal-16-active': { content: "" }, + 'ideal-16-inactive': { content: "" }, + 'ideal-24-active': { content: "" }, + 'ideal-24-inactive': { content: "" }, 'identityCard-12-active': { content: "" }, 'identityCard-12-inactive': { content: "" }, 'identityCard-16-active': { content: "" }, @@ -2083,6 +2113,12 @@ export const svgMap: Record = { 'outline-16-inactive': { content: "" }, 'outline-24-active': { content: "" }, 'outline-24-inactive': { content: "" }, + 'overPredictions-12-active': { content: "" }, + 'overPredictions-12-inactive': { content: "" }, + 'overPredictions-16-active': { content: "" }, + 'overPredictions-16-inactive': { content: "" }, + 'overPredictions-24-active': { content: "" }, + 'overPredictions-24-inactive': { content: "" }, 'paperAirplane-12-active': { content: "" }, 'paperAirplane-12-inactive': { content: "" }, 'paperAirplane-16-active': { content: "" }, @@ -2161,12 +2197,12 @@ export const svgMap: Record = { '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: "" }, + '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: "" }, @@ -2209,6 +2245,12 @@ export const svgMap: Record = { 'pieChartData-16-inactive': { content: "" }, 'pieChartData-24-active': { content: "" }, 'pieChartData-24-inactive': { content: "" }, + 'pieChartWithArrow-12-active': { content: "" }, + 'pieChartWithArrow-12-inactive': { content: "" }, + 'pieChartWithArrow-16-active': { content: "" }, + 'pieChartWithArrow-16-inactive': { content: "" }, + 'pieChartWithArrow-24-active': { content: "" }, + 'pieChartWithArrow-24-inactive': { content: "" }, 'pillBottle-12-active': { content: "" }, 'pillBottle-12-inactive': { content: "" }, 'pillBottle-16-active': { content: "" }, @@ -2649,7 +2691,7 @@ export const svgMap: Record = { 'singleNote-24-inactive': { content: "" }, 'smartContract-12-active': { content: "" }, 'smartContract-12-inactive': { content: "" }, - 'smartContract-16-active': { content: "" }, + 'smartContract-16-active': { content: "" }, 'smartContract-16-inactive': { content: "" }, 'smartContract-24-active': { content: "" }, 'smartContract-24-inactive': { content: "" }, @@ -3043,6 +3085,12 @@ export const svgMap: Record = { 'umbrella-16-inactive': { content: "" }, 'umbrella-24-active': { content: "" }, 'umbrella-24-inactive': { content: "" }, + 'underPredictions-12-active': { content: "" }, + 'underPredictions-12-inactive': { content: "" }, + 'underPredictions-16-active': { content: "" }, + 'underPredictions-16-inactive': { content: "" }, + 'underPredictions-24-active': { content: "" }, + 'underPredictions-24-inactive': { content: "" }, 'undo-12-active': { content: "" }, 'undo-12-inactive': { content: "" }, 'undo-16-active': { content: "" }, @@ -3079,6 +3127,12 @@ export const svgMap: Record = { 'upload-16-inactive': { content: "" }, 'upload-24-active': { content: "" }, 'upload-24-inactive': { content: "" }, + 'usdc-12-active': { content: "" }, + 'usdc-12-inactive': { content: "" }, + 'usdc-16-active': { content: "" }, + 'usdc-16-inactive': { content: "" }, + 'usdc-24-active': { content: "" }, + 'usdc-24-inactive': { content: "" }, 'venturesProduct-12-active': { content: "" }, 'venturesProduct-12-inactive': { content: "" }, 'venturesProduct-16-active': { content: "" }, @@ -3145,6 +3199,12 @@ export const svgMap: Record = { '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: "" }, diff --git a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx b/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx index de61de4d25..a780a1499f 100644 --- a/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx +++ b/packages/ui-mobile-playground/src/components/ExamplesListScreen.tsx @@ -30,6 +30,7 @@ export function ExamplesListScreen() { return ( - + ) : showBackButton ? ( - + ) : ( iconButtonPlaceholder @@ -74,6 +85,7 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator @@ -98,7 +110,14 @@ export function useExampleNavigatorProps({ setColorScheme }: UseExampleNavigator label="" onChange={handleSearch} placeholder="Search" - start={} + start={ + + } value={searchFilter} /> ) : ( diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index d5d4c026bd..ac70f70b4d 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -121,6 +121,10 @@ export const routes = [ 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, @@ -142,9 +146,16 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartAccessibility', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartAccessibility.stories') + .default, + }, + { + key: 'ChartTransitions', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -170,6 +181,22 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/collapsible/__stories__/Collapsible.stories').default, }, + { + key: 'Combobox', + getComponent: () => + require('@coinbase/cds-mobile/alpha/combobox/__stories__/Combobox.stories').default, + }, + { + key: 'ComponentConfigProvider', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProvider.stories').default, + }, + { + key: 'ComponentConfigProviderCustom', + getComponent: () => + require('@coinbase/cds-mobile/system/__stories__/ComponentConfigProviderCustom.stories') + .default, + }, { key: 'ContainedAssetCard', getComponent: () => @@ -195,6 +222,11 @@ export const routes = [ 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, @@ -236,6 +268,11 @@ export const routes = [ 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: () => @@ -251,6 +288,10 @@ export const routes = [ 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: () => @@ -303,6 +344,11 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default, }, + { + key: 'Legend', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default, + }, { key: 'LinearGradient', getComponent: () => @@ -341,10 +387,19 @@ export const routes = [ 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: () => @@ -355,6 +410,11 @@ export const routes = [ 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: () => @@ -460,6 +520,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, }, + { + key: 'PercentageBarChart', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/PercentageBarChart.stories') + .default, + }, { key: 'PeriodSelector', getComponent: () => @@ -522,6 +588,12 @@ export const routes = [ getComponent: () => require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, }, + { + key: 'Scrubber', + getComponent: () => + require('@coinbase/cds-mobile-visualization/chart/scrubber/__stories__/Scrubber.stories') + .default, + }, { key: 'SearchInput', getComponent: () => @@ -805,6 +877,16 @@ export const routes = [ 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: () => diff --git a/packages/ui-mobile-visreg/CHANGELOG.md b/packages/ui-mobile-visreg/CHANGELOG.md deleted file mode 100644 index 0e81e5f3d5..0000000000 --- a/packages/ui-mobile-visreg/CHANGELOG.md +++ /dev/null @@ -1,13 +0,0 @@ -# @coinbase/ui-mobile-visreg - -> [NPM registry](https://www.npmjs.com/package/@coinbase/ui-mobile-visreg) - -All notable changes to this project will be documented in this file. - -`@coinbase/ui-mobile-visreg` adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - - - -## 4.1.0 (9/18/2025 PST) - -- Prepare for open source release. diff --git a/packages/ui-mobile-visreg/README.md b/packages/ui-mobile-visreg/README.md deleted file mode 100644 index 2f85f63b77..0000000000 --- a/packages/ui-mobile-visreg/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# @coinbase/ui-mobile-visreg - -This package contains the logic to utilize visreg in your CI. - -## Releasing UI Mobile Visreg - -1. Commit your changes & open a PR - -2. Bump the package version and update the changelog - -```shell -yarn bump-version ui-mobile-visreg -``` - -- When prompted, do the following: - - Type of change?: Select what makes the most sense - - Changelog message?: Short and sweet description :) - - PR number?: Copy/paste your PR number - - Skip the rest (press enter to use defaults) - -3. Commit and push the changes to your existing PR. Get reviews & merge. - - - -5. After the deploy has succeeded, verify that the new package was published at the [production Coinbase NPM registry](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master). It usually takes about 10 min or so for the package to be uploaded. Look for the version number at the bottom of the artifact list in the [package directory](https://npmjs.com/package/@coinbase/ui/repos/tree/General/cb-npm-master/@coinbase/ui-mobile-visreg/-/@coinbase/ui-mobile-visreg-1.0.0-rc.1.tgz). diff --git a/packages/ui-mobile-visreg/babel.config.cjs b/packages/ui-mobile-visreg/babel.config.cjs deleted file mode 100644 index bd2fee9a44..0000000000 --- a/packages/ui-mobile-visreg/babel.config.cjs +++ /dev/null @@ -1,22 +0,0 @@ -// @ts-check -const isTestEnv = process.env.NODE_ENV === 'test'; - -/** @type {import('@babel/core').TransformOptions} */ -module.exports = { - presets: [ - ['@babel/preset-env', { modules: isTestEnv ? 'commonjs' : false }], - ['@babel/preset-react', { runtime: 'automatic' }], - '@babel/preset-typescript', - ], - ignore: isTestEnv - ? [] - : [ - '**/__stories__/**', - '**/__tests__/**', - '**/__mocks__/**', - '**/__fixtures__/**', - '**/*.stories.*', - '**/*.test.*', - '**/*.spec.*', - ], -}; diff --git a/packages/ui-mobile-visreg/deploy.yml b/packages/ui-mobile-visreg/deploy.yml deleted file mode 100644 index 9ab4b9dd7a..0000000000 --- a/packages/ui-mobile-visreg/deploy.yml +++ /dev/null @@ -1,3 +0,0 @@ -engine: Node -build_name: package-ui-mobile-visreg -continuous: true diff --git a/packages/ui-mobile-visreg/docker-compose.yml b/packages/ui-mobile-visreg/docker-compose.yml deleted file mode 100644 index 6dff2491fd..0000000000 --- a/packages/ui-mobile-visreg/docker-compose.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: '3' -services: - app: - build: - context: ../../ - dockerfile: packages/ui-mobile-visreg/publish.Dockerfile diff --git a/packages/ui-mobile-visreg/package.json b/packages/ui-mobile-visreg/package.json deleted file mode 100644 index e0507343c6..0000000000 --- a/packages/ui-mobile-visreg/package.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "name": "@coinbase/ui-mobile-visreg", - "version": "4.1.0", - "private": true, - "description": "Visreg Helpers", - "repository": { - "type": "git", - "url": "git@github.com:coinbase/cds.git", - "directory": "packages/ui-mobile-visreg" - }, - "type": "module", - "main": "./esm/index.js", - "types": "./dts/index.d.ts", - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dts/index.d.ts", - "default": "./esm/index.js" - }, - "./detox": { - "types": "./dts/detox/index.d.ts", - "default": "./esm/detox/index.js" - }, - "./percy": { - "types": "./dts/percy/index.d.ts", - "default": "./esm/percy/index.js" - }, - "./*": { - "types": "./dts/*.d.ts", - "default": "./esm/*.js" - } - }, - "sideEffects": false, - "files": [ - "dts", - "esm", - "src", - "CHANGELOG" - ], - "peerDependencies": { - "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", - "detox": "^20.14.8" - }, - "dependencies": { - "@percy/cli": "^1.31.1", - "@percy/client": "^1.31.1", - "@percy/sdk-utils": "^1.31.1", - "lodash": "^4.17.21" - }, - "devDependencies": { - "@babel/core": "^7.28.0", - "@babel/preset-env": "^7.28.0", - "@babel/preset-react": "^7.27.1", - "@babel/preset-typescript": "^7.27.1", - "@coinbase/cds-mobile": "workspace:^", - "@coinbase/cds-mobile-visualization": "workspace:^", - "detox": "^20.14.8" - } -} diff --git a/packages/ui-mobile-visreg/project.json b/packages/ui-mobile-visreg/project.json deleted file mode 100644 index 017c1dd430..0000000000 --- a/packages/ui-mobile-visreg/project.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "ui-mobile-visreg", - "$schema": "../../node_modules/nx/schemas/project-schema.json", - "sourceRoot": "packages/ui-mobile-visreg/src", - "projectType": "library", - "targets": { - "build": { - "command": "rm -rf esm && babel ./src --out-dir esm --extensions .ts,.tsx,.js,.jsx --copy-files --no-copy-ignored" - }, - "lint": { - "executor": "@nx/eslint:lint" - }, - "typecheck": { - "executor": "nx:run-commands", - "defaultConfiguration": "dev", - "configurations": { - "dev": { - "command": "tsc --build --pretty --verbose" - }, - "prod": { - "command": "tsc --build ./tsconfig.build.json --pretty --verbose" - } - } - } - } -} diff --git a/packages/ui-mobile-visreg/publish.Dockerfile b/packages/ui-mobile-visreg/publish.Dockerfile deleted file mode 100644 index 38bf4b7cc4..0000000000 --- a/packages/ui-mobile-visreg/publish.Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -FROM 652969937640.dkr.ecr.us-east-1.amazonaws.com/containers/node:v22-ub22 - -RUN apt-get update && apt-get install - -WORKDIR /repo - -COPY . . - -# Install dependencies -RUN yarn --immutable - -# Build the package with nx -RUN yarn nx run ui-mobile-visreg:typecheck:prod -RUN yarn nx run ui-mobile-visreg:build - -# Prepare the package for publish -RUN cd /repo/packages/ui-mobile-visreg && yarn pack -RUN mv /repo/packages/ui-mobile-visreg /shared - -WORKDIR /shared diff --git a/packages/ui-mobile-visreg/src/constants.ts b/packages/ui-mobile-visreg/src/constants.ts deleted file mode 100644 index d0386ab1f8..0000000000 --- a/packages/ui-mobile-visreg/src/constants.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const iosDeviceScrolDistance = 700; -// Detox only allows for deterministic swipe, while ios works with scroll distance. -export const androidDeviceSwipeOffset = 1; - -export const baseDir = '../../artifacts'; -export const playgroundDir = 'playground-screenshots'; - -export const homeScreen = 'mobile-playground-home-screen'; -export const homeFlatList = 'mobile-playground-home-flatlist'; -export const screen = 'mobile-playground-screen'; -export const scrollView = 'mobile-playground-scrollview'; -export const scrollViewEnd = 'mobile-playground-scrollview-end'; diff --git a/packages/ui-mobile-visreg/src/detox/getDevicePlatform.ts b/packages/ui-mobile-visreg/src/detox/getDevicePlatform.ts deleted file mode 100644 index bd01da84ff..0000000000 --- a/packages/ui-mobile-visreg/src/detox/getDevicePlatform.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// - -export default function getDevicePlatform() { - return device.getPlatform(); -} diff --git a/packages/ui-mobile-visreg/src/detox/index.ts b/packages/ui-mobile-visreg/src/detox/index.ts deleted file mode 100644 index b7e30d5d37..0000000000 --- a/packages/ui-mobile-visreg/src/detox/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { default as getDevicePlatform } from './getDevicePlatform'; -export { default as navigateToHome } from './navigateToHome'; -export { default as navigateToRoute } from './navigateToRoute'; -export { default as takeElementScreenshot } from './takeElementScreenshot'; -export { default as takeRouteScreenshots } from './takeRouteScreenshots'; -export { findElementById, screenShouldAppear, screenShouldExist, sleep } from './utils'; diff --git a/packages/ui-mobile-visreg/src/detox/navigateToHome.ts b/packages/ui-mobile-visreg/src/detox/navigateToHome.ts deleted file mode 100644 index f4d9ce84dc..0000000000 --- a/packages/ui-mobile-visreg/src/detox/navigateToHome.ts +++ /dev/null @@ -1,10 +0,0 @@ -/// - -import { homeScreen } from '../constants'; - -import { screenShouldAppear } from './utils'; - -export default async function navigateToHome(routeName: string) { - await device.openURL({ url: routeName }); - await screenShouldAppear(homeScreen); -} diff --git a/packages/ui-mobile-visreg/src/detox/navigateToRoute.ts b/packages/ui-mobile-visreg/src/detox/navigateToRoute.ts deleted file mode 100644 index 43c7719c4e..0000000000 --- a/packages/ui-mobile-visreg/src/detox/navigateToRoute.ts +++ /dev/null @@ -1,11 +0,0 @@ -/// - -import { screen } from '../constants'; - -import { screenShouldExist } from './utils'; - -export default async function navigateToRoute(routeName: string) { - await device.openURL({ url: routeName }); - // can't rely on screenShouldAppear (expect.toBeVisible()) because modals overlay the screen - await screenShouldExist(screen); -} diff --git a/packages/ui-mobile-visreg/src/detox/takeElementScreenshot.ts b/packages/ui-mobile-visreg/src/detox/takeElementScreenshot.ts deleted file mode 100644 index 157c143339..0000000000 --- a/packages/ui-mobile-visreg/src/detox/takeElementScreenshot.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { findElementById } from './utils'; - -export default async function takeElementScreenshot(elementId: string) { - // passing the file name into element level takeScreenshot causes detox to lose track of the image, - // but setting a file name isn't required for this use case - const tempFilePath = await findElementById(elementId).takeScreenshot(''); - - return tempFilePath; -} diff --git a/packages/ui-mobile-visreg/src/detox/takeRouteScreenshots.ts b/packages/ui-mobile-visreg/src/detox/takeRouteScreenshots.ts deleted file mode 100644 index d3aa31b314..0000000000 --- a/packages/ui-mobile-visreg/src/detox/takeRouteScreenshots.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - androidDeviceSwipeOffset, - iosDeviceScrolDistance, - screen, - scrollView, - scrollViewEnd, -} from '../constants'; - -import getDevicePlatform from './getDevicePlatform'; -import { findElementById, sleep } from './utils'; - -async function scrollToEnd() { - try { - await expect(findElementById(scrollViewEnd)).not.toBeVisible(); - - // detox scroll on Android and swipe on iOS are not deterministic, - // so we need to maintain a different method for each device - if (getDevicePlatform() === 'ios') { - await findElementById(scrollView).scroll(iosDeviceScrolDistance, 'down'); - } else { - await findElementById(scrollView).swipe('up', 'slow', androidDeviceSwipeOffset); - } - } catch { - return true; - } - - return false; -} - -export default async function takeRouteScreenshots( - fullDirPath: string, - routeName: string, - takeScreenshotCb: ( - dirPath: string, - testName: string, - options?: { - elementId?: string; - filenamePrefix?: string | number; - }, - ) => Promise, - options: { takeScreenLevelScreenshots?: boolean } = {}, -) { - let atEnd = false; - let count = 0; - - while (!atEnd) { - await sleep(1000); - await takeScreenshotCb(fullDirPath, routeName, { - elementId: options.takeScreenLevelScreenshots ? screen : undefined, - filenamePrefix: count, - }); - atEnd = await scrollToEnd(); - count += 1; - } -} diff --git a/packages/ui-mobile-visreg/src/detox/utils.ts b/packages/ui-mobile-visreg/src/detox/utils.ts deleted file mode 100644 index 1d872e9722..0000000000 --- a/packages/ui-mobile-visreg/src/detox/utils.ts +++ /dev/null @@ -1,34 +0,0 @@ -/// - -/** - * Inline implementations of detox utilities to replace @coinbase/detox-utils dependency - */ - -/** - * Wait for a screen element to appear (visible) - */ -export async function screenShouldAppear(elementId: string): Promise { - await expect(element(by.id(elementId))).toBeVisible(); -} - -/** - * Check that a screen element exists (doesn't need to be visible) - */ -export async function screenShouldExist(elementId: string): Promise { - // @ts-expect-error toExist() does not exist in jest-dom Matchers - await expect(element(by.id(elementId))).toExist(); -} - -/** - * Find an element by its ID - */ -export function findElementById(elementId: string): Detox.IndexableNativeElement { - return element(by.id(elementId)); -} - -/** - * Sleep for the specified number of milliseconds - */ -export async function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} diff --git a/packages/ui-mobile-visreg/src/getPlaygroundRoutes.ts b/packages/ui-mobile-visreg/src/getPlaygroundRoutes.ts deleted file mode 100644 index 38adff5034..0000000000 --- a/packages/ui-mobile-visreg/src/getPlaygroundRoutes.ts +++ /dev/null @@ -1,38 +0,0 @@ -/// - -// https://buildkite.com/docs/tutorials/parallel-builds -// BUILDKITE_PARALLEL_JOB starts at zero based index and VISREG_JOB_NUMBER expects 1 based index -const TOTAL_JOBS = Number(process.env.BUILDKITE_PARALLEL_JOB_COUNT ?? 1); -const JOB_INDEX = Number(process.env.BUILDKITE_PARALLEL_JOB ?? 0); - -/** - * Gets non-blacklisted route names from the Mobile Playground - */ -export function getPlaygroundRoutes({ - routes, - disabledRoutes = [], - iosDisabledRoutes = [], - androidDisabledRoutes = [], -}: { - routes: { - key: string; - getComponent: () => unknown; - }[]; - disabledRoutes: string[]; - iosDisabledRoutes: string[]; - androidDisabledRoutes: string[]; -}) { - const routeNames = routes - .filter(({ key }) => !disabledRoutes.includes(key)) /** Remove issue routes */ - .filter( - ({ key }) => - !(device.getPlatform() === 'ios' ? iosDisabledRoutes : androidDisabledRoutes).includes(key), - ) /** Remove device issue routes */ - .map((route) => route.key); - - const jobSize = Math.ceil(routeNames.length / TOTAL_JOBS); - const startIndex = JOB_INDEX * jobSize; - const endIndex = startIndex + jobSize; - - return routeNames.slice(startIndex, endIndex); -} diff --git a/packages/ui-mobile-visreg/src/index.ts b/packages/ui-mobile-visreg/src/index.ts deleted file mode 100644 index 3cbbdcc5d1..0000000000 --- a/packages/ui-mobile-visreg/src/index.ts +++ /dev/null @@ -1,128 +0,0 @@ -/// - -import { execSync } from 'node:child_process'; - -import type { PercyScreenshotOptions } from './percy/processScreenshots'; -import processScreenshots from './percy/processScreenshots'; -import { baseDir, playgroundDir } from './constants'; -import { - getDevicePlatform, - takeElementScreenshot, - takeRouteScreenshots as takeDetoxRouteScreenshots, -} from './detox'; -import { ensureDirExists, removeAllFilesFromDir } from './utils'; - -async function takeScreenshot( - dirPath: string, - testName: string, - options: { - elementId?: string; - filenamePrefix?: string | number; - } = {}, -) { - const { elementId, filenamePrefix } = options; - const prefix = filenamePrefix !== undefined ? `${filenamePrefix}_` : ''; - const filename = `${prefix}${testName}-${getDevicePlatform()}`; - const filenameWithExtension = `${filename}.png`; - const filePath = `${dirPath}/${filenameWithExtension}`; - - // if elementId provided, take an element level screenshot, otherwise take a device level screenshot - const tempFilePath = elementId - ? await takeElementScreenshot(elementId) - : await device.takeScreenshot(filename); - - ensureDirExists(filePath); - execSync(`mv ${tempFilePath} ${filePath}`); -} - -async function takeRouteScreenshots( - dirPath: string, - routeName: string, - options: { takeScreenLevelScreenshots?: boolean } = {}, -) { - const fullDirPath = `${dirPath}/${routeName}`; - - await takeDetoxRouteScreenshots(fullDirPath, routeName, takeScreenshot, options); - return fullDirPath; -} - -/** - * Takes all screenshots for a given playground route. Once it has taken all screenshots - * for the route, this method uploads the screenshots to percy. - * - * @param {string} routeName Name of the route that will be included in the screenshot file name (e.g. 0_-ios.png). - * The sdk will generate a set of route names leveraging the routes in the Mobile Playground. - * @param {Object} [options] Options to configure screenshot captures. - * @param {boolean} [options.takeScreenLevelScreenshots] If true, takes screenshots of the screen component and its children - * instead of the entire device. This can be useful to avoid capturing external noise like the device status or navigation - * bars, but cannot reliably capture modals as they operate outside the regular view hierarchy of the app. - */ -export async function uploadScreenshotsToPercyForRoute( - routeName: string, - options: { takeScreenLevelScreenshots?: boolean } = {}, -) { - const parentDir = `${baseDir}/${playgroundDir}`; - const screenshotsDir = await takeRouteScreenshots(parentDir, routeName, options); - - processScreenshots(screenshotsDir, { parallelPercy: true }); -} - -/** - * Takes one screenshot of a single component and its children on the current screen. This can take a full-screen - * screenshot if the provided componentId is associated to a component that wraps the entire app or screen and - * can be useful to avoid capturing external noise like the device status or navigation bars. Howevever, - * this method cannot reliably capture modals as they often operate outside the regular view hierarchy of the app. - * - * @param {string} testName Name of the test that will be included in the screenshot file name (e.g. -ios.png). - * @param {string} componentId The React Native testID for the component to be screenshot. - * @param {Object} [options] Options to configure screenshot captures. - * @param {string} [options.screenshotDir] Path to artifacts subdirectory where screenshot will be saved. If not - * provided, the screenshot will be saved to the base artifacts directory. - * @param {(string|number)} [options.filenamePrefix] Prefix to attach to the screenshot file name. - */ -export async function takeComponentScreenshot( - testName: string, - componentId: string, - options: { - screenshotDir?: string; - filenamePrefix?: string | number; - } = {}, -) { - const screenshotDir = options.screenshotDir ? `/${options.screenshotDir}` : ''; - const fullDirPath = `${baseDir}${screenshotDir}`; - - await takeScreenshot(fullDirPath, testName, { - elementId: componentId, - filenamePrefix: options.filenamePrefix, - }); -} - -/** - * Processes and uploads all stored screenshots in a directory for visual diffing. - * - * @param {Object} [options] Options to configure screenshot uploads. - * @param {string} [options.screenshotDir] Path to artifacts subdirectory where screenshots to be uploaded are stored. - * If not provided, the processor will look for screenshots in the base artifacts directory. - * @param {Object} [options.processingOptions] Options to configure underlying screenshot processor. - */ -export function processScreenshotsForVisualDiffs(options: { - screenshotsDir?: string; - processingOptions?: PercyScreenshotOptions; -}) { - const screenshotsDir = options.screenshotsDir ? `/${options.screenshotsDir}` : ''; - const fullDirPath = `${baseDir}${screenshotsDir}`; - - processScreenshots(fullDirPath, options.processingOptions ?? {}); -} - -/** - * Removes the artifacts directory and takes care of all other required clean up. This can be run after each test case - * or after all tests are run depending on the context. - */ -export function finishVisregTests() { - removeAllFilesFromDir(baseDir); -} - -export * from './detox'; -export { getPlaygroundRoutes } from './getPlaygroundRoutes'; -export * from './routes'; diff --git a/packages/ui-mobile-visreg/src/percy/index.ts b/packages/ui-mobile-visreg/src/percy/index.ts deleted file mode 100644 index 85a2673088..0000000000 --- a/packages/ui-mobile-visreg/src/percy/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as processScreenshots } from './processScreenshots'; diff --git a/packages/ui-mobile-visreg/src/percy/processScreenshots.ts b/packages/ui-mobile-visreg/src/percy/processScreenshots.ts deleted file mode 100644 index e36f33b553..0000000000 --- a/packages/ui-mobile-visreg/src/percy/processScreenshots.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { runCmd } from '../utils'; - -export type PercyScreenshotOptions = { - skipPercyUpload?: boolean; - parallelPercy?: boolean; -}; - -function uploadImages(dirPath: string, parallelPercy = false) { - const percyUploadCmd = `percy upload -c ./.percy.yml -f "./*.{png,jpg,jpeg}" ${dirPath}`; - const cmd = parallelPercy ? `percy exec --parallel -- ${percyUploadCmd}` : percyUploadCmd; - runCmd(cmd); -} - -export default function processScreenshots( - dirPath: string, - { skipPercyUpload = false, parallelPercy = false }: PercyScreenshotOptions, -) { - if (!skipPercyUpload) { - uploadImages(dirPath, parallelPercy); - } -} diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts deleted file mode 100644 index d5d4c026bd..0000000000 --- a/packages/ui-mobile-visreg/src/routes.ts +++ /dev/null @@ -1,828 +0,0 @@ -/** - * DO NOT MODIFY - * Generated from scripts/codegen/main.ts - */ -export const routes = [ - { - key: 'Accordion', - getComponent: () => - require('@coinbase/cds-mobile/accordion/__stories__/Accordion.stories').default, - }, - { - key: 'AlertBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertBasic.stories').default, - }, - { - key: 'AlertLongTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertLongTitle.stories').default, - }, - { - key: 'AlertOverModal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertOverModal.stories').default, - }, - { - key: 'AlertPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertPortal.stories').default, - }, - { - key: 'AlertSingleAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertSingleAction.stories').default, - }, - { - key: 'AlertVerticalActions', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/AlertVerticalActions.stories').default, - }, - { - key: 'AlphaSelect', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select/__stories__/AlphaSelect.stories').default, - }, - { - key: 'AlphaSelectChip', - getComponent: () => - require('@coinbase/cds-mobile/alpha/select-chip/__stories__/AlphaSelectChip.stories').default, - }, - { - key: 'AlphaTabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/alpha/tabbed-chips/__stories__/AlphaTabbedChips.stories') - .default, - }, - { - key: 'AnimatedCaret', - getComponent: () => - require('@coinbase/cds-mobile/motion/__stories__/AnimatedCaret.stories').default, - }, - { - key: 'AreaChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/area/__stories__/AreaChart.stories') - .default, - }, - { - key: 'Avatar', - getComponent: () => require('@coinbase/cds-mobile/media/__stories__/Avatar.stories').default, - }, - { - key: 'AvatarButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/AvatarButton.stories').default, - }, - { - key: 'Axis', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/axis/__stories__/Axis.stories').default, - }, - { - key: 'Banner', - getComponent: () => require('@coinbase/cds-mobile/banner/__stories__/Banner.stories').default, - }, - { - key: 'BannerActions', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerActions.stories').default, - }, - { - key: 'BannerLayout', - getComponent: () => - require('@coinbase/cds-mobile/banner/__stories__/BannerLayout.stories').default, - }, - { - key: 'BarChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/bar/__stories__/BarChart.stories').default, - }, - { - key: 'Box', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Box.stories').default, - }, - { - key: 'BrowserBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBar.stories').default, - }, - { - key: 'BrowserBarSearchInput', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/BrowserBarSearchInput.stories').default, - }, - { - key: 'Button', - getComponent: () => require('@coinbase/cds-mobile/buttons/__stories__/Button.stories').default, - }, - { - key: 'ButtonGroup', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/ButtonGroup.stories').default, - }, - { - key: 'Card', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/Card.stories').default, - }, - { - key: 'Carousel', - getComponent: () => - require('@coinbase/cds-mobile/carousel/__stories__/Carousel.stories').default, - }, - { - key: 'CarouselMedia', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/CarouselMedia.stories').default, - }, - { - key: 'CartesianChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/CartesianChart.stories') - .default, - }, - { - key: 'Chart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.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: '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: '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: '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: '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: 'LinearGradient', - getComponent: () => - require('@coinbase/cds-mobile/gradients/__stories__/LinearGradient.stories').default, - }, - { - key: 'LineChart', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/LineChart.stories') - .default, - }, - { - key: 'Link', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Link.stories').default, - }, - { - key: 'ListCell', - getComponent: () => require('@coinbase/cds-mobile/cells/__stories__/ListCell.stories').default, - }, - { - key: 'ListCellFallback', - getComponent: () => - require('@coinbase/cds-mobile/cells/__stories__/ListCellFallback.stories').default, - }, - { - key: 'Logo', - getComponent: () => require('@coinbase/cds-mobile/icons/__stories__/Logo.stories').default, - }, - { - key: 'Lottie', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/Lottie.stories').default, - }, - { - key: 'LottieStatusAnimation', - getComponent: () => - require('@coinbase/cds-mobile/animation/__stories__/LottieStatusAnimation.stories').default, - }, - { - key: 'MediaChip', - getComponent: () => require('@coinbase/cds-mobile/chips/__stories__/MediaChip.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: 'ModalLong', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalLong.stories').default, - }, - { - key: 'ModalPortal', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/ModalPortal.stories').default, - }, - { - key: 'MultiContentModule', - getComponent: () => - require('@coinbase/cds-mobile/multi-content-module/__stories__/MultiContentModule.stories') - .default, - }, - { - key: 'NavBarIconButton', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavBarIconButton.stories').default, - }, - { - key: 'NavigationSubtitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationSubtitle.stories').default, - }, - { - key: 'NavigationTitle', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitle.stories').default, - }, - { - key: 'NavigationTitleSelect', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/NavigationTitleSelect.stories').default, - }, - { - key: 'NudgeCard', - getComponent: () => require('@coinbase/cds-mobile/cards/__stories__/NudgeCard.stories').default, - }, - { - key: 'Numpad', - getComponent: () => require('@coinbase/cds-mobile/numpad/__stories__/Numpad.stories').default, - }, - { - key: 'Overlay', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/Overlay.stories').default, - }, - { - key: 'PageFooter', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageFooter.stories').default, - }, - { - key: 'PageFooterInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageFooterInPage.stories').default, - }, - { - key: 'PageHeader', - getComponent: () => require('@coinbase/cds-mobile/page/__stories__/PageHeader.stories').default, - }, - { - key: 'PageHeaderInErrorEmptyState', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInErrorEmptyState.stories').default, - }, - { - key: 'PageHeaderInPage', - getComponent: () => - require('@coinbase/cds-mobile/page/__stories__/PageHeaderInPage.stories').default, - }, - { - key: 'Palette', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Palette.stories').default, - }, - { - key: 'PatternDisclosureHighFrictionBenefit', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionBenefit.stories') - .default, - }, - { - key: 'PatternDisclosureHighFrictionRisk', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureHighFrictionRisk.stories') - .default, - }, - { - key: 'PatternDisclosureLowFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureLowFriction.stories') - .default, - }, - { - key: 'PatternDisclosureMedFriction', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternDisclosureMedFriction.stories') - .default, - }, - { - key: 'PatternError', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PatternError.stories').default, - }, - { - key: 'PeriodSelector', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/PeriodSelector.stories') - .default, - }, - { - key: 'Pictogram', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/Pictogram.stories').default, - }, - { - key: 'Pressable', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/Pressable.stories').default, - }, - { - key: 'PressableOpacity', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/PressableOpacity.stories').default, - }, - { - key: 'ProgressBar', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressBar.stories').default, - }, - { - key: 'ProgressCircle', - getComponent: () => - require('@coinbase/cds-mobile/visualizations/__stories__/ProgressCircle.stories').default, - }, - { - key: 'RadioCell', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioCell.stories').default, - }, - { - key: 'RadioGroup', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/RadioGroup.stories').default, - }, - { - key: 'ReferenceLine', - getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/line/__stories__/ReferenceLine.stories') - .default, - }, - { - key: 'RemoteImage', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImage.stories').default, - }, - { - key: 'RemoteImageGroup', - getComponent: () => - require('@coinbase/cds-mobile/media/__stories__/RemoteImageGroup.stories').default, - }, - { - key: 'RollingNumber', - getComponent: () => - require('@coinbase/cds-mobile/numbers/__stories__/RollingNumber.stories').default, - }, - { - key: 'SearchInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SearchInput.stories').default, - }, - { - key: 'SectionHeader', - getComponent: () => - require('@coinbase/cds-mobile/section-header/__stories__/SectionHeader.stories').default, - }, - { - key: 'SegmentedTabs', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/SegmentedTabs.stories').default, - }, - { - key: 'Select', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Select.stories').default, - }, - { - key: 'SelectChip', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/SelectChip.stories').default, - }, - { - key: 'SelectOption', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/SelectOption.stories').default, - }, - { - key: 'SlideButton', - getComponent: () => - require('@coinbase/cds-mobile/buttons/__stories__/SlideButton.stories').default, - }, - { - key: 'Spacer', - getComponent: () => require('@coinbase/cds-mobile/layout/__stories__/Spacer.stories').default, - }, - { - key: 'Sparkline', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/Sparkline.stories').default, - }, - { - key: 'SparklineGradient', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/__stories__/SparklineGradient.stories') - .default, - }, - { - key: 'SparklineInteractive', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories') - .default, - }, - { - key: 'SparklineInteractiveHeader', - getComponent: () => - require('@coinbase/cds-mobile-visualization/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories') - .default, - }, - { - key: 'Spectrum', - getComponent: () => require('@coinbase/cds-mobile/system/__stories__/Spectrum.stories').default, - }, - { - key: 'Spinner', - getComponent: () => require('@coinbase/cds-mobile/loaders/__stories__/Spinner.stories').default, - }, - { - key: 'SpotIcon', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotIcon.stories').default, - }, - { - key: 'SpotRectangle', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotRectangle.stories').default, - }, - { - key: 'SpotSquare', - getComponent: () => - require('@coinbase/cds-mobile/illustrations/__stories__/SpotSquare.stories').default, - }, - { - key: 'StepperHorizontal', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperHorizontal.stories').default, - }, - { - key: 'StepperVertical', - getComponent: () => - require('@coinbase/cds-mobile/stepper/__stories__/StepperVertical.stories').default, - }, - { - key: 'StickyFooter', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooter.stories').default, - }, - { - key: 'StickyFooterWithTray', - getComponent: () => - require('@coinbase/cds-mobile/sticky-footer/__stories__/StickyFooterWithTray.stories') - .default, - }, - { - key: 'Switch', - getComponent: () => require('@coinbase/cds-mobile/controls/__stories__/Switch.stories').default, - }, - { - key: 'TabbedChips', - getComponent: () => - require('@coinbase/cds-mobile/chips/__stories__/TabbedChips.stories').default, - }, - { - key: 'TabIndicator', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabIndicator.stories').default, - }, - { - key: 'TabLabel', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/TabLabel.stories').default, - }, - { - key: 'TabNavigation', - getComponent: () => - require('@coinbase/cds-mobile/tabs/__stories__/TabNavigation.stories').default, - }, - { - key: 'Tabs', - getComponent: () => require('@coinbase/cds-mobile/tabs/__stories__/Tabs.stories').default, - }, - { - key: 'Tag', - getComponent: () => require('@coinbase/cds-mobile/tag/__stories__/Tag.stories').default, - }, - { - key: 'Text', - getComponent: () => require('@coinbase/cds-mobile/typography/__stories__/Text.stories').default, - }, - { - key: 'TextBody', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextBody.stories').default, - }, - { - key: 'TextCaption', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCaption.stories').default, - }, - { - key: 'TextCore', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextCore.stories').default, - }, - { - key: 'TextDisplay1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay1.stories').default, - }, - { - key: 'TextDisplay2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay2.stories').default, - }, - { - key: 'TextDisplay3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextDisplay3.stories').default, - }, - { - key: 'TextHeadline', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextHeadline.stories').default, - }, - { - key: 'TextInput', - getComponent: () => - require('@coinbase/cds-mobile/controls/__stories__/TextInput.stories').default, - }, - { - key: 'TextLabel1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel1.stories').default, - }, - { - key: 'TextLabel2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLabel2.stories').default, - }, - { - key: 'TextLegal', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextLegal.stories').default, - }, - { - key: 'TextTitle1', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle1.stories').default, - }, - { - key: 'TextTitle2', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle2.stories').default, - }, - { - key: 'TextTitle3', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle3.stories').default, - }, - { - key: 'TextTitle4', - getComponent: () => - require('@coinbase/cds-mobile/typography/__stories__/TextTitle4.stories').default, - }, - { - key: 'ThemeProvider', - getComponent: () => - require('@coinbase/cds-mobile/system/__stories__/ThemeProvider.stories').default, - }, - { - key: 'Toast', - getComponent: () => require('@coinbase/cds-mobile/overlays/__stories__/Toast.stories').default, - }, - { - key: 'TooltipV2', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TooltipV2.stories').default, - }, - { - key: 'TopNavBar', - getComponent: () => - require('@coinbase/cds-mobile/navigation/__stories__/TopNavBar.stories').default, - }, - { - key: 'Tour', - getComponent: () => require('@coinbase/cds-mobile/tour/__stories__/Tour.stories').default, - }, - { - key: 'TrayAction', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayAction.stories').default, - }, - { - key: 'TrayBasic', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayBasic.stories').default, - }, - { - key: 'TrayFallback', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFallback.stories').default, - }, - { - key: 'TrayFeedCard', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayFeedCard.stories').default, - }, - { - key: 'TrayInformational', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayInformational.stories').default, - }, - { - key: 'TrayMessaging', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMessaging.stories').default, - }, - { - key: 'TrayMisc', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayMisc.stories').default, - }, - { - key: 'TrayNavigation', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayNavigation.stories').default, - }, - { - key: 'TrayPromotional', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayPromotional.stories').default, - }, - { - key: 'TrayScrollable', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayScrollable.stories').default, - }, - { - key: 'TrayTall', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayTall.stories').default, - }, - { - key: 'TrayWithTitle', - getComponent: () => - require('@coinbase/cds-mobile/overlays/__stories__/TrayWithTitle.stories').default, - }, - { - key: 'UpsellCard', - getComponent: () => - require('@coinbase/cds-mobile/cards/__stories__/UpsellCard.stories').default, - }, -]; diff --git a/packages/ui-mobile-visreg/src/utils.ts b/packages/ui-mobile-visreg/src/utils.ts deleted file mode 100644 index a31dcdd5fe..0000000000 --- a/packages/ui-mobile-visreg/src/utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { execSync } from 'node:child_process'; -import fs from 'node:fs'; -import path from 'node:path'; - -export function runCmd(cmd: string) { - try { - console.log('runCmd:\n', cmd); - const output = execSync(cmd, { encoding: 'utf-8' }); // the default is 'buffer' - console.log('Output was:\n', output); - } catch (err) { - console.log(`Error was:\n${err}`); - } -} - -export function ensureDirExists(dirPath: string) { - const dir = path.dirname(dirPath); - - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } -} - -export function removeAllFilesFromDir(dirPath: string) { - runCmd(`rm -rf ${dirPath}`); -} diff --git a/packages/ui-mobile-visreg/tsconfig.build.json b/packages/ui-mobile-visreg/tsconfig.build.json deleted file mode 100644 index 16067bc74f..0000000000 --- a/packages/ui-mobile-visreg/tsconfig.build.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "sourceMap": false - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "**/__stories__/**", - "**/__tests__/**", - "**/__mocks__/**", - "**/__fixtures__/**", - "**/*.stories.*", - "**/*.test.*", - "**/*.spec.*" - ], - "references": [ - { - "path": "../../packages/mobile" - }, - { - "path": "../../packages/mobile-visualization" - } - ] -} diff --git a/packages/ui-mobile-visreg/tsconfig.json b/packages/ui-mobile-visreg/tsconfig.json deleted file mode 100644 index 2635bc8389..0000000000 --- a/packages/ui-mobile-visreg/tsconfig.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "extends": "../../tsconfig.project.json", - "compilerOptions": { - "declarationDir": "dts", - "rootDir": "src" - }, - "include": [ - "src/**/*" - ], - "exclude": [], - "references": [ - { - "path": "../../packages/mobile" - }, - { - "path": "../../packages/mobile-visualization" - } - ] -} diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 6427151e29..2637f9f793 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + ## 2.3.5 (12/17/2025 PST) #### 🐞 Fixes diff --git a/packages/utils/src/object.ts b/packages/utils/src/object.ts index a74f9a6efa..16e4a9a88e 100644 --- a/packages/utils/src/object.ts +++ b/packages/utils/src/object.ts @@ -1,6 +1,9 @@ import type { AnyObject, StringKey } from './types'; -/** @deprecated Do not use */ +/** + * @deprecated Do not use. This will be removed in a future major release. + * @deprecationExpectedRemoval v2 + */ export const emptyObject = {}; export function entries>(item: T) { diff --git a/packages/web-visualization/CHANGELOG.md b/packages/web-visualization/CHANGELOG.md index 900ebbe088..cad0acbf5e 100644 --- a/packages/web-visualization/CHANGELOG.md +++ b/packages/web-visualization/CHANGELOG.md @@ -8,10 +8,146 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 3.7.0 (4/20/2026 PST) + +#### 🚀 Updates + +- Feat: add chart baseline support. [[#502](https://github.com/coinbase/cds/pull/502)] + +#### 📘 Misc + +- Update Legend JSDocs. [[#636](https://github.com/coinbase/cds/pull/636)] + +## 3.6.2 (4/20/2026 PST) + +This is an artificial version bump with no new change. + +## 3.6.1 (4/16/2026 PST) + +#### 🐞 Fixes + +- Fix: support strict mode on charts. [[#618](https://github.com/coinbase/cds/pull/618)] + +## 3.6.0 (4/13/2026 PST) + +#### 🚀 Updates + +- Add PercentageBarChart component. [[#550](https://github.com/coinbase/cds/pull/550)] + +## 3.5.0 (4/13/2026 PST) + +#### 🚀 Updates + +- Feat: add enter opacity transition to bars. [[#612](https://github.com/coinbase/cds/pull/612)] + +## 3.4.0 (4/1/2026 PST) + +#### 🐞 Fixes + +- Remove usage of Array.prototype.at(). [[#575](https://github.com/coinbase/cds/pull/575)] + +## 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 + +- Fix bar enter and update animation. [[#540](https://github.com/coinbase/cds/pull/540)] + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + +## 3.4.0-beta.24 (3/12/2026 PST) + +This is an artificial version bump with no new change. + +## 3.4.0-beta.23 (3/10/2026 PST) + +#### 🚀 Updates + +- Add layout prop on CartesianChart. [[#483](https://github.com/coinbase/cds/pull/483)] + +## 3.4.0-beta.22 (3/4/2026 PST) + +#### 🚀 Updates + +- Improve PeriodSelector types. [[#464](https://github.com/coinbase/cds/pull/464)] +- Skip null path transitions. [[#464](https://github.com/coinbase/cds/pull/464)] + +## 3.4.0-beta.21 (3/2/2026 PST) + +#### 🚀 Updates + +- Fix styles props on Scrubber. [[#463](https://github.com/coinbase/cds/pull/463)] +- Fix text elevation background. [[#463](https://github.com/coinbase/cds/pull/463)] + +## 3.4.0-beta.20 (2/27/2026 PST) + +#### 🚀 Updates + +- Add classnames and styles props to PeriodSelector. [[#438](https://github.com/coinbase/cds/pull/438/)] + +#### 📘 Misc + +- Clarify framer-motion is a peerDependency. [[#437](https://github.com/coinbase/cds/pull/437)] +- Update oudated doc links. [[#440](https://github.com/coinbase/cds/pull/440)] + +## 3.4.0-beta.19 (2/20/2026 PST) + +#### 🚀 Updates + +- Support custom enter transitions [[#400](https://github.com/coinbase/cds/pull/400/)] + +#### 📘 Misc + +- Update jsdocs for styles props. [[#384](https://github.com/coinbase/cds/pull/384)] + +## 3.4.0-beta.18 (2/8/2026 PST) + +This is an artificial version bump with no new change. + +## 3.4.0-beta.17 (2/4/2026 PST) + +#### 🚀 Updates + +- Add support preferred side for scrubber beacon label group. [[#366](https://github.com/coinbase/cds/pull/366)] + +## 3.4.0-beta.16 (1/29/2026 PST) + +#### 🚀 Updates + +- Export `CartesianChartContext`. [[#340](https://github.com/coinbase/cds/pull/340)] + +## 3.4.0-beta.15 (1/27/2026 PST) + +#### 🐞 Fixes + +- Fix padding on PeriodSelector. [[#330](https://github.com/coinbase/cds/pull/330)] + +## 3.4.0-beta.14 (1/22/2026 PST) + +#### 🚀 Updates + +- Add chart Legend component. [[#302](https://github.com/coinbase/cds/pull/302)] +- Add support for hideBeaconLabels in Scrubber. [[#302](https://github.com/coinbase/cds/pull/302)] +- Add support for custom bar components. [[#302](https://github.com/coinbase/cds/pull/302)] + +## 3.4.0-beta.13 (1/20/2026 PST) + +#### 🚀 Updates + +- Feat: support styling default scrubber beacon. [[#315](https://github.com/coinbase/cds/pull/315)] #### 📘 Misc +- Internal: code connect file lint fixes. [[#311](https://github.com/coinbase/cds/pull/311)] - Internal: update figma code connect config and some mapping files. [[#304](https://github.com/coinbase/cds/pull/304)] ## 3.4.0-beta.12 (1/8/2026 PST) diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index e02d3ef7ac..60c74f98ac 100644 --- a/packages/web-visualization/package.json +++ b/packages/web-visualization/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web-visualization", - "version": "3.4.0-beta.12", + "version": "3.7.0", "description": "Coinbase Design System - Web Sparkline", "repository": { "type": "git", @@ -42,6 +42,7 @@ "@coinbase/cds-lottie-files": "workspace:^", "@coinbase/cds-utils": "workspace:^", "@coinbase/cds-web": "workspace:^", + "framer-motion": "^10.18.0", "react": "^18.3.1", "react-dom": "^18.3.1" }, @@ -64,6 +65,7 @@ "@coinbase/cds-web": "workspace:^", "@linaria/core": "^3.0.0-beta.22", "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1" + "@types/react-dom": "^18.3.1", + "framer-motion": "^10.18.0" } } diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx index 2b541d2a3a..6dd7c42cc7 100644 --- a/packages/web-visualization/src/chart/CartesianChart.tsx +++ b/packages/web-visualization/src/chart/CartesianChart.tsx @@ -7,20 +7,24 @@ import { css } from '@linaria/core'; import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider'; import { CartesianChartProvider } from './ChartProvider'; +import { Legend } from './legend'; import { - type AxisConfig, - type AxisConfigProps, + type CartesianAxisConfig, + type CartesianAxisConfigProps, type CartesianChartContextValue, + type CartesianChartLayout, type ChartInset, type ChartScaleFunction, defaultAxisId, - defaultChartInset, + defaultHorizontalLayoutChartInset, + defaultVerticalLayoutChartInset, getAxisConfig, - getAxisDomain, getAxisRange, - getAxisScale, + getCartesianAxisDomain, + getCartesianAxisScale, getChartInset, getStackedSeriesData as calculateStackedSeriesData, + type LegendPosition, type Series, useTotalAxisPadding, } from './utils'; @@ -42,23 +46,51 @@ export type CartesianChartBaseProps = BoxBaseProps & * Each series contains its own data array. */ series?: Array; + /** + * Chart layout - describes the direction bars/areas grow. + * - 'vertical' (default): Bars grow vertically. X is category axis, Y is value axis. + * - 'horizontal': Bars grow horizontally. Y is category axis, X is value axis. + * @default 'vertical' + */ + layout?: CartesianChartLayout; /** * Whether to animate the chart. * @default true */ animate?: boolean; /** - * Configuration for x-axis. + * Configuration for x-axis(es). Can be a single config or array of configs. + * + * @note Multiple x-axis configs are only supported when `layout="horizontal"`. */ - xAxis?: Partial>; + xAxis?: Partial | Partial[]; /** * Configuration for y-axis(es). Can be a single config or array of configs. + * + * @note Multiple y-axis configs are only supported when `layout="vertical"`. */ - yAxis?: Partial> | Partial>[]; + yAxis?: Partial | Partial[]; /** * Inset around the entire chart (outside the axes). */ inset?: number | Partial; + /** + * Whether to show the legend or a custom legend element. + * - `true` renders the default Legend component + * - A React element renders that element as the legend + * - `false` or omitted hides the legend + */ + legend?: boolean | React.ReactNode; + /** + * Position of the legend relative to the chart. + * @default 'bottom' + */ + legendPosition?: LegendPosition; + /** + * Accessibility label for the legend group. + * @default 'Legend' + */ + legendAccessibilityLabel?: string; }; export type CartesianChartProps = Omit, 'title'> & @@ -105,18 +137,23 @@ export const CartesianChart = memo( { series, children, + layout = 'vertical', animate = true, xAxis: xAxisConfigProp, yAxis: yAxisConfigProp, inset, enableScrubbing, onScrubberPositionChange, + legend, + legendPosition = 'bottom', + legendAccessibilityLabel, width = '100%', height = '100%', className, classNames, style, styles, + accessibilityLabel, ...props }, ref, @@ -124,13 +161,35 @@ export const CartesianChart = memo( const { observe, width: chartWidth, height: chartHeight } = useDimensions(); const svgRef = useRef(null); - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); + const calculatedInset = useMemo( + () => + getChartInset( + inset, + layout === 'horizontal' + ? defaultHorizontalLayoutChartInset + : defaultVerticalLayoutChartInset, + ), + [inset, layout], + ); // Axis configs store the properties of each axis, such as id, scale type, domain limit, etc. - // We only support 1 x axis but allow for multiple y axes. - const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp)[0], [xAxisConfigProp]); + const xAxisConfig = useMemo(() => getAxisConfig('x', xAxisConfigProp), [xAxisConfigProp]); const yAxisConfig = useMemo(() => getAxisConfig('y', yAxisConfigProp), [yAxisConfigProp]); + // Horizontal layout supports multiple value axes on x, but only a single category axis on y. + // Vertical layout keeps a single x-axis to preserve existing behavior. + if (layout === 'horizontal' && yAxisConfig.length > 1) { + throw new Error( + 'When layout="horizontal", only one y-axis is supported. See https://cds.coinbase.com/components/charts/CartesianChart.', + ); + } + + if (layout !== 'horizontal' && xAxisConfig.length > 1) { + throw new Error( + 'Multiple x-axes are only supported when layout="horizontal". See https://cds.coinbase.com/components/charts/CartesianChart.', + ); + } + const { renderedAxes, registerAxis, unregisterAxis, axisPadding } = useTotalAxisPadding(); const chartRect: Rect = useMemo(() => { @@ -154,49 +213,66 @@ export const CartesianChart = memo( }; }, [chartHeight, chartWidth, calculatedInset, axisPadding]); - const { xAxis, xScale } = useMemo(() => { + const { xAxes, xScales } = useMemo(() => { + const axes = new Map(); + const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) - return { xAxis: undefined, xScale: undefined }; - - const domain = getAxisDomain(xAxisConfig, series ?? [], 'x'); - const range = getAxisRange(xAxisConfig, chartRect, 'x'); - - const axisConfig: AxisConfig = { - scaleType: xAxisConfig.scaleType, - domain, - range, - data: xAxisConfig.data, - categoryPadding: xAxisConfig.categoryPadding, - domainLimit: xAxisConfig.domainLimit, - }; + return { xAxes: axes, xScales: scales }; - // Create the scale - const scale = getAxisScale({ - config: axisConfig, - type: 'x', - range: axisConfig.range, - dataDomain: axisConfig.domain, - }); + xAxisConfig.forEach((axisParam) => { + const axisId = axisParam.id ?? defaultAxisId; - if (!scale) return { xAxis: undefined, xScale: undefined }; + // Get relevant series data + const relevantSeries = + xAxisConfig.length > 1 + ? (series?.filter((s) => (s.xAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); - // Update axis config with actual scale domain (after .nice() or other adjustments) - const scaleDomain = scale.domain(); - const actualDomain = - Array.isArray(scaleDomain) && scaleDomain.length === 2 - ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } - : axisConfig.domain; + // Calculate domain and range + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'x', layout); + const range = getAxisRange(axisParam, chartRect, 'x'); - const finalAxisConfig = { - ...axisConfig, - domain: actualDomain, - }; + const axisConfig: CartesianAxisConfig = { + scaleType: axisParam.scaleType, + domain: dataDomain, + range, + data: axisParam.data, + categoryPadding: axisParam.categoryPadding, + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'nice' : 'strict'), + baseline: axisParam.baseline, + }; + + // Create the scale + const scale = getCartesianAxisScale({ + config: axisConfig, + type: 'x', + range: axisConfig.range, + dataDomain: axisConfig.domain, + layout, + }); - return { xAxis: finalAxisConfig, xScale: scale }; - }, [xAxisConfig, series, chartRect]); + if (scale) { + scales.set(axisId, scale); + + // Update axis config with actual scale domain (after .nice() or other adjustments) + const scaleDomain = scale.domain(); + const actualDomain = + Array.isArray(scaleDomain) && scaleDomain.length === 2 + ? { min: scaleDomain[0] as number, max: scaleDomain[1] as number } + : axisConfig.domain; + + axes.set(axisId, { + ...axisConfig, + domain: actualDomain, + }); + } + }); + + return { xAxes: axes, xScales: scales }; + }, [xAxisConfig, series, chartRect, layout]); const { yAxes, yScales } = useMemo(() => { - const axes = new Map(); + const axes = new Map(); const scales = new Map(); if (!chartRect || chartRect.width <= 0 || chartRect.height <= 0) return { yAxes: axes, yScales: scales }; @@ -206,27 +282,31 @@ export const CartesianChart = memo( // Get relevant series data const relevantSeries = - series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []; + yAxisConfig.length > 1 + ? (series?.filter((s) => (s.yAxisId ?? defaultAxisId) === axisId) ?? []) + : (series ?? []); // Calculate domain and range - const dataDomain = getAxisDomain(axisParam, relevantSeries, 'y'); + const dataDomain = getCartesianAxisDomain(axisParam, relevantSeries, 'y', layout); const range = getAxisRange(axisParam, chartRect, 'y'); - const axisConfig: AxisConfig = { + const axisConfig: CartesianAxisConfig = { scaleType: axisParam.scaleType, domain: dataDomain, range, data: axisParam.data, categoryPadding: axisParam.categoryPadding, - domainLimit: axisParam.domainLimit ?? 'nice', + domainLimit: axisParam.domainLimit ?? (layout === 'horizontal' ? 'strict' : 'nice'), + baseline: axisParam.baseline, }; // Create the scale - const scale = getAxisScale({ + const scale = getCartesianAxisScale({ config: axisConfig, type: 'y', range: axisConfig.range, dataDomain: axisConfig.domain, + layout, }); if (scale) { @@ -247,11 +327,11 @@ export const CartesianChart = memo( }); return { yAxes: axes, yScales: scales }; - }, [yAxisConfig, series, chartRect]); + }, [yAxisConfig, series, chartRect, layout]); - const getXAxis = useCallback(() => xAxis, [xAxis]); + const getXAxis = useCallback((id?: string) => xAxes.get(id ?? defaultAxisId), [xAxes]); const getYAxis = useCallback((id?: string) => yAxes.get(id ?? defaultAxisId), [yAxes]); - const getXScale = useCallback(() => xScale, [xScale]); + const getXScale = useCallback((id?: string) => xScales.get(id ?? defaultAxisId), [xScales]); const getYScale = useCallback((id?: string) => yScales.get(id ?? defaultAxisId), [yScales]); const getSeries = useCallback( (seriesId?: string) => series?.find((s) => s.id === seriesId), @@ -260,8 +340,8 @@ export const CartesianChart = memo( const stackedDataMap = useMemo(() => { if (!series) return new Map>(); - return calculateStackedSeriesData(series); - }, [series]); + return calculateStackedSeriesData(series, layout, xAxisConfig, yAxisConfig); + }, [series, layout, xAxisConfig, yAxisConfig]); const getStackedSeriesData = useCallback( (seriesId?: string) => { @@ -271,10 +351,20 @@ export const CartesianChart = memo( [stackedDataMap], ); + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxisConfig = useMemo(() => { + return categoryAxisIsX + ? (xAxisConfig[0] ?? yAxisConfig[0]) + : (yAxisConfig[0] ?? xAxisConfig[0]); + }, [categoryAxisIsX, xAxisConfig, yAxisConfig]); + const dataLength = useMemo(() => { - // If xAxis has categorical data, use that length - if (xAxisConfig.data && xAxisConfig.data.length > 0) { - return xAxisConfig.data.length; + // If category axis has categorical data, use that length + if (categoryAxisConfig.data && categoryAxisConfig.data.length > 0) { + return categoryAxisConfig.data.length; } // Otherwise, find the longest series @@ -283,7 +373,7 @@ export const CartesianChart = memo( const seriesData = getStackedSeriesData(s.id); return Math.max(max, seriesData?.length ?? 0); }, 0); - }, [xAxisConfig.data, series, getStackedSeriesData]); + }, [categoryAxisConfig, series, getStackedSeriesData]); const getAxisBounds = useCallback( (axisId: string): Rect | undefined => { @@ -345,6 +435,7 @@ export const CartesianChart = memo( const contextValue: CartesianChartContextValue = useMemo( () => ({ + layout, series: series ?? [], getSeries, getSeriesData: getStackedSeriesData, @@ -362,6 +453,7 @@ export const CartesianChart = memo( getAxisBounds, }), [ + layout, series, getSeries, getStackedSeriesData, @@ -386,6 +478,68 @@ export const CartesianChart = memo( ); const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]); + const legendElement = useMemo(() => { + if (!legend) return; + + if (legend === true) { + const isHorizontal = legendPosition === 'top' || legendPosition === 'bottom'; + const flexDirection = isHorizontal ? 'row' : 'column'; + + return ( + + ); + } + + return legend; + }, [legend, legendAccessibilityLabel, legendPosition]); + + const rootBoxProps: BoxProps<'div'> = useMemo( + () => ({ + className: rootClassNames, + height, + style: rootStyles, + width, + ...props, + }), + [rootClassNames, height, rootStyles, width, props], + ); + + const chartContent = ( + { + observe(node as unknown as HTMLElement); + }} + height={legend ? undefined : height} + style={{ flex: 1, minHeight: 0, minWidth: 0 }} + width={legend ? undefined : width} + > + { + const svgElement = node as unknown as SVGSVGElement; + svgRef.current = svgElement; + // Forward the ref to the user + if (ref) { + if (typeof ref === 'function') { + ref(svgElement); + } else { + (ref as React.MutableRefObject).current = svgElement; + } + } + }} + accessibilityLabel={accessibilityLabel} + aria-live="polite" + as="svg" + className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} + height="100%" + style={styles?.chart} + tabIndex={enableScrubbing ? 0 : undefined} + width="100%" + > + {children} + + + ); + return ( - { - observe(node as unknown as HTMLElement); - }} - className={rootClassNames} - height={height} - style={rootStyles} - width={width} - {...props} - > + {legend ? ( { - const svgElement = node as unknown as SVGSVGElement; - svgRef.current = svgElement; - // Forward the ref to the user - if (ref) { - if (typeof ref === 'function') { - ref(svgElement); - } else { - (ref as React.MutableRefObject).current = svgElement; - } - } - }} - aria-live="polite" - as="svg" - className={cx(enableScrubbing && focusStylesCss, classNames?.chart)} - height="100%" - style={styles?.chart} - tabIndex={enableScrubbing ? 0 : undefined} - width="100%" + {...rootBoxProps} + flexDirection={ + legendPosition === 'top' || legendPosition === 'bottom' ? 'column' : 'row' + } > - {children} + {(legendPosition === 'top' || legendPosition === 'left') && legendElement} + {chartContent} + {(legendPosition === 'bottom' || legendPosition === 'right') && legendElement} - + ) : ( + {chartContent} + )} ); diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx index 34ac00c480..192421a5d0 100644 --- a/packages/web-visualization/src/chart/ChartProvider.tsx +++ b/packages/web-visualization/src/chart/ChartProvider.tsx @@ -2,13 +2,15 @@ import { createContext, useContext } from 'react'; import type { CartesianChartContextValue } from './utils/context'; -const CartesianChartContext = createContext(undefined); +export const CartesianChartContext = createContext( + undefined, +); export const useCartesianChartContext = (): CartesianChartContextValue => { const context = useContext(CartesianChartContext); if (!context) { throw new Error( - 'useCartesianChartContext must be used within a CartesianChart component. See http://cds.coinbase.com/components/graphs/CartesianChart.', + 'useCartesianChartContext must be used within a CartesianChart component. See https://cds.coinbase.com/components/charts/CartesianChart.', ); } return context; diff --git a/packages/web-visualization/src/chart/Path.tsx b/packages/web-visualization/src/chart/Path.tsx index cfc8892f5b..0aebd8c546 100644 --- a/packages/web-visualization/src/chart/Path.tsx +++ b/packages/web-visualization/src/chart/Path.tsx @@ -3,11 +3,14 @@ import type { SVGProps } from 'react'; import type { Rect, SharedProps } from '@coinbase/cds-common/types'; import { m as motion, type Transition } from 'framer-motion'; -import { usePathTransition } from './utils/transition'; +import { defaultPathEnterTransition } from './utils/path'; +import { defaultTransition, getTransition, usePathTransition } from './utils/transition'; import { useCartesianChartContext } from './ChartProvider'; /** * Duration in seconds for path enter transition. + * @deprecated Use `transitions.enter` on the Path component instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const pathEnterTransitionDuration = 0.5; @@ -16,6 +19,20 @@ export type PathBaseProps = SharedProps & { * Whether to animate this path. Overrides the animate prop on the Chart component. */ animate?: boolean; + /** + * Initial path for enter animation. + * When provided, the first animation will go from initialPath to d. + * If not provided, defaults to d (no path enter animation). + */ + initialPath?: string; + /** + * Fill color for the path. + */ + fill?: string; + /** + * Opacity for the path fill. + */ + fillOpacity?: number; }; export type PathProps = PathBaseProps & @@ -34,6 +51,48 @@ export type PathProps = PathBaseProps & | 'onDragEndCapture' | 'onDragStartCapture' > & { + /** + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + * + * @default transitions = {{ + * enter: { type: 'tween', duration: 0.5 }, + * enterOpacity: undefined, + * update: { type: 'spring', stiffness: 900, damping: 120, mass: 4 } + * }} + * + * @example + * // Custom enter and update transitions + * transitions={{ enter: { type: 'tween', duration: 0.3 }, update: { type: 'spring', damping: 20 } }} + * + * @example + * // Disable enter animation + * transitions={{ enter: null }} + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for the initial enter opacity animation. + * When provided, path opacity animates from 0 to 1. + * Set to `null` to disable. + */ + enterOpacity?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + */ + transition?: Transition; /** * Offset added to the clip rect boundaries. */ @@ -44,35 +103,67 @@ export type PathProps = PathBaseProps & * @default drawingArea of chart + clipOffset */ clipRect?: Rect | null; - /** - * Transition configuration for path. - * - * @example - * // Timing based animation - * transition={{ type: 'tween', duration: 0.2, ease: 'easeOut' }} - * - * @example - * // Spring animation - * transition={{ type: 'spring', damping: 20, stiffness: 300 }} - */ - transition?: Transition; }; -const AnimatedPath = memo>(({ d = '', transition, ...pathProps }) => { - const interpolatedPath = usePathTransition({ - currentPath: d, - transition, - }); +const AnimatedPath = memo>( + ({ d = '', initialPath, transitions, ...pathProps }) => { + const interpolatedPath = usePathTransition({ + currentPath: d, + initialPath, + transitions, + }); + + const animateEnterOpacity = Boolean(transitions?.enterOpacity); - return ; -}); + return ( + + ); + }, +); export const Path = memo( - ({ animate: animateProp, clipRect, clipOffset = 0, d = '', transition, ...pathProps }) => { + ({ + animate: animateProp, + clipRect, + clipOffset = 0, + d = '', + transitions, + transition, + ...pathProps + }) => { const clipPathId = useId(); const context = useCartesianChartContext(); const rect = clipRect !== undefined ? clipRect : context.drawingArea; const animate = animateProp ?? context.animate; + const clipPath = rect !== null ? `url(#${clipPathId})` : undefined; + + const enterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultPathEnterTransition), + [animate, transitions?.enter], + ); + + const updateTransition = useMemo( + () => + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + [animate, transitions?.update, transition], + ); + + const enterOpacityTransition = useMemo(() => { + if (!animate) return null; + return transitions?.enterOpacity; + }, [animate, transitions?.enterOpacity]); + + const animateClip = animate && enterTransition !== null; // The clip offset provides extra padding to prevent path from being cut off // Area charts typically use offset=0 for exact clipping, while lines use offset=2 for breathing room @@ -80,41 +171,45 @@ export const Path = memo( const clipPathAnimation = useMemo(() => { if (rect === null) return; + const categoryAxisIsX = context.layout !== 'horizontal'; + const fullWidth = rect.width + totalOffset; + const fullHeight = rect.height + totalOffset; + return { - hidden: { width: 0 }, + hidden: { + width: categoryAxisIsX ? 0 : fullWidth, + height: categoryAxisIsX ? fullHeight : 0, + }, visible: { - width: rect.width + totalOffset, + width: fullWidth, + height: fullHeight, transition: { type: 'timing', duration: pathEnterTransitionDuration, }, }, }; - }, [rect, totalOffset]); - - const clipPath = useMemo( - () => (rect !== null ? `url(#${clipPathId})` : undefined), - [rect, clipPathId], - ); + }, [rect, totalOffset, context.layout]); return ( <> {rect !== null && ( - {!animate ? ( - ) : ( - @@ -122,11 +217,16 @@ export const Path = memo( )} - {!animate ? ( - - ) : ( - - )} + ); }, diff --git a/packages/web-visualization/src/chart/PeriodSelector.tsx b/packages/web-visualization/src/chart/PeriodSelector.tsx index 2f984da2a4..947b58d433 100644 --- a/packages/web-visualization/src/chart/PeriodSelector.tsx +++ b/packages/web-visualization/src/chart/PeriodSelector.tsx @@ -1,4 +1,5 @@ import React, { forwardRef, memo, useMemo } from 'react'; +import { cx } from '@coinbase/cds-web'; import type { Polymorphic } from '@coinbase/cds-web/core/polymorphism'; import { Box } from '@coinbase/cds-web/layout'; import { @@ -11,7 +12,7 @@ import { import { SegmentedTab, type SegmentedTabProps } from '@coinbase/cds-web/tabs/SegmentedTab'; import { Text, type TextBaseProps } from '@coinbase/cds-web/typography'; import { css } from '@linaria/core'; -import { m as motion, type Transition } from 'framer-motion'; +import { m as motion } from 'framer-motion'; const MotionBox = motion(Box); @@ -23,6 +24,7 @@ export const PeriodSelectorActiveIndicator = memo( position = 'absolute', borderRadius = 1000, style, + ...props }: TabsActiveIndicatorProps) => { const { width, height, x } = activeTabRect; const activeAnimation = useMemo(() => ({ width, x }), [width, x]); @@ -36,6 +38,7 @@ export const PeriodSelectorActiveIndicator = memo( data-testid="period-selector-active-indicator" height={height} initial={false} + left={0} position={position} role="none" style={{ @@ -44,6 +47,7 @@ export const PeriodSelectorActiveIndicator = memo( ...style, }} transition={tabsTransitionConfig} + {...props} /> ); }, @@ -143,6 +147,10 @@ export const PeriodSelector = memo( justifyContent = 'space-between', TabComponent = PeriodSelectorTab, TabsActiveIndicatorComponent = PeriodSelectorActiveIndicator, + className, + classNames, + style, + styles, ...props }: PeriodSelectorProps, ref: React.ForwardedRef, @@ -153,7 +161,17 @@ export const PeriodSelector = memo( TabsActiveIndicatorComponent={TabsActiveIndicatorComponent} activeBackground={activeBackground} background={background} + className={cx(className, classNames?.root)} + classNames={{ + tab: classNames?.tab, + activeIndicator: classNames?.activeIndicator, + }} justifyContent={justifyContent} + style={styles?.root ? { ...style, ...styles.root } : style} + styles={{ + tab: styles?.tab, + activeIndicator: styles?.activeIndicator, + }} width={width} {...props} /> diff --git a/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx index d995842340..35819a4e93 100644 --- a/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/CartesianChart.stories.tsx @@ -16,18 +16,16 @@ import { ReferenceLine, SolidLine, type SolidLineProps } from '../line'; import { Line } from '../line/Line'; import { LineChart } from '../line/LineChart'; import { isCategoricalScale } from '../utils'; -import { - BarPlot, - CartesianChart, - type ChartTextChildren, - PeriodSelector, - Point, - Scrubber, -} from '../'; +import { BarPlot, CartesianChart, type ChartTextChildren, PeriodSelector, Scrubber } from '../'; export default { component: CartesianChart, title: 'Components/Chart/CartesianChart', + parameters: { + a11y: { + test: 'todo', + }, + }, }; const MultipleChart = () => { @@ -156,6 +154,16 @@ const PredictionMarket = () => { return selectedSeriesId ? [selectedSeriesId] : undefined; }, [selectedSeriesId]); + const chartAccessibilityLabel = useMemo(() => { + const lastIndex = eaglesData.length - 1; + const teamA = eaglesData[lastIndex]; + const teamB = 100 - teamA; + + return `Prediction market chart with ${eaglesData.length} data points. Latest odds: Team A ${teamA.toFixed( + 1, + )}%, Team B ${teamB.toFixed(1)}%.`; + }, [eaglesData]); + const [scrubberLabel, setScrubberLabel] = useState(null); const updateScrubberLabel = useCallback( (scrubberPosition: number | undefined) => { @@ -179,6 +187,17 @@ const PredictionMarket = () => { [eaglesData.length], ); + const getScrubberAccessibilityLabel = useCallback( + (dataIndex: number) => { + const teamA = eaglesData[dataIndex]; + const teamB = 100 - teamA; + return `At position ${dataIndex + 1} of ${eaglesData.length}: Team A ${teamA.toFixed( + 1, + )}%, Team B ${teamB.toFixed(1)}%.`; + }, + [eaglesData], + ); + return ( @@ -191,6 +210,7 @@ const PredictionMarket = () => { { /> ))} - + @@ -324,7 +348,7 @@ const EarningsHistory = () => { [actualEPS, estimatedEPS], ); - const LegendItem = memo(({ opacity = 1, label }: { opacity?: number; label: string }) => { + const LegendEntry = memo(({ opacity = 1, label }: { opacity?: number; label: string }) => { return ( @@ -365,8 +389,8 @@ const EarningsHistory = () => {
    - - + + ); @@ -410,11 +434,21 @@ const PriceWithVolume = () => { const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; - const accessibilityLabel = useMemo(() => { - if (scrubIndex === undefined) - return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; - }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); + const chartAccessibilityLabel = useMemo(() => { + const lastIndex = btcPrices.length - 1; + return `Bitcoin chart. Current date ${formatDate(btcDates[lastIndex])}. Current price ${formatPrice( + btcPrices[lastIndex], + )}. Current volume ${formatVolume(btcVolumes[lastIndex])}.`; + }, [btcDates, btcPrices, btcVolumes, formatDate, formatPrice, formatVolume]); + + const getScrubberAccessibilityLabel = useCallback( + (dataIndex: number) => { + return `Bitcoin on ${formatDate(btcDates[dataIndex])}. Price ${formatPrice( + btcPrices[dataIndex], + )}. Volume ${formatVolume(btcVolumes[dataIndex])}.`; + }, + [btcDates, btcPrices, btcVolumes, formatDate, formatPrice, formatVolume], + ); const ThinSolidLine = memo((props: SolidLineProps) => ); @@ -441,7 +475,7 @@ const PriceWithVolume = () => { /> { /> - + ); @@ -566,22 +600,24 @@ const Example: React.FC< export const Miscellaneous = () => { return ( - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/__stories__/ChartTransitions.stories.tsx b/packages/web-visualization/src/chart/__stories__/ChartTransitions.stories.tsx new file mode 100644 index 0000000000..b3f415c45d --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/ChartTransitions.stories.tsx @@ -0,0 +1,520 @@ +import { + memo, + type PropsWithChildren, + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; +import { Button } from '@coinbase/cds-web/buttons'; +import { Box, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { Area } from '../area/Area'; +import type { BarProps } from '../bar/Bar'; +import { BarChart } from '../bar/BarChart'; +import { CartesianChart } from '../CartesianChart'; +import { Line, type LineProps } from '../line/Line'; +import type { PathProps } from '../Path'; +import type { PointBaseProps, PointProps } from '../point'; +import { Scrubber, type ScrubberProps, type ScrubberRef } from '../scrubber'; + +export default { + title: 'Components/Chart/CartesianChart', + component: CartesianChart, + parameters: { + percy: { skip: true }, + }, +}; + +const dataCount = 15; +const updateInterval = 2500; +const rapidUpdateInterval = 800; + +function generateNextValue(previousValue: number) { + const step = Math.random() * 30 - 15; + return Math.max(0, Math.min(100, previousValue + step)); +} + +function generateInitialData() { + const data = [50]; + for (let i = 1; i < dataCount; i++) { + data.push(generateNextValue(data[i - 1])); + } + return data; +} + +const enterOnly: PathProps['transitions'] = { + update: null, +}; +const updateOnly: PathProps['transitions'] = { + enter: null, +}; +const bothDisabled: PathProps['transitions'] = { enter: null, update: null }; +const customEnterUpdate: PathProps['transitions'] = { + enter: { type: 'tween', duration: 1.5 }, + update: { type: 'spring', stiffness: 400, damping: 30 }, +}; +const customEnterUpdateBeacon: PathProps['transitions'] = { + enter: { type: 'tween', duration: 0.5, delay: 1.0 }, + update: { type: 'spring', stiffness: 400, damping: 30 }, +}; +const slowSpringBoth: PathProps['transitions'] = { + enter: { type: 'spring', stiffness: 100, damping: 10 }, + update: { type: 'spring', stiffness: 100, damping: 10 }, +}; +const staggeredBoth: BarProps['transitions'] = { + enter: { type: 'tween', duration: 0.75, staggerDelay: 0.25 }, + update: { type: 'spring', stiffness: 300, damping: 20, staggerDelay: 0.15 }, +}; +const slowTimingBoth: PathProps['transitions'] = { + enter: { type: 'tween', duration: 2 }, + update: { type: 'tween', duration: 2 }, +}; + +const TransitionLineChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; + scrubberTransitions?: PathProps['transitions']; + animate?: boolean; + idlePulse?: boolean; + scrubberRef?: RefObject; + enableScrubbing?: boolean; + points?: LineProps['points']; +}>( + ({ + data, + transitions, + scrubberTransitions, + animate: animateProp, + idlePulse, + scrubberRef, + enableScrubbing = true, + points, + }) => ( + + + {enableScrubbing && ( + } + hideOverlay + idlePulse={idlePulse} + transitions={scrubberTransitions ?? transitions} + /> + )} + + ), +); + +const TransitionAreaChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; + idlePulse?: boolean; + scrubberRef?: RefObject; +}>(({ data, transitions, idlePulse, scrubberRef }) => ( + + + + } + hideOverlay + idlePulse={idlePulse} + transitions={transitions} + /> + +)); + +const MultiLineChart = memo<{ + data1: number[]; + data2: number[]; + transitions: PathProps['transitions']; +}>(({ data1, data2, transitions }) => ( + + + + + +)); + +function LineExample({ + transitions, + scrubberTransitions, + pointTransitions, + animate, + idlePulse, + resettable = true, + imperative = false, + points, +}: { + transitions: PathProps['transitions']; + scrubberTransitions?: ScrubberProps['transitions']; + pointTransitions?: PointProps['transitions']; + animate?: boolean; + idlePulse?: boolean; + resettable?: boolean; + imperative?: boolean; + points?: boolean; +}) { + const scrubberRef = useRef(null); + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + if (imperative) scrubberRef.current?.pulse(); + }, updateInterval); + return () => clearInterval(intervalId); + }, [imperative]); + + const pointFunction: LineProps['points'] = (props: PointBaseProps) => ({ + ...props, + transitions: pointTransitions, + }); + + const pointProps: LineProps['points'] = points ? pointFunction : false; + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function AreaExample({ + transitions, + idlePulse, + resettable = true, + imperative = false, +}: { + transitions: PathProps['transitions']; + idlePulse?: boolean; + resettable?: boolean; + imperative?: boolean; +}) { + const scrubberRef = useRef(null); + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + if (imperative) scrubberRef.current?.pulse(); + }, updateInterval); + return () => clearInterval(intervalId); + }, [imperative]); + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function SessionBaselineAreaTransitionsExample() { + const [resetKey, setResetKey] = useState(0); + const [data, setData] = useState(generateInitialData); + const handleReset = useCallback(() => { + setData(generateInitialData()); + setResetKey((k) => k + 1); + }, []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((d) => [...d.slice(1), generateNextValue(d[d.length - 1])]); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + const baseline = data[0]; + + return ( + + + + + + + + + + + ); +} + +const barCategories = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +function generateBarData() { + return barCategories.map(() => Math.round(Math.random() * 80 + 10)); +} + +const barChartProps = { + showXAxis: true, + enableScrubbing: true, + height: 250, + xAxis: { data: barCategories }, + yAxis: { domain: { min: 0, max: 100 } }, +} as const; + +const TransitionBarChart = memo<{ + data: number[]; + transitions: PathProps['transitions']; +}>(({ data, transitions }) => ( + + + +)); + +function BarExample({ + transitions, + resettable = true, +}: { + transitions: PathProps['transitions']; + resettable?: boolean; +}) { + const [data, setData] = useState(generateBarData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData(generateBarData()); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + {resettable && ( + + + + )} + + ); +} + +function RapidLineExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data, setData] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, rapidUpdateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +function RapidBarExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data, setData] = useState(generateBarData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData(generateBarData()); + }, rapidUpdateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +function MultiLineExample({ transitions }: { transitions: PathProps['transitions'] }) { + const [data1, setData1] = useState(generateInitialData); + const [data2, setData2] = useState(generateInitialData); + const [resetKey, setResetKey] = useState(0); + const handleReset = useCallback(() => setResetKey((k) => k + 1), []); + + useEffect(() => { + const intervalId = setInterval(() => { + setData1((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + setData2((current) => { + const last = current[current.length - 1]; + return [...current.slice(1), generateNextValue(last)]; + }); + }, updateInterval); + return () => clearInterval(intervalId); + }, []); + + return ( + + + + + + + ); +} + +const Example = ({ + category, + title, + children, +}: PropsWithChildren<{ category: string; title: string }>) => ( + + + + {category} + + {title} + + {children} + +); + +export const Transitions = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/__stories__/PeriodSelector.stories.tsx b/packages/web-visualization/src/chart/__stories__/PeriodSelector.stories.tsx index a9c518e476..86ef076dd3 100644 --- a/packages/web-visualization/src/chart/__stories__/PeriodSelector.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/PeriodSelector.stories.tsx @@ -67,6 +67,25 @@ const MinWidthPeriodSelectorExample = () => { ); }; +const PaddedPeriodSelectorExample = () => { + const tabs = [ + { id: '1W', label: '1W' }, + { id: '1M', label: '1M' }, + { id: 'YTD', label: 'YTD' }, + ]; + const [activeTab, setActiveTab] = useState(tabs[0]); + return ( + setActiveTab(tab)} + padding={3} + tabs={tabs} + width="fit-content" + /> + ); +}; + const LivePeriodSelectorExample = () => { const tabs = useMemo( () => [ @@ -300,6 +319,9 @@ export const All = () => { + + + ); }; diff --git a/packages/web-visualization/src/chart/__stories__/TextStories.stories.tsx b/packages/web-visualization/src/chart/__stories__/TextStories.stories.tsx index c939ac1270..a7a0df752f 100644 --- a/packages/web-visualization/src/chart/__stories__/TextStories.stories.tsx +++ b/packages/web-visualization/src/chart/__stories__/TextStories.stories.tsx @@ -14,6 +14,11 @@ const CHART_HEIGHT = 300; export default { component: ChartText, title: 'Components/Chart/ChartText', + parameters: { + a11y: { + test: 'todo', + }, + }, }; export const InteractiveChartText = () => { @@ -158,7 +163,11 @@ export const InteractiveChartText = () => { - setShowDebug(e.target.checked)} /> + setShowDebug(e.target.checked)} + />
    {/* Hide via display:none */} @@ -167,6 +176,7 @@ export const InteractiveChartText = () => { Hide Text (display:none): setHideWithDisplayNone(e.target.checked)} /> @@ -580,7 +590,11 @@ export const InteractiveChartTextGroup = () => { {/* Debug Toggle */}
    - setShowDebug(e.target.checked)} /> + setShowDebug(e.target.checked)} + />
    {/* Instructions */} diff --git a/packages/web-visualization/src/chart/__tests__/CartesianChart.test.tsx b/packages/web-visualization/src/chart/__tests__/CartesianChart.test.tsx new file mode 100644 index 0000000000..d5e00fbe8c --- /dev/null +++ b/packages/web-visualization/src/chart/__tests__/CartesianChart.test.tsx @@ -0,0 +1,755 @@ +import type { ComponentProps, ReactNode } from 'react'; +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import { Area } from '../area/Area'; +import { XAxis } from '../axis/XAxis'; +import { YAxis } from '../axis/YAxis'; +import type { BarComponentProps } from '../bar/Bar'; +import { BarPlot } from '../bar/BarPlot'; +import { CartesianChart } from '../CartesianChart'; +import { Line } from '../line/Line'; +import { ReferenceLine } from '../line/ReferenceLine'; +import { Point } from '../point/Point'; +import { Scrubber } from '../scrubber/Scrubber'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +const baseSeries: ComponentProps['series'] = [ + { id: 'test', data: [10, 20, 30, 40, 50], label: 'Test Series' }, +]; + +const multiSeries: ComponentProps['series'] = [ + { id: 'alpha', data: [10, 20, 30, 40, 50], label: 'Alpha' }, + { id: 'beta', data: [50, 40, 30, 20, 10], label: 'Beta' }, +]; + +const renderCartesianChart = ({ + testID = 'cartesian-chart', + series = baseSeries, + chartProps, + children, +}: { + testID?: string; + series?: ComponentProps['series']; + chartProps?: Partial>; + children?: ReactNode; +} = {}) => { + const defaultSeriesId = series?.[0]?.id ?? 'test'; + + render( + + + {children ?? } + + , + ); + + return screen.getByTestId(testID); +}; + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in axis label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('CartesianChart', () => { + describe('core rendering and transitions', () => { + it('renders line content when child enter transition is disabled', () => { + render( + + + + + , + ); + + const svg = screen.getByTestId('cartesian-enter-null'); + const linePath = svg.querySelector('path[d]'); + expect(linePath).toBeInTheDocument(); + expect(linePath?.getAttribute('d')).toBeTruthy(); + + const clipRect = svg.querySelector('clipPath rect'); + expect(clipRect).toBeInTheDocument(); + expect(Number(clipRect?.getAttribute('width'))).toBeGreaterThan(0); + }); + + it('renders line content when child update transition is disabled', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-update-null', + children: , + }); + expect(svg.querySelector('path[d]')).toBeInTheDocument(); + }); + + it('renders line content when chart animation is disabled', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-animate-false', + chartProps: { animate: false }, + }); + expect(svg.querySelector('path[d]')).toBeInTheDocument(); + }); + }); + + describe('axis behavior and placement', () => { + it('renders multiple y axes for different series', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-multi-y', + series: [ + { id: 'left-series', data: [10, 20, 30, 40, 50], yAxisId: 'left-axis' }, + { id: 'right-series', data: [1, 2, 3, 4, 5], yAxisId: 'right-axis' }, + ], + chartProps: { + yAxis: [ + { id: 'left-axis', scaleType: 'linear' }, + { id: 'right-axis', scaleType: 'linear' }, + ], + }, + children: ( + <> + + + + + + ), + }); + + const yAxes = svg.querySelectorAll('[data-axis="y"]'); + expect(yAxes.length).toBe(2); + expect(svg.querySelector('[data-axis="y"][data-position="left"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"][data-position="right"]')).toBeInTheDocument(); + }); + + it('renders multiple y axes on the same side', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-multi-y-left', + series: [ + { id: 'a', data: [10, 20, 30], yAxisId: 'axis-a' }, + { id: 'b', data: [1, 2, 3], yAxisId: 'axis-b' }, + ], + chartProps: { + yAxis: [ + { id: 'axis-a', scaleType: 'linear' }, + { id: 'axis-b', scaleType: 'linear' }, + ], + }, + children: ( + <> + + + + + + ), + }); + + expect(svg.querySelectorAll('[data-axis="y"][data-position="left"]').length).toBe(2); + }); + + it('renders multiple x axes in horizontal layout', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-multi-x-horizontal', + series: [ + { id: 'series-a', data: [10, 20, 30], xAxisId: 'x-a' }, + { id: 'series-b', data: [100, 200, 300], xAxisId: 'x-b' }, + ], + chartProps: { + layout: 'horizontal', + xAxis: [ + { id: 'x-a', scaleType: 'linear' }, + { id: 'x-b', scaleType: 'linear' }, + ], + yAxis: { scaleType: 'band' }, + }, + children: ( + <> + + + + + + + ), + }); + + const xAxes = svg.querySelectorAll('[data-axis="x"]'); + expect(xAxes.length).toBe(2); + expect(svg.querySelector('[data-axis="x"][data-position="bottom"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-axis="x"][data-position="top"]')).toBeInTheDocument(); + }); + + it('throws when horizontal layout is configured with multiple y axes', () => { + expect(() => + renderCartesianChart({ + testID: 'cartesian-invalid-horizontal-multi-y', + chartProps: { + layout: 'horizontal', + yAxis: [ + { id: 'y-a', scaleType: 'band' }, + { id: 'y-b', scaleType: 'band' }, + ], + }, + }), + ).toThrow('only one y-axis'); + }); + + it('throws when vertical layout is configured with multiple x axes', () => { + expect(() => + renderCartesianChart({ + testID: 'cartesian-invalid-vertical-multi-x', + chartProps: { + layout: 'vertical', + xAxis: [ + { id: 'x-a', scaleType: 'linear' }, + { id: 'x-b', scaleType: 'linear' }, + ], + }, + }), + ).toThrow('layout="horizontal"'); + }); + + it.each([ + { + name: 'x-axis top', + child: , + selector: '[data-axis="x"][data-position="top"]', + testID: 'cartesian-axis-top', + }, + { + name: 'x-axis bottom', + child: , + selector: '[data-axis="x"][data-position="bottom"]', + testID: 'cartesian-axis-bottom', + }, + { + name: 'y-axis left', + child: , + selector: '[data-axis="y"][data-position="left"]', + testID: 'cartesian-axis-left', + }, + { + name: 'y-axis right', + child: , + selector: '[data-axis="y"][data-position="right"]', + testID: 'cartesian-axis-right', + }, + ])('renders $name', ({ child, selector, testID }) => { + const svg = renderCartesianChart({ + testID, + children: ( + <> + {child} + + + ), + }); + expect(svg.querySelector(selector)).toBeInTheDocument(); + }); + + it.each([ + { + name: 'x-axis line when showLine is true', + child: , + selector: '[data-testid="x-axis-line"]', + expected: true, + testID: 'cartesian-x-line-visible', + }, + { + name: 'x-axis line when showLine is false', + child: , + selector: '[data-testid="x-axis-line"]', + expected: false, + testID: 'cartesian-x-line-hidden', + }, + { + name: 'y-axis line when showLine is true', + child: , + selector: '[data-testid="y-axis-line"]', + expected: true, + testID: 'cartesian-y-line-visible', + }, + { + name: 'y-axis line when showLine is false', + child: , + selector: '[data-testid="y-axis-line"]', + expected: false, + testID: 'cartesian-y-line-hidden', + }, + { + name: 'x-axis tick marks when showTickMarks is true', + child: , + selector: '[data-testid="x-axis-tick-marks"]', + expected: true, + testID: 'cartesian-x-ticks-visible', + }, + { + name: 'x-axis tick marks when showTickMarks is false', + child: , + selector: '[data-testid="x-axis-tick-marks"]', + expected: false, + testID: 'cartesian-x-ticks-hidden', + }, + { + name: 'y-axis tick marks when showTickMarks is true', + child: , + selector: '[data-testid="y-axis-tick-marks"]', + expected: true, + testID: 'cartesian-y-ticks-visible', + }, + { + name: 'y-axis tick marks when showTickMarks is false', + child: , + selector: '[data-testid="y-axis-tick-marks"]', + expected: false, + testID: 'cartesian-y-ticks-hidden', + }, + { + name: 'x-axis grid when showGrid is true', + child: , + selector: '[data-testid="x-axis-grid"]', + expected: true, + testID: 'cartesian-x-grid-visible', + }, + { + name: 'y-axis grid when showGrid is true', + child: , + selector: '[data-testid="y-axis-grid"]', + expected: true, + testID: 'cartesian-y-grid-visible', + }, + ])('$name', ({ child, selector, expected, testID }) => { + const svg = renderCartesianChart({ + testID, + children: ( + <> + {child} + + + ), + }); + expect(Boolean(svg.querySelector(selector))).toBe(expected); + }); + + it('renders x-axis categorical labels from chart config', () => { + renderCartesianChart({ + testID: 'cartesian-categorical-x', + chartProps: { xAxis: { data: ['A', 'B', 'C', 'D', 'E'] } }, + children: ( + <> + + + + ), + }); + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + it('renders x-axis with numeric x data', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-numeric-x', + chartProps: { xAxis: { data: [1, 2, 3, 5, 8] } }, + children: ( + <> + + + + ), + }); + expect(svg.querySelector('[data-axis="x"]')).toBeInTheDocument(); + }); + }); + + describe('axis labels', () => { + it('shows both axis labels when provided', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-with-both-labels', + children: ( + <> + + + + + ), + }); + expect(svg.querySelector('[data-testid="x-axis-label"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).toBeInTheDocument(); + }); + + it('hides x-axis label when x-axis label is not provided', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-no-x-label', + children: ( + <> + + + + + ), + }); + expect(svg.querySelector('[data-testid="x-axis-label"]')).not.toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).toBeInTheDocument(); + }); + + it('hides y-axis label when y-axis label is not provided', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-no-y-label', + children: ( + <> + + + + + ), + }); + expect(svg.querySelector('[data-testid="x-axis-label"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).not.toBeInTheDocument(); + }); + + it('hides both axis labels when none are provided', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-no-axis-labels', + children: ( + <> + + + + + ), + }); + expect(svg.querySelector('[data-testid="x-axis-label"]')).not.toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).not.toBeInTheDocument(); + }); + }); + + describe('legend integration', () => { + it('renders custom legend node', () => { + renderCartesianChart({ + testID: 'cartesian-custom-legend-node', + chartProps: { + legend:
    Custom Legend
    , + }, + }); + expect(screen.getByTestId('custom-legend-node')).toBeInTheDocument(); + }); + + it('does not render default legend when legend is false', () => { + renderCartesianChart({ + testID: 'cartesian-legend-disabled', + chartProps: { + legend: false, + }, + }); + expect(screen.queryByLabelText('Legend')).not.toBeInTheDocument(); + }); + + it.each(['top', 'bottom', 'left', 'right'] as const)( + 'renders default legend when legend position is %s', + (legendPosition) => { + renderCartesianChart({ + testID: `cartesian-legend-${legendPosition}`, + chartProps: { + legend: true, + legendPosition, + }, + }); + expect(screen.getByLabelText('Legend')).toBeInTheDocument(); + }, + ); + }); + + describe('accessibility and scrubbing flags', () => { + it('applies accessibilityLabel to svg', () => { + const root = renderCartesianChart({ + testID: 'cartesian-accessibility-label', + chartProps: { accessibilityLabel: 'Revenue trend chart' }, + }); + const svg = root.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg?.getAttribute('aria-label')).toBe('Revenue trend chart'); + }); + + it('applies aria-labelledby to svg', () => { + const root = renderCartesianChart({ + testID: 'cartesian-aria-labelledby', + chartProps: { 'aria-labelledby': 'chart-heading' }, + }); + const svg = root.querySelector('svg'); + expect(svg).toBeInTheDocument(); + const labelledBy = + svg?.getAttribute('aria-labelledby') ?? root.getAttribute('aria-labelledby'); + expect(labelledBy).toBe('chart-heading'); + }); + + it('adds keyboard focus tabIndex when enableScrubbing is true', () => { + const root = renderCartesianChart({ + testID: 'cartesian-scrubbing-focus', + chartProps: { enableScrubbing: true }, + }); + const svg = root.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg?.getAttribute('tabindex')).toBe('0'); + }); + + it('does not add keyboard focus tabIndex when enableScrubbing is false', () => { + const root = renderCartesianChart({ + testID: 'cartesian-no-scrubbing-focus', + chartProps: { enableScrubbing: false }, + }); + const svg = root.querySelector('svg'); + expect(svg).toBeInTheDocument(); + expect(svg?.getAttribute('tabindex')).toBeNull(); + }); + }); + + describe('compositions', () => { + it('renders line and area composition', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-line-area', + children: ( + <> + + + + ), + }); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(1); + }); + + it('renders bar plot composition', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-bar-plot', + series: [{ id: 'bars', data: [10, 20, 30, 40, 50] }], + chartProps: { + xAxis: { + scaleType: 'band', + data: ['A', 'B', 'C', 'D', 'E'], + }, + }, + children: , + }); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(0); + }); + + it('routes horizontal bar series to their configured x-axis scales', () => { + const CustomBar = jest.fn((props: BarComponentProps) => ); + + renderCartesianChart({ + testID: 'cartesian-horizontal-bar-multi-x-routing', + series: [ + { id: 'fast-axis-series', data: [10, 10, 10], xAxisId: 'fast-axis' }, + { id: 'slow-axis-series', data: [10, 10, 10], xAxisId: 'slow-axis' }, + ], + chartProps: { + layout: 'horizontal', + xAxis: [ + { id: 'fast-axis', domain: { min: 0, max: 10 }, domainLimit: 'strict' }, + { id: 'slow-axis', domain: { min: 0, max: 100 }, domainLimit: 'strict' }, + ], + yAxis: { scaleType: 'band' }, + }, + children: ( + + ), + }); + + const fastAxisWidths = CustomBar.mock.calls + .filter(([props]) => props.seriesId === 'fast-axis-series') + .map(([props]) => props.width as number); + const slowAxisWidths = CustomBar.mock.calls + .filter(([props]) => props.seriesId === 'slow-axis-series') + .map(([props]) => props.width as number); + + expect(fastAxisWidths.length).toBeGreaterThan(0); + expect(slowAxisWidths.length).toBeGreaterThan(0); + expect(Math.min(...fastAxisWidths)).toBeGreaterThan(Math.max(...slowAxisWidths)); + }); + + it('renders mixed line and bar composition', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-line-bar', + series: [ + { id: 'bars', data: [10, 20, 30, 40, 50] }, + { id: 'line', data: [5, 10, 15, 10, 5] }, + ], + chartProps: { + xAxis: { + scaleType: 'band', + data: ['A', 'B', 'C', 'D', 'E'], + }, + }, + children: ( + <> + + + + ), + }); + + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(1); + }); + + it('renders point overlay with label', () => { + renderCartesianChart({ + testID: 'cartesian-point-overlay', + children: ( + <> + + + + ), + }); + expect(screen.getByText('Peak')).toBeInTheDocument(); + }); + + it('renders horizontal reference line in composition', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-horizontal-reference', + children: ( + <> + + + + ), + }); + expect(svg.querySelector('path[stroke="#cc0000"]')).toBeInTheDocument(); + }); + + it('renders vertical reference line in composition', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-vertical-reference', + chartProps: { + xAxis: { data: [1, 2, 3, 4, 5] }, + }, + children: ( + <> + + + + ), + }); + expect(svg.querySelector('path[stroke="#00aa00"]')).toBeInTheDocument(); + }); + + it('does not render scrubber overlay while idle', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-scrubber-default', + chartProps: { + enableScrubbing: true, + }, + children: ( + <> + + + + ), + }); + expect( + svg.querySelector('g[data-component="scrubber-group"] rect[opacity="0.8"]'), + ).toBeNull(); + }); + + it('hides scrubber overlay when hideOverlay is true', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-scrubber-no-overlay', + chartProps: { + enableScrubbing: true, + }, + children: ( + <> + + + + ), + }); + expect(svg.querySelector('[data-testid="scrubber-overlay"]')).not.toBeInTheDocument(); + }); + + it('renders scrubber beacons only for provided seriesIds', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-scrubber-series-filter', + series: multiSeries, + chartProps: { + enableScrubbing: true, + }, + children: ( + <> + + + + + ), + }); + + expect(svg.querySelector('[data-testid="scrubber-alpha"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="scrubber-beta"]')).not.toBeInTheDocument(); + }); + + it('renders scrubber beacons for all series when seriesIds are not provided', () => { + const svg = renderCartesianChart({ + testID: 'cartesian-scrubber-all-series', + series: multiSeries, + chartProps: { + enableScrubbing: true, + }, + children: ( + <> + + + + + ), + }); + + expect(svg.querySelector('[data-testid="scrubber-alpha"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="scrubber-beta"]')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web-visualization/src/chart/area/Area.tsx b/packages/web-visualization/src/chart/area/Area.tsx index 74e9c52221..532282a9fd 100644 --- a/packages/web-visualization/src/chart/area/Area.tsx +++ b/packages/web-visualization/src/chart/area/Area.tsx @@ -1,8 +1,8 @@ import React, { memo, useMemo } from 'react'; import type { SVGProps } from 'react'; -import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; +import type { PathBaseProps, PathProps } from '../Path'; import { type ChartPathCurveType, getAreaPath, type GradientDefinition } from '../utils'; import { DottedArea } from './DottedArea'; @@ -37,16 +37,19 @@ export type AreaBaseProps = { * The color of the area. * @default color of the series or 'var(--color-fgPrimary)' */ - fill?: string; + fill?: PathBaseProps['fill']; /** * Opacity of the area * @note when combined with gradient, both will be applied * @default 1 */ - fillOpacity?: number; + fillOpacity?: PathBaseProps['fillOpacity']; /** * Baseline value for the gradient. * When set, overrides the default baseline. + * + * @deprecated this prop has no functionality. Use 'baseline' on axis config instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v5 */ baseline?: number; /** @@ -58,27 +61,29 @@ export type AreaBaseProps = { * Whether to animate the area. * Overrides the animate value from the chart context. */ - animate?: boolean; + animate?: PathBaseProps['animate']; }; -export type AreaProps = AreaBaseProps & { - /** - * Transition configuration for path animations. - */ - transition?: Transition; -}; +export type AreaProps = AreaBaseProps & Pick; export type AreaComponentProps = Pick< AreaProps, - 'fill' | 'fillOpacity' | 'baseline' | 'gradient' | 'animate' | 'transition' + 'fill' | 'fillOpacity' | 'baseline' | 'gradient' | 'animate' | 'transitions' | 'transition' > & { /** * Path of the area */ d: SVGProps['d']; + /** + * ID of the x-axis to use. + * If not provided, defaults to the default x-axis. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * ID of the y-axis to use. * If not provided, defaults to the default y-axis. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; }; @@ -93,13 +98,14 @@ export const Area = memo( AreaComponent: AreaComponentProp, fill: fillProp, fillOpacity = 1, - baseline, connectNulls, gradient: gradientProp, + transitions, transition, animate, }) => { - const { getSeries, getSeriesData, getXScale, getYScale, getXAxis } = useCartesianChartContext(); + const { layout, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = + useCartesianChartContext(); const matchedSeries = useMemo(() => getSeries(seriesId), [seriesId, getSeries]); const gradient = useMemo( @@ -110,17 +116,27 @@ export const Area = memo( const sourceData = useMemo(() => getSeriesData(seriesId), [seriesId, getSeriesData]); - const xAxis = getXAxis(); - const xScale = getXScale(); + const xAxis = getXAxis(matchedSeries?.xAxisId); + const xScale = getXScale(matchedSeries?.xAxisId); const yScale = getYScale(matchedSeries?.yAxisId); + const yAxis = getYAxis(matchedSeries?.yAxisId); + + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxis = useMemo(() => { + return categoryAxisIsX ? xAxis : yAxis; + }, [categoryAxisIsX, xAxis, yAxis]); const area = useMemo(() => { if (!sourceData || sourceData.length === 0 || !xScale || !yScale) return ''; - // Get numeric x-axis data if available - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) + const indexData = + categoryAxis?.data && + Array.isArray(categoryAxis.data) && + typeof categoryAxis.data[0] === 'number' + ? (categoryAxis.data as number[]) : undefined; return getAreaPath({ @@ -128,10 +144,12 @@ export const Area = memo( xScale, yScale, curve, - xData, + xData: categoryAxisIsX ? indexData : undefined, + yData: !categoryAxisIsX ? indexData : undefined, connectNulls, + layout, }); - }, [sourceData, xScale, yScale, curve, xAxis?.data, connectNulls]); + }, [sourceData, xScale, yScale, curve, categoryAxis, categoryAxisIsX, connectNulls, layout]); const AreaComponent = useMemo((): AreaComponent => { if (AreaComponentProp) { @@ -154,12 +172,13 @@ export const Area = memo( return ( ); diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index d53a61a8a5..0f365dfc9e 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -8,11 +8,10 @@ import { } from '../CartesianChart'; import { Line, type LineProps } from '../line/Line'; import { - type AxisConfigProps, - defaultChartInset, + type CartesianAxisConfigProps, defaultStackId, - getChartInset, type Series, + withBaselineDomain, } from '../utils'; import { Area, type AreaProps } from './Area'; @@ -21,7 +20,14 @@ export type AreaSeries = Series & Partial< Pick< AreaProps, - 'AreaComponent' | 'curve' | 'fillOpacity' | 'type' | 'fill' | 'connectNulls' | 'transition' + | 'AreaComponent' + | 'curve' + | 'fillOpacity' + | 'type' + | 'fill' + | 'connectNulls' + | 'transitions' + | 'transition' > > & Partial> & { @@ -36,7 +42,13 @@ export type AreaSeries = Series & export type AreaChartBaseProps = Omit & Pick< AreaProps, - 'AreaComponent' | 'curve' | 'fillOpacity' | 'type' | 'connectNulls' | 'transition' + | 'AreaComponent' + | 'curve' + | 'fillOpacity' + | 'type' + | 'connectNulls' + | 'transitions' + | 'transition' > & Pick & { /** @@ -76,13 +88,13 @@ export type AreaChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type AreaChartProps = AreaChartBaseProps & @@ -99,6 +111,7 @@ export const AreaChart = memo( fillOpacity, type, connectNulls, + transitions, transition, LineComponent, strokeWidth, @@ -114,8 +127,6 @@ export const AreaChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert AreaSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( @@ -124,9 +135,11 @@ export const AreaChart = memo( data: s.data, label: s.label, color: s.color, + xAxisId: s.xAxisId, yAxisId: s.yAxisId, stackId: s.stackId, gradient: s.gradient, + legendShape: s.legendShape, }), ); }, [series]); @@ -146,6 +159,8 @@ export const AreaChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; const { @@ -155,50 +170,43 @@ export const AreaChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, - domain: xDomain, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, }; - const hasNegativeValues = useMemo(() => { - if (!series) return false; - return series.some((s) => - s.data?.some( - (value: number | null | [number, number]) => - (typeof value === 'number' && value < 0) || - (Array.isArray(value) && value.some((v) => typeof v === 'number' && v < 0)), - ), - ); - }, [series]); - - // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, - domain: hasNegativeValues ? yDomain : { min: 0, ...yDomain }, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, }; return ( - {showXAxis && } + {showXAxis && } {showYAxis && } {series?.map( ({ @@ -210,7 +218,7 @@ export const AreaChart = memo( opacity, LineComponent, stackId, - transition: seriesTransition, + legendShape, ...areaPropsFromSeries }) => ( @@ -237,9 +246,8 @@ export const AreaChart = memo( fill, fillOpacity, stackId, + legendShape, type, // Area type (don't pass to Line) - lineType: seriesLineType, - transition: seriesTransition, ...otherPropsFromSeries }) => { return ( @@ -250,8 +258,9 @@ export const AreaChart = memo( curve={curve} seriesId={id} strokeWidth={strokeWidth} - transition={seriesTransition ?? transition} - type={seriesLineType ?? lineType} + transition={transition} + transitions={transitions} + type={lineType} {...otherPropsFromSeries} /> ); diff --git a/packages/web-visualization/src/chart/area/DottedArea.tsx b/packages/web-visualization/src/chart/area/DottedArea.tsx index df83fa5775..211ac1f68d 100644 --- a/packages/web-visualization/src/chart/area/DottedArea.tsx +++ b/packages/web-visualization/src/chart/area/DottedArea.tsx @@ -57,28 +57,37 @@ export const DottedArea = memo( dotSize = 1, peakOpacity = 1, baselineOpacity = 0, - baseline, + xAxisId, yAxisId, gradient: gradientProp, animate, + transitions, transition, ...pathProps }) => { - const { getYAxis } = useCartesianChartContext(); + const { layout, getXAxis, getYAxis } = useCartesianChartContext(); const patternId = useId(); const gradientId = useId(); const maskId = useId(); const dotCenterPosition = patternSize / 2; - const yAxisConfig = getYAxis(yAxisId); + const valueAxisConfig = layout !== 'horizontal' ? getYAxis(yAxisId) : getXAxis(xAxisId); + const gradientAxis = layout !== 'horizontal' ? 'y' : 'x'; const gradient = useMemo(() => { if (gradientProp) return gradientProp; - if (!yAxisConfig) return; + if (!valueAxisConfig) return; - const baselineValue = getBaseline(yAxisConfig.domain, baseline); - return createGradient(yAxisConfig.domain, baselineValue, fill, peakOpacity, baselineOpacity); - }, [gradientProp, yAxisConfig, fill, baseline, peakOpacity, baselineOpacity]); + const baselineValue = getBaseline(valueAxisConfig.domain, valueAxisConfig.baseline); + return createGradient( + valueAxisConfig.domain, + baselineValue, + fill, + peakOpacity, + baselineOpacity, + gradientAxis, + ); + }, [gradientProp, valueAxisConfig, fill, peakOpacity, baselineOpacity, gradientAxis]); return ( @@ -94,14 +103,21 @@ export const DottedArea = memo( - + {gradient && ( )} @@ -112,6 +128,7 @@ export const DottedArea = memo( fill={gradient ? `url(#${gradientId})` : fill} mask={`url(#${maskId})`} transition={transition} + transitions={transitions} {...pathProps} /> diff --git a/packages/web-visualization/src/chart/area/GradientArea.tsx b/packages/web-visualization/src/chart/area/GradientArea.tsx index f55f0c5ad7..e55a9f9a94 100644 --- a/packages/web-visualization/src/chart/area/GradientArea.tsx +++ b/packages/web-visualization/src/chart/area/GradientArea.tsx @@ -50,25 +50,34 @@ export const GradientArea = memo( fillOpacity = 1, peakOpacity = 0.3, baselineOpacity = 0, - baseline, + xAxisId, yAxisId, gradient: gradientProp, animate, + transitions, transition, ...pathProps }) => { - const { getYAxis } = useCartesianChartContext(); + const { layout, getXAxis, getYAxis } = useCartesianChartContext(); const patternId = useId(); - const yAxisConfig = getYAxis(yAxisId); + const valueAxisConfig = layout !== 'horizontal' ? getYAxis(yAxisId) : getXAxis(xAxisId); + const gradientAxis = layout !== 'horizontal' ? 'y' : 'x'; const gradient = useMemo(() => { if (gradientProp) return gradientProp; - if (!yAxisConfig) return; + if (!valueAxisConfig) return; - const baselineValue = getBaseline(yAxisConfig.domain, baseline); - return createGradient(yAxisConfig.domain, baselineValue, fill, peakOpacity, baselineOpacity); - }, [gradientProp, yAxisConfig, fill, baseline, peakOpacity, baselineOpacity]); + const baselineValue = getBaseline(valueAxisConfig.domain, valueAxisConfig.baseline); + return createGradient( + valueAxisConfig.domain, + baselineValue, + fill, + peakOpacity, + baselineOpacity, + gradientAxis, + ); + }, [gradientProp, valueAxisConfig, fill, peakOpacity, baselineOpacity, gradientAxis]); return ( <> @@ -78,7 +87,8 @@ export const GradientArea = memo( animate={animate} gradient={gradient} id={patternId} - transition={transition} + transition={transitions?.update ?? transition} + xAxisId={xAxisId} yAxisId={yAxisId} /> @@ -89,6 +99,7 @@ export const GradientArea = memo( fill={gradient ? `url(#${patternId})` : fill} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} /> diff --git a/packages/web-visualization/src/chart/area/SolidArea.tsx b/packages/web-visualization/src/chart/area/SolidArea.tsx index 8df896e2b7..14cc878c6e 100644 --- a/packages/web-visualization/src/chart/area/SolidArea.tsx +++ b/packages/web-visualization/src/chart/area/SolidArea.tsx @@ -30,8 +30,10 @@ export const SolidArea = memo( d, fill = 'var(--color-fgPrimary)', fillOpacity = 1, + xAxisId, yAxisId, animate, + transitions, transition, gradient, ...pathProps @@ -46,7 +48,8 @@ export const SolidArea = memo( animate={animate} gradient={gradient} id={patternId} - transition={transition} + transition={transitions?.update ?? transition} + xAxisId={xAxisId} yAxisId={yAxisId} /> @@ -57,6 +60,7 @@ export const SolidArea = memo( fill={gradient ? `url(#${patternId})` : fill} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} /> diff --git a/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx b/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx index 7c100cb40e..1f3c32b99d 100644 --- a/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx +++ b/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx @@ -1,7 +1,14 @@ +import React, { memo, useCallback } from 'react'; +import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; import { VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; -import { DottedLine } from '../../line'; +import { + DefaultReferenceLineLabel, + DottedLine, + ReferenceLine, + type ReferenceLineLabelComponentProps, +} from '../../line'; import { Scrubber } from '../../scrubber/Scrubber'; import { AreaChart } from '..'; @@ -24,108 +31,230 @@ const Example: React.FC< ); }; +const CustomBaseline = () => { + const candles = [...btcCandles].reverse().slice(0, 180); + const prices = candles.map((candle) => parseFloat(candle.close)); + const dates = candles.map((candle) => new Date(parseInt(candle.start, 10) * 1000)); + + const startingPrice = prices[0]; + + const formatPrice = useCallback((price: number) => { + return `$${price.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }, []); + + const formatDate = useCallback((date: Date) => { + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + }, []); + + const formatLabel = useCallback( + (dataIndex: number) => { + const price = prices[dataIndex]; + const date = dates[dataIndex]; + + return ( + <> + {formatPrice(price)} {formatDate(date)} + + ); + }, + [dates, formatDate, formatPrice, prices], + ); + + const PriceLabel = memo((props: ReferenceLineLabelComponentProps) => ( + + )); + + const getScrubberAccessibilityLabel = useCallback( + (index: number) => `${formatPrice(prices[index])} ${formatDate(dates[index])}`, + [dates, formatDate, formatPrice, prices], + ); + + return ( + + + ( + + )} + dataY={startingPrice} + label={formatPrice(startingPrice)} + /> + + ); +}; + export const All = () => { return ( - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + `${value}%` }} + yAxis={{ data: ['BTC', 'ETH', 'SOL', 'DOGE', 'ADA'], scaleType: 'band' }} + > + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/area/__tests__/AreaChart.test.tsx b/packages/web-visualization/src/chart/area/__tests__/AreaChart.test.tsx new file mode 100644 index 0000000000..bbe093b0c6 --- /dev/null +++ b/packages/web-visualization/src/chart/area/__tests__/AreaChart.test.tsx @@ -0,0 +1,291 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import type { LineComponentProps } from '../../line/Line'; +import type { AreaComponentProps } from '../Area'; +import { AreaChart } from '../AreaChart'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in axis label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('AreaChart', () => { + it('renders area content when enter transition is disabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart'); + const areaPath = svg.querySelector('path'); + expect(areaPath).toBeInTheDocument(); + expect(areaPath?.getAttribute('d')).toBeTruthy(); + + const clipRect = svg.querySelector('clipPath rect'); + expect(clipRect).toBeInTheDocument(); + expect(Number(clipRect?.getAttribute('width'))).toBeGreaterThan(0); + }); + + it('passes custom transitions to custom area components', () => { + const customTransitions = { + enter: { type: 'tween' as const, duration: 0.25 }, + update: { type: 'spring' as const, stiffness: 320, damping: 30 }, + }; + const CustomArea = jest.fn((props: AreaComponentProps) => ); + + render( + + + , + ); + + expect(CustomArea).toHaveBeenCalled(); + const firstCallProps = CustomArea.mock.calls[0][0]; + expect(firstCallProps.transitions).toEqual(customTransitions); + }); + + it('allows series-level transitions to override chart transitions', () => { + const chartTransitions = { + enter: { type: 'tween' as const, duration: 0.2 }, + update: { type: 'spring' as const, stiffness: 200, damping: 20 }, + }; + const seriesTransitions = { + enter: { type: 'tween' as const, duration: 0.5 }, + update: { type: 'spring' as const, stiffness: 500, damping: 45 }, + }; + const CustomArea = jest.fn((props: AreaComponentProps) => ); + + render( + + + , + ); + + const callProps = CustomArea.mock.calls.map(([props]) => props as AreaComponentProps); + const seriesAProps = callProps.find((props) => props.fill === '#111111'); + const seriesBProps = callProps.find((props) => props.fill === '#222222'); + + expect(seriesAProps).toBeDefined(); + expect(seriesBProps).toBeDefined(); + expect(seriesAProps?.transitions).toEqual(seriesTransitions); + expect(seriesBProps?.transitions).toEqual(chartTransitions); + }); + + it('passes transitions to stacked dotted area and line components', () => { + const customTransitions = { + enter: { type: 'spring' as const, stiffness: 700, damping: 80 }, + update: { type: 'spring' as const, stiffness: 700, damping: 20 }, + }; + const CustomArea = jest.fn((props: AreaComponentProps) => ); + const CustomLine = jest.fn((props: LineComponentProps) => ); + + render( + + + , + ); + + expect(CustomArea).toHaveBeenCalled(); + expect(CustomLine).toHaveBeenCalled(); + expect(CustomArea.mock.calls[0][0].transitions).toEqual(customTransitions); + expect(CustomLine.mock.calls[0][0].transitions).toEqual(customTransitions); + }); + + it('shows axes and axis labels when enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart-with-axes'); + expect(svg.querySelector('[data-axis="x"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="x-axis-label"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).toBeInTheDocument(); + }); + + it('hides axes when showXAxis and showYAxis are false', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart-no-axes'); + expect(svg.querySelector('[data-axis="x"]')).not.toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).not.toBeInTheDocument(); + }); + + it('renders area and line paths when showLines is enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart-with-lines'); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(1); + }); + + it('renders stacked series', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart-stacked'); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThanOrEqual(2); + }); + + it('renders categorical y-axis labels in horizontal layout', () => { + render( + + + , + ); + + const svg = screen.getByTestId('area-chart-horizontal-layout'); + expect(svg.querySelector('[data-axis="y"]')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + it('does not pass baseline to AreaComponent (value-axis baseline is read from context in subcomponents)', () => { + const CustomArea = jest.fn((props: AreaComponentProps) => ); + + render( + + + , + ); + + expect(CustomArea).toHaveBeenCalled(); + expect(CustomArea.mock.calls[0][0]).not.toHaveProperty('baseline'); + }); +}); diff --git a/packages/web-visualization/src/chart/area/__tests__/AreaChartBaseline.test.tsx b/packages/web-visualization/src/chart/area/__tests__/AreaChartBaseline.test.tsx new file mode 100644 index 0000000000..990afa2217 --- /dev/null +++ b/packages/web-visualization/src/chart/area/__tests__/AreaChartBaseline.test.tsx @@ -0,0 +1,135 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { AreaChart } from '../AreaChart'; + +jest.mock('../Area', () => ({ + Area: () => null, +})); + +jest.mock('../../line/Line', () => ({ + Line: () => null, +})); + +jest.mock('../../CartesianChart', () => ({ + CartesianChart: jest.fn(({ children }) => children), +})); + +describe('AreaChart baseline domain defaults', () => { + const mockedCartesianChart = jest.mocked(CartesianChart); + const getSingleAxisConfig = (axis: Config | Config[] | undefined): Config | undefined => + Array.isArray(axis) ? axis[0] : axis; + + beforeEach(() => { + mockedCartesianChart.mockClear(); + }); + + it('extends lower bound to baseline in vertical layout', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: 55, max: 84 })).toEqual({ min: 30, max: 84 }); + }); + + it('keeps bounds unchanged when baseline is already inside range', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: 20, max: 55 })).toEqual({ min: 20, max: 55 }); + }); + + it('extends upper bound to baseline when values are below it', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: -98, max: -52 })).toEqual({ min: -98, max: 30 }); + }); + + it('extends lower bound to baseline on horizontal value axis', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const xAxisConfig = getSingleAxisConfig(cartesianChartProps?.xAxis); + const xDomain = xAxisConfig?.domain; + expect(xAxisConfig?.baseline).toBe(30); + expect(typeof xDomain).toBe('function'); + if (typeof xDomain !== 'function') throw new Error('Expected x-axis domain function'); + expect(xDomain({ min: 55, max: 84 })).toEqual({ min: 30, max: 84 }); + }); + + it('preserves function domains on value axis', () => { + const customDomain = jest.fn((bounds: { min: number; max: number }) => bounds); + + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + expect(cartesianChartProps?.xAxis).toEqual( + expect.objectContaining({ + domain: customDomain, + }), + ); + }); +}); diff --git a/packages/web-visualization/src/chart/axis/Axis.tsx b/packages/web-visualization/src/chart/axis/Axis.tsx index b6a353eba4..18bcb213e5 100644 --- a/packages/web-visualization/src/chart/axis/Axis.tsx +++ b/packages/web-visualization/src/chart/axis/Axis.tsx @@ -90,7 +90,9 @@ export type AxisBaseProps = SharedProps & { * This value is passed into d3 and may not be respected. * @note This property is overridden when `ticks` is provided. * @note this property overrides the `tickInterval` property. - * @default 5 (for y-axis) + * @default 5 for value axes by layout: + * - X axis when chart layout is horizontal + * - Y axis when chart layout is vertical */ requestedTickCount?: number; /** @@ -240,18 +242,16 @@ export type AxisProps = AxisBaseProps & { * Formatter function for axis tick values. * Tick values will be wrapped in ChartText component. * + * For band scales with string data, the value will be the string label (e.g., "Jan", "Feb"). + * For numeric scales, the value will be the number. + * * @example - * // XAxis - * tickLabelFormatter: (index) => { - * if (index % 12 === 0) { - * return ${prices[index]}; - * } - * return `$${prices[index]}`; - * } + * // XAxis with categorical data + * tickLabelFormatter: (value) => String(value).toUpperCase() * * @example - * // YAxis - * tickLabelFormatter: (value) => `$${prices[value]}` + * // YAxis with numeric data + * tickLabelFormatter: (value) => `$${value}` */ tickLabelFormatter?: (value: number) => ChartTextChildren; /** diff --git a/packages/web-visualization/src/chart/axis/XAxis.tsx b/packages/web-visualization/src/chart/axis/XAxis.tsx index 1e690ffa00..ce6787ec87 100644 --- a/packages/web-visualization/src/chart/axis/XAxis.tsx +++ b/packages/web-visualization/src/chart/axis/XAxis.tsx @@ -37,6 +37,12 @@ const axisLineCss = css` `; export type XAxisBaseProps = AxisBaseProps & { + /** + * The ID of the axis to render. + * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + axisId?: string; /** * The position of the axis relative to the chart's drawing area. * @default 'bottom' @@ -53,6 +59,7 @@ export type XAxisProps = AxisProps & XAxisBaseProps; export const XAxis = memo( ({ + axisId, position = 'bottom', showGrid, requestedTickCount, @@ -85,6 +92,7 @@ export const XAxis = memo( const registrationId = useId(); const { animate, + layout, getXScale, getXAxis, registerAxis, @@ -93,8 +101,8 @@ export const XAxis = memo( drawingArea, } = useCartesianChartContext(); - const xScale = getXScale(); - const xAxis = getXAxis(); + const xScale = getXScale(axisId); + const xAxis = getXAxis(axisId); const axisBounds = getAxisBounds(registrationId); @@ -105,21 +113,18 @@ export const XAxis = memo( }, [registrationId, registerAxis, unregisterAxis, position, height]); const formatTick = useCallback( - (value: any) => { + (value: number) => { // If we have string labels and no custom formatter, use the labels const axisData = xAxis?.data; const hasStringLabels = axisData && Array.isArray(axisData) && typeof axisData[0] === 'string'; - let finalValue = value; - - // For band scales with string data, value is an index - if (hasStringLabels && typeof value === 'number' && axisData[value] !== undefined) { - finalValue = axisData[value]; + if (hasStringLabels && !tickLabelFormatter && axisData[value] !== undefined) { + return axisData[value]; } - // Use the formatter (if provided) or the value itself - return tickLabelFormatter?.(finalValue) ?? finalValue; + // Otherwise passes raw index to formatter + return tickLabelFormatter?.(value) ?? value; }, [xAxis?.data, tickLabelFormatter], ); @@ -146,7 +151,7 @@ export const XAxis = memo( return getAxisTicksData({ scaleFunction: xScale, ticks, - requestedTickCount, + requestedTickCount: requestedTickCount ?? (layout === 'horizontal' ? 5 : undefined), categories, possibleTickValues: axisData && Array.isArray(axisData) && typeof axisData[0] === 'string' @@ -158,7 +163,16 @@ export const XAxis = memo( maxStep: tickMaxStep, }, }); - }, [ticks, xScale, requestedTickCount, tickInterval, tickMinStep, tickMaxStep, xAxis?.data]); + }, [ + ticks, + xScale, + requestedTickCount, + tickInterval, + tickMinStep, + tickMaxStep, + xAxis?.data, + layout, + ]); const isBandScale = useMemo(() => { if (!xScale) return false; diff --git a/packages/web-visualization/src/chart/axis/YAxis.tsx b/packages/web-visualization/src/chart/axis/YAxis.tsx index db72b725cb..f65036c018 100644 --- a/packages/web-visualization/src/chart/axis/YAxis.tsx +++ b/packages/web-visualization/src/chart/axis/YAxis.tsx @@ -40,6 +40,7 @@ export type YAxisBaseProps = AxisBaseProps & { /** * The ID of the axis to render. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ axisId?: string; /** @@ -89,6 +90,7 @@ export const YAxis = memo( const registrationId = useId(); const { animate, + layout, getYScale, getYAxis, registerAxis, @@ -116,11 +118,10 @@ export const YAxis = memo( axisData && Array.isArray(axisData) && typeof axisData[0] === 'string'; if (hasStringLabels && !tickLabelFormatter && axisData[value] !== undefined) { - // Use the string label from the data array return axisData[value]; } - // Otherwise use the formatter (if provided) or the value itself + // Otherwise passes raw index to formatter return tickLabelFormatter?.(value) ?? value; }, [yAxis?.data, tickLabelFormatter], @@ -151,11 +152,14 @@ export const YAxis = memo( return getAxisTicksData({ scaleFunction: yScale as any, ticks, - requestedTickCount: tickInterval !== undefined ? undefined : (requestedTickCount ?? 5), + requestedTickCount: + tickInterval !== undefined + ? undefined + : (requestedTickCount ?? (layout === 'horizontal' ? undefined : 5)), categories, tickInterval: tickInterval, }); - }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data]); + }, [ticks, yScale, requestedTickCount, tickInterval, yAxis?.data, layout]); const isBandScale = useMemo(() => { if (!yScale) return false; diff --git a/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx b/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx index 8505da1b26..e7874ce869 100644 --- a/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx +++ b/packages/web-visualization/src/chart/axis/__stories__/Axis.stories.tsx @@ -501,52 +501,54 @@ const DomainLimitType = ({ limit }: { limit: 'nice' | 'strict' }) => { export const All = () => { return ( - - - - - - - - - - - - - - - - - - - - - Using a function to filter which ticks are shown on a band scale. - - } - title="Band Scale - Tick Filtering" - > - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + Using a function to filter which ticks are shown on a band scale. + + } + title="Band Scale - Tick Filtering" + > + + + + + + + + + + + + + + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/axis/__tests__/Axis.test.tsx b/packages/web-visualization/src/chart/axis/__tests__/Axis.test.tsx index 60593752ea..f7a2640f26 100644 --- a/packages/web-visualization/src/chart/axis/__tests__/Axis.test.tsx +++ b/packages/web-visualization/src/chart/axis/__tests__/Axis.test.tsx @@ -3,8 +3,17 @@ import { render, screen } from '@testing-library/react'; import { CartesianChart } from '../../CartesianChart'; import { Line } from '../../line/Line'; +import { getAxisTicksData } from '../../utils'; import { XAxis, YAxis } from '..'; +jest.mock('../../utils', () => { + const actual = jest.requireActual('../../utils'); + return { + ...actual, + getAxisTicksData: jest.fn(actual.getAxisTicksData), + }; +}); + jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ useDimensions: jest.fn(() => ({ observe: jest.fn(), @@ -35,7 +44,10 @@ beforeAll(() => { })); }); -const renderChart = (children: React.ReactNode) => { +const renderChart = ( + children: React.ReactNode, + chartProps: Partial> = {}, +) => { return render( { series={[{ id: 'test', data: [10, 20, 30, 40, 50] }]} testID="test-chart" width={600} + {...chartProps} > {children} @@ -192,6 +205,37 @@ describe('XAxis', () => { expect(tickMarkPaths?.length).toBeGreaterThan(0); }); }); + + describe('axis selection', () => { + it('uses axisId to select x-axis config when multiple x axes are provided', () => { + renderChart( + <> + + + + , + { + layout: 'horizontal', + xAxis: [ + { + id: 'x-a', + data: ['A1', 'A2', 'A3'], + scaleType: 'linear', + }, + { + id: 'x-b', + data: ['B1', 'B2', 'B3'], + scaleType: 'linear', + }, + ], + yAxis: { scaleType: 'band' }, + }, + ); + + expect(screen.getByText('A1')).toBeInTheDocument(); + expect(screen.getByText('B1')).toBeInTheDocument(); + }); + }); }); describe('Multiple Y Axes', () => { @@ -381,6 +425,80 @@ describe('Axis labels', () => { }); }); +describe('Layout-aware requestedTickCount defaults', () => { + it('defaults XAxis requestedTickCount to 5 only for horizontal layout', () => { + const getAxisTicksDataMock = jest.mocked(getAxisTicksData); + getAxisTicksDataMock.mockClear(); + + const horizontal = renderChart( + <> + + + , + { layout: 'horizontal' }, + ); + + expect(getAxisTicksDataMock).toHaveBeenCalled(); + const horizontalRequestedTickCount = + getAxisTicksDataMock.mock.calls[getAxisTicksDataMock.mock.calls.length - 1]?.[0] + ?.requestedTickCount; + expect(horizontalRequestedTickCount).toBe(5); + + horizontal.unmount(); + getAxisTicksDataMock.mockClear(); + + renderChart( + <> + + + , + { layout: 'vertical' }, + ); + + expect(getAxisTicksDataMock).toHaveBeenCalled(); + const verticalRequestedTickCount = + getAxisTicksDataMock.mock.calls[getAxisTicksDataMock.mock.calls.length - 1]?.[0] + ?.requestedTickCount; + expect(verticalRequestedTickCount).toBeUndefined(); + }); + + it('defaults YAxis requestedTickCount to 5 only for vertical layout', () => { + const getAxisTicksDataMock = jest.mocked(getAxisTicksData); + getAxisTicksDataMock.mockClear(); + + const vertical = renderChart( + <> + + + , + { layout: 'vertical' }, + ); + + expect(getAxisTicksDataMock).toHaveBeenCalled(); + const verticalRequestedTickCount = + getAxisTicksDataMock.mock.calls[getAxisTicksDataMock.mock.calls.length - 1]?.[0] + ?.requestedTickCount; + expect(verticalRequestedTickCount).toBe(5); + + vertical.unmount(); + getAxisTicksDataMock.mockClear(); + + renderChart( + <> + + + , + { layout: 'horizontal' }, + ); + + expect(getAxisTicksDataMock).toHaveBeenCalled(); + const horizontalRequestedTickCount = + getAxisTicksDataMock.mock.calls[getAxisTicksDataMock.mock.calls.length - 1]?.[0] + ?.requestedTickCount; + expect(horizontalRequestedTickCount).toBeUndefined(); + }); +}); + describe('Custom testID', () => { it('uses custom testID for YAxis elements', () => { renderChart( diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index e7f3f7b8b8..ec189c4699 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -1,79 +1,87 @@ import React, { memo, useMemo } from 'react'; import type { SVGProps } from 'react'; +import type { Rect } from '@coinbase/cds-common'; import type { Transition } from 'framer-motion'; -import { getBarPath } from '../utils'; +import { useCartesianChartContext } from '../ChartProvider'; +import { type BarTransition, getBarPath } from '../utils'; import { DefaultBar } from './'; -export type BarBaseProps = { - /** - * X coordinate of the bar (left edge). - */ - x: number; - /** - * Y coordinate of the bar (top edge). - */ - y: number; - /** - * Width of the bar. - */ - width: number; - /** - * Height of the bar. - */ - height: number; +export type BarBaseProps = Rect & { /** * Border radius for the bar. * @default 4 */ borderRadius?: number; - /** - * Whether to round the top of the bar. - */ + /** Whether to round the top of the bar. */ roundTop?: boolean; - /** - * Whether to round the bottom of the bar. - */ + /** Whether to round the bottom of the bar. */ roundBottom?: boolean; - /** - * Y coordinate of the baseline/origin for animations. - * Used to calculate initial animation state. - */ - originY?: number; - /** - * The x-axis data value for this bar. - */ - dataX?: number | string; - /** - * The y-axis data value for this bar. - */ + /** Origin of the bar. */ + origin?: number; + /** The x-axis data value for this bar. */ + dataX?: number | [number, number] | null; + /** The y-axis data value for this bar. */ dataY?: number | [number, number] | null; - /** - * Fill color for the bar. - */ + /** The ID of the series this bar belongs to. */ + seriesId?: string; + /** Fill color for the bar. */ fill?: string; - /** - * Fill opacity for the bar. - */ + /** Fill opacity for the bar. */ fillOpacity?: number; - /** - * Stroke color for the bar outline. - */ + /** Stroke color for the bar outline. */ stroke?: string; - /** - * Stroke width for the bar outline. - */ + /** Stroke width for the bar outline. */ strokeWidth?: number; - /** - * Component to render the bar. - */ + /** Component to render the bar. */ BarComponent?: BarComponent; + /** Minimum bar size in pixels. When set, bars shorter than this value are expanded. */ + minSize?: number; }; export type BarProps = BarBaseProps & { /** - * Transition configuration for animation. + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + * + * @default transitions = {{ + * enter: { type: 'spring', stiffness: 900, damping: 120, mass: 4, staggerDelay: 0.25 }, + * enterOpacity: { type: 'tween', duration: 0.2 }, + * update: { type: 'spring', stiffness: 900, damping: 120, mass: 4 } + * }} + * + * @example + * // Custom staggered enter and spring update + * transitions={{ enter: { type: 'tween', duration: 0.5, staggerDelay: 0.3 }, update: { type: 'spring', damping: 20 } }} + * + * @example + * // Disable enter animation + * transitions={{ enter: null }} + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: BarTransition | null; + /** + * Transition for the initial enter opacity animation. + * Uses a default subtle fade when undefined (unless `enter` is disabled). + * @note falls back to `enter` timing offsets (`delay` and `staggerDelay`) when not provided. + * Set to `null` to disable enter opacity animation. Automatically set to null if enter transition is disabled. + */ + enterOpacity?: BarTransition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: BarTransition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ transition?: Transition; }; @@ -105,9 +113,10 @@ export const Bar = memo( y, width, height, - originY, + origin: originProp, dataX, dataY, + seriesId, BarComponent = DefaultBar, fill = 'var(--color-fgPrimary)', fillOpacity = 1, @@ -116,17 +125,22 @@ export const Bar = memo( borderRadius = 4, roundTop = true, roundBottom = true, + minSize, + transitions, transition, }) => { + const { layout } = useCartesianChartContext(); + const barPath = useMemo(() => { - return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); - }, [x, y, width, height, borderRadius, roundTop, roundBottom]); + return getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout); + }, [x, y, width, height, borderRadius, roundTop, roundBottom, layout]); - const effectiveOriginY = originY ?? y + height; + const origin = useMemo( + () => originProp ?? (layout === 'horizontal' ? x : y + height), + [originProp, layout, x, y, height], + ); - if (!barPath) { - return null; - } + if (!barPath) return; return ( ( fill={fill} fillOpacity={fillOpacity} height={height} - originY={effectiveOriginY} + minSize={minSize} + origin={origin} roundBottom={roundBottom} roundTop={roundTop} + seriesId={seriesId} stroke={stroke} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} width={width} x={x} y={y} diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx index 5b819ae37d..5b56e45312 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -7,16 +7,26 @@ import { type CartesianChartProps, } from '../CartesianChart'; import { - type AxisConfigProps, - defaultChartInset, + type CartesianAxisConfigProps, defaultStackId, - getChartInset, type Series, + withBaselineDomain, } from '../utils'; import { BarPlot, type BarPlotProps } from './BarPlot'; +import type { BarSeries } from './BarStack'; -export type BarChartBaseProps = Omit & +export type BarChartBaseProps = Omit< + CartesianChartBaseProps, + | 'xAxis' + | 'yAxis' + | 'series' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' +> & Pick< BarPlotProps, | 'barPadding' @@ -30,12 +40,14 @@ export type BarChartBaseProps = Omit & { /** * Configuration objects that define how to visualize the data. + * Each series can optionally define its own BarComponent. */ - series?: Array; + series?: Array; /** * Whether to stack the areas on top of each other. * When true, each series builds cumulative values on top of the previous series. @@ -58,23 +70,33 @@ export type BarChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type BarChartProps = BarChartBaseProps & - Omit; + Omit< + CartesianChartProps, + | 'xAxis' + | 'yAxis' + | 'series' + | 'borderRadius' + | 'borderTopLeftRadius' + | 'borderTopRightRadius' + | 'borderBottomLeftRadius' + | 'borderBottomRightRadius' + >; export const BarChart = memo( forwardRef( ( { - series, + series: seriesProp, stacked, showXAxis, showYAxis, @@ -93,22 +115,21 @@ export const BarChart = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, ...chartProps }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - - const transformedSeries = useMemo(() => { - if (!stacked || !series) return series; - return series.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); - }, [series, stacked]); + const series: Array | undefined = useMemo(() => { + if (!stacked || !seriesProp) return seriesProp; + return seriesProp.map((s) => ({ ...s, stackId: s.stackId ?? defaultStackId })); + }, [seriesProp, stacked]); - // Unlike other charts with custom props per series, we do not need to pick out - // the props from each series that shouldn't be passed to CartesianChart - const seriesToRender = transformedSeries ?? series; - const seriesIds = seriesToRender?.map((s) => s.id); + const seriesIds = useMemo(() => series?.map((s) => s.id), [series]); + const isHorizontalLayout = chartProps.layout === 'horizontal'; + const defaultXScaleType = isHorizontalLayout ? 'linear' : 'band'; + const defaultYScaleType = isHorizontalLayout ? 'band' : 'linear'; // Split axis props into config props for Chart and visual props for axis components const { @@ -118,6 +139,8 @@ export const BarChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; const { @@ -127,50 +150,70 @@ export const BarChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; + const valueAxisBaseline = isHorizontalLayout ? xBaseline : yBaseline; - const xAxisConfig: Partial = { - scaleType: xScaleType ?? 'band', - data: xData, - categoryPadding: xCategoryPadding, - domain: xDomain, - domainLimit: xDomainLimit, - range: xRange, - }; - - const hasNegativeValues = useMemo(() => { - if (!series) return false; - return series.some((s) => - s.data?.some( - (value: number | null | [number, number]) => - (typeof value === 'number' && value < 0) || - (Array.isArray(value) && value.some((v) => typeof v === 'number' && v < 0)), - ), - ); - }, [series]); + const xAxisConfig = useMemo>( + () => ({ + scaleType: xScaleType ?? defaultXScaleType, + data: xData, + categoryPadding: xCategoryPadding, + domain: isHorizontalLayout ? withBaselineDomain(xDomain, valueAxisBaseline) : xDomain, + domainLimit: xDomainLimit, + range: xRange, + baseline: xBaseline, + }), + [ + xScaleType, + xData, + xCategoryPadding, + xDomain, + isHorizontalLayout, + xDomainLimit, + xRange, + xBaseline, + valueAxisBaseline, + defaultXScaleType, + ], + ); - // Set default min domain to 0 for area chart, but only if there are no negative values - const yAxisConfig: Partial = { - scaleType: yScaleType, - data: yData, - categoryPadding: yCategoryPadding, - domain: hasNegativeValues ? yDomain : { min: 0, ...yDomain }, - domainLimit: yDomainLimit, - range: yRange, - }; + const yAxisConfig = useMemo>( + () => ({ + scaleType: yScaleType ?? defaultYScaleType, + data: yData, + categoryPadding: yCategoryPadding, + domain: !isHorizontalLayout ? withBaselineDomain(yDomain, valueAxisBaseline) : yDomain, + domainLimit: yDomainLimit, + range: yRange, + baseline: yBaseline, + }), + [ + yScaleType, + yData, + yCategoryPadding, + yDomain, + isHorizontalLayout, + yDomainLimit, + yRange, + yBaseline, + valueAxisBaseline, + defaultYScaleType, + ], + ); return ( - {showXAxis && } + {showXAxis && } {showYAxis && } {children} diff --git a/packages/web-visualization/src/chart/bar/BarPlot.tsx b/packages/web-visualization/src/chart/bar/BarPlot.tsx index 0511f82b2a..34b87c3b89 100644 --- a/packages/web-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/web-visualization/src/chart/bar/BarPlot.tsx @@ -1,9 +1,10 @@ import { memo, useId, useMemo } from 'react'; +import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import type { Series } from '../utils'; -import { defaultAxisId } from '../utils'; +import { getStackGroups, instantTransition } from '../utils'; +import type { BarSeries } from './BarStack'; import type { BarStackGroupProps } from './BarStackGroup'; import { BarStackGroup } from './BarStackGroup'; @@ -28,7 +29,8 @@ export type BarPlotBaseProps = Pick< seriesIds?: string[]; }; -export type BarPlotProps = BarPlotBaseProps & Pick; +export type BarPlotProps = BarPlotBaseProps & + Pick; /** * BarPlot component that handles multiple series with proper stacking coordination. @@ -50,12 +52,13 @@ export const BarPlot = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, }) => { - const { series: allSeries, drawingArea } = useCartesianChartContext(); + const { animate, series: allSeries, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); - const targetSeries = useMemo(() => { + const targetSeries: BarSeries[] = useMemo(() => { // Then filter by seriesIds if provided if (seriesIds !== undefined) { return allSeries.filter((s: any) => seriesIds.includes(s.id)); @@ -64,51 +67,34 @@ export const BarPlot = memo( return allSeries; }, [allSeries, seriesIds]); - const stackGroups = useMemo(() => { - const groups = new Map< - string, - { - stackId: string; - series: Series[]; - yAxisId?: string; - } - >(); + const stackGroups = useMemo(() => getStackGroups(targetSeries), [targetSeries]); - // Group series into stacks based on stackId + yAxisId combination - targetSeries.forEach((series) => { - const yAxisId = series.yAxisId ?? defaultAxisId; - const stackId = series.stackId || `individual-${series.id}`; - const stackKey = `${stackId}:${yAxisId}`; + if (!drawingArea) return; - if (!groups.has(stackKey)) { - groups.set(stackKey, { - stackId: stackKey, - series: [], - yAxisId: series.yAxisId, - }); - } - - const group = groups.get(stackKey)!; - group.series.push(series); - }); - - return Array.from(groups.values()); - }, [targetSeries]); - - if (!drawingArea) { - return null; - } + // Clip path animation for bar is just for chart size changes, not for + // enter transition. One caveat, bar update transitions are staggered + // but clip path is not, so some bars could be clipped in rare cases return ( <> - + {animate ? ( + + ) : ( + + )} @@ -130,6 +116,8 @@ export const BarPlot = memo( strokeWidth={defaultStrokeWidth} totalStacks={stackGroups.length} transition={transition} + transitions={transitions} + xAxisId={group.xAxisId} yAxisId={group.yAxisId} /> ))} diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 37fb64c3c6..c29fd42f73 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -4,44 +4,64 @@ import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartScaleFunction, Series } from '../utils'; -import { evaluateGradientAtValue, getGradientConfig } from '../utils/gradient'; +import { EPSILON, getBars, getBaselinePx, getStackOrigin } from '../utils/bar'; +import { getGradientAxis, getGradientConfig } from '../utils/gradient'; -import { Bar, type BarComponent, type BarProps } from './Bar'; +import { Bar, type BarBaseProps, type BarComponent, type BarProps } from './Bar'; import { DefaultBarStack } from './DefaultBarStack'; -const EPSILON = 1e-4; +/** + * Extended series type that includes bar-specific properties. + */ +export type BarSeries = Series & { + /** + * Custom component to render bars for this series. + */ + BarComponent?: BarComponent; +}; export type BarStackBaseProps = Pick< - BarProps, + BarBaseProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' > & { /** * Array of series configurations that belong to this stack. */ - series: Series[]; + series: BarSeries[]; /** * The category index for this stack. */ categoryIndex: number; /** - * X position for this stack. + * Position of this stack along the index (categorical) axis. */ - x: number; + indexPos: number; /** - * Width of this stack. + * Thickness of this stack. */ - width: number; + thickness: number; + /** + * Scale for the independent (categorical) axis. + */ + indexScale: ChartScaleFunction; /** - * Y scale function. + * Scale for the dependent (magnitude) axis. */ - yScale: ChartScaleFunction; + valueScale: ChartScaleFunction; /** * Chart rect for bounds. */ rect: Rect; + /** + * X axis ID to use. + * If not provided, defaults to defaultAxisId. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Y axis ID to use. - * If not provided, will use the yAxisId from the first series. + * If not provided, defaults to defaultAxisId. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** @@ -68,25 +88,41 @@ export type BarStackBaseProps = Pick< stackMinSize?: number; }; -export type BarStackProps = BarStackBaseProps & { +export type BarStackProps = BarStackBaseProps & Pick; + +export type BarStackComponentProps = { /** - * Transition configuration for animation. + * The x position of the stack. */ - transition?: Transition; -}; - -export type BarStackComponentProps = Pick< - BarStackProps, - 'x' | 'width' | 'categoryIndex' | 'borderRadius' | 'transition' -> & { + x: number; /** * The y position of the stack. */ y: number; + /** + * The width of the stack. + */ + width: number; /** * The height of the stack. */ height: number; + /** + * The category index for this stack. + */ + categoryIndex: number; + /** + * Transition configuration for animation. + */ + transition?: Transition; + /** + * Transition configuration for enter and update animations. + */ + transitions?: BarProps['transitions']; + /** + * Border radius for the bars. + */ + borderRadius?: number; /** * The bar elements to render within the stack. */ @@ -100,9 +136,11 @@ export type BarStackComponentProps = Pick< */ roundBottom?: boolean; /** - * The y-origin for animations (baseline position). + * Stack animation origin. + * - number: baseline on the value axis + * - tuple: [start, end] clip range for stacked min-size enter animation */ - yOrigin?: number; + origin?: number | [number, number]; }; export type BarStackComponent = React.FC; @@ -115,10 +153,13 @@ export const BarStack = memo( ({ series, categoryIndex, - x, - width, - yScale, + indexPos, + thickness, + indexScale, + valueScale, rect, + xAxisId, + yAxisId, BarComponent: defaultBarComponent, fillOpacity: defaultFillOpacity, stroke: defaultStroke, @@ -129,590 +170,179 @@ export const BarStack = memo( barMinSize, stackMinSize, roundBaseline, + transitions, transition, }) => { - const { getSeriesData, getXAxis, getXScale, getSeries } = useCartesianChartContext(); - - const xScale = getXScale(); - const barMinSizePx = barMinSize; - const stackMinSizePx = stackMinSize; + const { layout, getSeriesData, getXAxis, getYAxis, getXScale, getYScale } = + useCartesianChartContext(); - const xAxis = getXAxis(); + const xAxis = getXAxis(xAxisId); + const yAxis = getYAxis(yAxisId); + const xScale = getXScale(xAxisId); + const yScale = getYScale(yAxisId); - const baseline = useMemo(() => { - const domain = yScale.domain(); - const [domainMin, domainMax] = domain; - const baselineValue = domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0; - const baseline = yScale(baselineValue) ?? rect.y + rect.height; + const baseline = useMemo( + () => (layout === 'vertical' ? yAxis : xAxis)?.baseline, + [layout, yAxis, xAxis], + ); - return Math.max(rect.y, Math.min(baseline, rect.y + rect.height)); - }, [rect.height, rect.y, yScale]); + const baselinePx = useMemo(() => { + return getBaselinePx(valueScale, rect, layout, baseline); + }, [rect, valueScale, layout, baseline]); const seriesGradients = useMemo(() => { return series.map((s) => { - if (!s.gradient || !xScale || !yScale) return null; + if (!s.gradient) return null; + if (!xScale || !yScale) return null; + + const gradientAxis = getGradientAxis(s.gradient, layout); + const evalScale = gradientAxis === 'x' ? xScale : yScale; - const gradientScale = s.gradient.axis === 'x' ? xScale : yScale; - const stops = getGradientConfig(s.gradient, xScale, yScale); + const stops = getGradientConfig(s.gradient, xScale, yScale, layout); if (!stops) return null; return { seriesId: s.id, gradient: s.gradient, - scale: gradientScale, + scale: evalScale, stops, }; }); - }, [series, xScale, yScale]); - - // Calculate bars for this specific category - const { bars, stackRect } = useMemo(() => { - let allBars: Array<{ - seriesId: string; - x: number; - y: number; - width: number; - height: number; - dataY?: number | [number, number] | null; - BarComponent?: BarComponent; - fill?: string; - fillOpacity?: number; - stroke?: string; - strokeWidth?: number; - borderRadius?: BarProps['borderRadius']; - roundTop?: boolean; - roundBottom?: boolean; - shouldApplyGap?: boolean; - }> = []; - - // Track how many bars we've stacked in each direction for gap calculation - let positiveBarCount = 0; - let negativeBarCount = 0; - - // Track stack bounds for clipping - let minY = Infinity; - let maxY = -Infinity; - - // Process each series in the stack - series.forEach((s) => { - const data = getSeriesData(s.id); - if (!data) return; - - const value = data[categoryIndex]; - if (value === null || value === undefined) return; - - const originalData = s.data; - const originalValue = originalData?.[categoryIndex]; - // Only apply gap logic if the original data wasn't tuple format - const shouldApplyGap = !Array.isArray(originalValue); - - // Sort to be in ascending order - const [bottom, top] = (value as [number, number]).sort((a, b) => a - b); - - const isAboveBaseline = bottom >= 0 && top !== bottom; - const isBelowBaseline = bottom <= 0 && bottom !== top; - - const barBottom = yScale(bottom) ?? baseline; - const barTop = yScale(top) ?? baseline; - - // Track bar counts for later gap calculations - if (shouldApplyGap) { - if (isAboveBaseline) { - positiveBarCount++; - } else if (isBelowBaseline) { - negativeBarCount++; - } - } - - // Calculate height (remember SVG y coordinates are inverted) - const height = Math.abs(barBottom - barTop); - const y = Math.min(barBottom, barTop); - - // Skip bars that would have zero or negative height - if (height <= 0) { - return; - } - - // Update stack bounds - minY = Math.min(minY, y); - maxY = Math.max(maxY, y + height); - - let barFill = s.color ?? 'var(--color-fgPrimary)'; - - // Evaluate gradient if provided (using precomputed stops) - const seriesGradientConfig = seriesGradients.find((g) => g?.seriesId === s.id); - if (seriesGradientConfig && originalValue !== null && originalValue !== undefined) { - const axis = seriesGradientConfig.gradient.axis ?? 'y'; - // For x-axis gradient, use the categoryIndex - // For y-axis gradient, use the ORIGINAL data value (not the processed top value) - // This is important for bar charts where originalValue might be a single number (e.g., -40, 15) - // or a tuple (e.g., [0, 10] for range bars) - let evalValue: number; - if (axis === 'x') { - evalValue = categoryIndex; - } else { - // Use original value for evaluation - handles both single numbers and tuples - evalValue = Array.isArray(originalValue) ? originalValue[1] : originalValue; - } - const evaluatedColor = evaluateGradientAtValue( - seriesGradientConfig.stops, - evalValue, - seriesGradientConfig.scale, - ); - if (evaluatedColor) { - // Only apply gradient color if fill is not explicitly set - barFill = evaluatedColor; - } - } - - allBars.push({ - seriesId: s.id, - x, - y, - width, - height, - dataY: value, // Store the actual data value - fill: barFill, - // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding - roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON, - roundBottom: roundBaseline || Math.abs(barBottom - baseline) >= EPSILON, - shouldApplyGap, - }); - }); - - // Apply proportional gap distribution to maintain total stack height - if (stackGap && allBars.length > 1) { - // Separate bars by baseline side - const barsAboveBaseline = allBars.filter((bar) => { - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - return bottom >= 0 && top !== bottom && bar.shouldApplyGap; - }); - const barsBelowBaseline = allBars.filter((bar) => { - const [bottom, top] = (bar.dataY as [number, number]).sort((a, b) => a - b); - return bottom <= 0 && bottom !== top && bar.shouldApplyGap; - }); - - // Apply proportional gaps to bars above baseline - if (barsAboveBaseline.length > 1) { - const totalGapSpace = stackGap * (barsAboveBaseline.length - 1); - const totalDataHeight = barsAboveBaseline.reduce((sum, bar) => sum + bar.height, 0); - const heightReduction = totalGapSpace / totalDataHeight; - - // Sort bars by position (from baseline upward) - const sortedBars = barsAboveBaseline.sort((a, b) => b.y - a.y); - - let currentY = baseline; - sortedBars.forEach((bar, index) => { - // Reduce bar height proportionally - const newHeight = bar.height * (1 - heightReduction); - const newY = currentY - newHeight; - - // Update the bar in allBars array - const barIndex = allBars.findIndex((b) => b.seriesId === bar.seriesId); - if (barIndex !== -1) { - allBars[barIndex] = { - ...allBars[barIndex], - height: newHeight, - y: newY, - }; - } - - // Move to next position (include gap for next bar) - currentY = newY - (index < sortedBars.length - 1 ? stackGap : 0); - }); - } - - // Apply proportional gaps to bars below baseline - if (barsBelowBaseline.length > 1) { - const totalGapSpace = stackGap * (barsBelowBaseline.length - 1); - const totalDataHeight = barsBelowBaseline.reduce((sum, bar) => sum + bar.height, 0); - const heightReduction = totalGapSpace / totalDataHeight; - - // Sort bars by position (from baseline downward) - const sortedBars = barsBelowBaseline.sort((a, b) => a.y - b.y); - - let currentY = baseline; - sortedBars.forEach((bar, index) => { - // Reduce bar height proportionally - const newHeight = bar.height * (1 - heightReduction); - - // Update the bar in allBars array - const barIndex = allBars.findIndex((b) => b.seriesId === bar.seriesId); - if (barIndex !== -1) { - allBars[barIndex] = { - ...allBars[barIndex], - height: newHeight, - y: currentY, - }; - } - - // Move to next position (include gap for next bar) - currentY = currentY + newHeight + (index < sortedBars.length - 1 ? stackGap : 0); - }); - } - - // Recalculate stack bounds after gap adjustments - if (allBars.length > 0) { - minY = Math.min(...allBars.map((bar) => bar.y)); - maxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); - } - } - - // Apply barMinSize constraints - if (barMinSizePx) { - // First, expand bars that need it and track the expansion - const expandedBars = allBars.map((bar, index) => { - if (bar.height < barMinSizePx) { - const heightIncrease = barMinSizePx - bar.height; - - const bottom = 0; - const top = 0; - - // Determine how to expand the bar - let newBottom = bottom; - let newTop = top; - - const scaleUnit = Math.abs((yScale(1) ?? 0) - (yScale(0) ?? 0)); - - if (bottom === 0) { - // Expand away from baseline (upward for positive) - newTop = top + heightIncrease / scaleUnit; - } else if (top === 0) { - // Expand away from baseline (downward for negative) - newBottom = bottom - heightIncrease / scaleUnit; - } else { - // Expand in both directions - const halfIncrease = heightIncrease / scaleUnit / 2; - newBottom = bottom - halfIncrease; - newTop = top + halfIncrease; - } - - // Recalculate bar position with new data values - const newBarBottom = yScale(newBottom) ?? baseline; - const newBarTop = yScale(newTop) ?? baseline; - const newHeight = Math.abs(newBarBottom - newBarTop); - const newY = Math.min(newBarBottom, newBarTop); - - return { - ...bar, - height: newHeight, - y: newY, - wasExpanded: true, - }; - } - return { ...bar, wasExpanded: false }; - }); - - // Now reposition all bars to avoid overlaps, similar to stackMinSize logic - - // Sort bars by position to maintain order - const sortedExpandedBars = [...expandedBars].sort((a, b) => a.y - b.y); - - // Determine if we have bars above and below baseline - const barsAboveBaseline = sortedExpandedBars.filter( - (bar) => bar.y + bar.height <= baseline, - ); - const barsBelowBaseline = sortedExpandedBars.filter((bar) => bar.y >= baseline); - - // Create a map of new positions - const newPositions = new Map(); - - // Start positioning from the baseline and work outward - let currentYAbove = baseline; // Start at baseline, work upward (decreasing Y) - let currentYBelow = baseline; // Start at baseline, work downward (increasing Y) - - // Position bars above baseline (positive values, decreasing Y) - for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { - const bar = barsAboveBaseline[i]; - const newY = currentYAbove - bar.height; - - newPositions.set(bar.seriesId, { y: newY, height: bar.height }); - - // Update currentYAbove for next bar (preserve gaps) - if (i > 0) { - const currentBar = barsAboveBaseline[i]; - const nextBar = barsAboveBaseline[i - 1]; - // Find original bars to get original gap - const originalCurrent = allBars.find((b) => b.seriesId === currentBar.seriesId)!; - const originalNext = allBars.find((b) => b.seriesId === nextBar.seriesId)!; - const originalGap = originalCurrent.y - (originalNext.y + originalNext.height); - currentYAbove = newY - originalGap; - } - } - - // Position bars below baseline (negative values, increasing Y) - for (let i = 0; i < barsBelowBaseline.length; i++) { - const bar = barsBelowBaseline[i]; - const newY = currentYBelow; - - newPositions.set(bar.seriesId, { y: newY, height: bar.height }); - - // Update currentYBelow for next bar (preserve gaps) - if (i < barsBelowBaseline.length - 1) { - const currentBar = barsBelowBaseline[i]; - const nextBar = barsBelowBaseline[i + 1]; - // Find original bars to get original gap - const originalCurrent = allBars.find((b) => b.seriesId === currentBar.seriesId)!; - const originalNext = allBars.find((b) => b.seriesId === nextBar.seriesId)!; - const originalGap = originalNext.y - (originalCurrent.y + originalCurrent.height); - currentYBelow = newY + bar.height + originalGap; - } - } - - // Apply new positions to all bars - allBars = expandedBars.map((bar) => { - const newPos = newPositions.get(bar.seriesId); - if (newPos) { - return { - ...bar, - y: newPos.y, - height: newPos.height, - }; - } - return bar; - }); - - // Recalculate stack bounds after barMinSize expansion and repositioning - if (allBars.length > 0) { - minY = Math.min(...allBars.map((bar) => bar.y)); - maxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); - } - } - - // Apply border radius logic (will be reapplied after stackMinSize if needed) - const applyBorderRadiusLogic = (bars: typeof allBars) => { - return bars - .sort((a, b) => b.y - a.y) - .map((a, index) => { - const barBefore = index > 0 ? bars[index - 1] : null; - const barAfter = index < bars.length - 1 ? bars[index + 1] : null; - - const shouldRoundTop = - index === bars.length - 1 || - (a.shouldApplyGap && stackGap) || - (!a.shouldApplyGap && barAfter && barAfter.y + barAfter.height !== a.y); - - const shouldRoundBottom = - index === 0 || - (a.shouldApplyGap && stackGap) || - (!a.shouldApplyGap && barBefore && barBefore.y !== a.y + a.height); - - return { - ...a, - roundTop: Boolean(a.roundTop && shouldRoundTop), - roundBottom: Boolean(a.roundBottom && shouldRoundBottom), - }; - }); - }; - - allBars = applyBorderRadiusLogic(allBars); - - // Calculate the bounding rect for the entire stack - let stackBounds = { - x, - y: minY === Infinity ? baseline : minY, - width, - height: maxY === -Infinity ? 0 : maxY - minY, - }; - - // Apply stackMinSize constraints - if (stackMinSizePx) { - if (allBars.length === 1 && stackBounds.height < stackMinSizePx) { - // For single bars (non-stacked), treat stackMinSize like barMinSize - - const bar = allBars[0]; - const heightIncrease = stackMinSizePx - bar.height; - - const bottom = 0; - const top = 0; - - // Determine how to expand the bar (same logic as barMinSize) - let newBottom = bottom; - let newTop = top; - - const scaleUnit = Math.abs((yScale(1) ?? 0) - (yScale(0) ?? 0)); - - if (bottom === 0) { - // Expand away from baseline (upward for positive) - newTop = top + heightIncrease / scaleUnit; - } else if (top === 0) { - // Expand away from baseline (downward for negative) - newBottom = bottom - heightIncrease / scaleUnit; - } else { - // Expand in both directions - const halfIncrease = heightIncrease / scaleUnit / 2; - newBottom = bottom - halfIncrease; - newTop = top + halfIncrease; - } - - // Recalculate bar position with new data values - const newBarBottom = yScale(newBottom) ?? baseline; - const newBarTop = yScale(newTop) ?? baseline; - const newHeight = Math.abs(newBarBottom - newBarTop); - const newY = Math.min(newBarBottom, newBarTop); - - allBars[0] = { - ...bar, - height: newHeight, - y: newY, - }; - - // Recalculate stack bounds - stackBounds = { - x, - y: newY, - width, - height: newHeight, - }; - } else if (allBars.length > 1 && stackBounds.height < stackMinSizePx) { - // For multiple bars (stacked), scale heights while preserving gaps - - // Calculate total bar height (excluding gaps) - const totalBarHeight = allBars.reduce((sum, bar) => sum + bar.height, 0); - const totalGapHeight = stackBounds.height - totalBarHeight; - - // Calculate how much we need to increase bar heights - const requiredBarHeight = stackMinSizePx - totalGapHeight; - const barScaleFactor = requiredBarHeight / totalBarHeight; - - // Sort bars by position to maintain order - const sortedBars = [...allBars].sort((a, b) => a.y - b.y); - - // Determine if we have bars above and below baseline - const barsAboveBaseline = sortedBars.filter((bar) => bar.y + bar.height <= baseline); - const barsBelowBaseline = sortedBars.filter((bar) => bar.y >= baseline); - - // Create a map of new positions - const newPositions = new Map(); - - // Start positioning from the baseline and work outward - let currentYAbove = baseline; // Start at baseline, work upward (decreasing Y) - let currentYBelow = baseline; // Start at baseline, work downward (increasing Y) - - // Position bars above baseline (positive values, decreasing Y) - for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { - const bar = barsAboveBaseline[i]; - const newHeight = bar.height * barScaleFactor; - const newY = currentYAbove - newHeight; - - newPositions.set(bar.seriesId, { y: newY, height: newHeight }); - - // Update currentYAbove for next bar (preserve gaps) - if (i > 0) { - const currentBar = barsAboveBaseline[i]; - const nextBar = barsAboveBaseline[i - 1]; - const originalGap = currentBar.y - (nextBar.y + nextBar.height); - currentYAbove = newY - originalGap; - } - } - - // Position bars below baseline (negative values, increasing Y) - for (let i = 0; i < barsBelowBaseline.length; i++) { - const bar = barsBelowBaseline[i]; - const newHeight = bar.height * barScaleFactor; - const newY = currentYBelow; - - newPositions.set(bar.seriesId, { y: newY, height: newHeight }); - - // Update currentYBelow for next bar (preserve gaps) - if (i < barsBelowBaseline.length - 1) { - const currentBar = barsBelowBaseline[i]; - const nextBar = barsBelowBaseline[i + 1]; - const originalGap = nextBar.y - (currentBar.y + currentBar.height); - currentYBelow = newY + newHeight + originalGap; - } - } - - // Apply new positions to all bars - allBars = allBars.map((bar) => { - const newPos = newPositions.get(bar.seriesId); - if (!newPos) return bar; - return { - ...bar, - height: newPos.height, - y: newPos.y, - }; - }); + }, [series, xScale, yScale, layout]); + + const categoryAxis = layout === 'vertical' ? xAxis : yAxis; + const categoryData = + categoryAxis?.data && + Array.isArray(categoryAxis.data) && + typeof categoryAxis.data[0] === 'number' + ? (categoryAxis.data as number[]) + : undefined; + const categoryValue = categoryData ? categoryData[categoryIndex] : categoryIndex; - // Recalculate stack bounds - const newMinY = Math.min(...allBars.map((bar) => bar.y)); - const newMaxY = Math.max(...allBars.map((bar) => bar.y + bar.height)); + const seriesData = useMemo( + () => Object.fromEntries(series.map((s) => [s.id, getSeriesData(s.id) ?? []])), + [series, getSeriesData], + ); - stackBounds = { - x, - y: newMinY, - width, - height: newMaxY - newMinY, - }; - } + const bars = useMemo( + () => + getBars({ + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + defaultFill: 'var(--color-fgPrimary)', + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + }), + [ + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + ], + ); - // Reapply border radius logic only if we actually scaled - if (stackBounds.height < stackMinSizePx) { - allBars = applyBorderRadiusLogic(allBars); - } + const stackRect = useMemo(() => { + if (bars.length === 0) { + return { + x: layout === 'vertical' ? indexPos : baselinePx, + y: layout === 'vertical' ? baselinePx : indexPos, + width: layout === 'vertical' ? thickness : 0, + height: layout === 'vertical' ? 0 : thickness, + }; } - - return { bars: allBars, stackRect: stackBounds }; - }, [ - series, - stackGap, - barMinSizePx, - x, - baseline, - width, - stackMinSizePx, - getSeriesData, - categoryIndex, - yScale, - seriesGradients, - roundBaseline, - ]); - - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) - : undefined; - const dataX = xData ? xData[categoryIndex] : categoryIndex; + const minX = Math.min(...bars.map((b) => b.x)); + const minY = Math.min(...bars.map((b) => b.y)); + const maxX = Math.max(...bars.map((b) => b.x + b.width)); + const maxY = Math.max(...bars.map((b) => b.y + b.height)); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; + }, [bars, baselinePx, indexPos, layout, thickness]); + + const stackOrigin = useMemo( + () => + getStackOrigin( + bars.map((b) => b.origin), + bars.map((b) => b.minSize ?? 0), + ) ?? baselinePx, + [bars, baselinePx], + ); const barElements = bars.map((bar, index) => ( )); - // Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding - const stackRoundBottom = - roundBaseline || Math.abs(stackRect.y + stackRect.height - baseline) >= EPSILON; - const stackRoundTop = roundBaseline || Math.abs(stackRect.y - baseline) >= EPSILON; + const edge = layout === 'vertical' ? stackRect.y : stackRect.x; + const size = layout === 'vertical' ? stackRect.height : stackRect.width; + const stackRoundLower = roundBaseline || Math.abs(edge - baselinePx) >= EPSILON; + const stackRoundHigher = roundBaseline || Math.abs(edge + size - baselinePx) >= EPSILON; + const stackRoundTop = layout === 'vertical' ? stackRoundLower : stackRoundHigher; + const stackRoundBottom = layout === 'vertical' ? stackRoundHigher : stackRoundLower; return ( {barElements} diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 31ea2e64ab..4179f0c35f 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -19,9 +19,10 @@ export type BarStackGroupProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'transitions' | 'transition' > & - Pick & { + Pick & { /** * Index of this stack within the category (0-based). */ @@ -42,71 +43,86 @@ export type BarStackGroupProps = Pick< * Delegates the actual stacking logic to BarStack for each category. */ export const BarStackGroup = memo( - ({ series, yAxisId, stackIndex, totalStacks, barPadding = 0.1, ...props }) => { - const { getXScale, getYScale, drawingArea, dataLength } = useCartesianChartContext(); + ({ series, xAxisId, yAxisId, stackIndex, totalStacks, barPadding = 0.1, ...props }) => { + const { layout, getXScale, getYScale, drawingArea, dataLength } = useCartesianChartContext(); - const xScale = getXScale(); + const xScale = getXScale(xAxisId); const yScale = getYScale(yAxisId); const stackConfigs = useMemo(() => { if (!xScale || !yScale || !drawingArea || dataLength === 0) return []; - if (!isCategoricalScale(xScale)) { + const indexScale = layout !== 'horizontal' ? xScale : yScale; + + if (!isCategoricalScale(indexScale)) { return []; } - const categoryWidth = xScale.bandwidth(); + const categoryWidth = indexScale.bandwidth(); - // Calculate width for each stack within a category - // Only apply barPadding when there are multiple stacks - const gapWidth = totalStacks > 1 ? (categoryWidth * barPadding) / (totalStacks - 1) : 0; - const barWidth = categoryWidth / totalStacks - getBarSizeAdjustment(totalStacks, gapWidth); + // Calculate thickness for each stack within a category + const gapSize = totalStacks > 1 ? (categoryWidth * barPadding) / (totalStacks - 1) : 0; + const stackThickness = + categoryWidth / totalStacks - getBarSizeAdjustment(totalStacks, gapSize); const configs: Array<{ categoryIndex: number; - x: number; - width: number; + indexPos: number; + thickness: number; }> = []; // Calculate position for each category - // todo: look at using xDomain for this instead of dataLength for (let categoryIndex = 0; categoryIndex < dataLength; categoryIndex++) { - // Get x position for this category - const categoryX = xScale(categoryIndex); - if (categoryX !== undefined) { - // Calculate x position for this specific stack within the category - const stackX = categoryX + stackIndex * (barWidth + gapWidth); + // Get position for this category along the index axis + const categoryPos = indexScale(categoryIndex); + if (categoryPos !== undefined) { + // Calculate position for this specific stack within the category + const stackPos = categoryPos + stackIndex * (stackThickness + gapSize); configs.push({ categoryIndex, - x: stackX, - width: barWidth, + indexPos: stackPos, + thickness: stackThickness, }); } } return configs; - }, [xScale, yScale, drawingArea, dataLength, stackIndex, totalStacks, barPadding]); + }, [xScale, yScale, drawingArea, dataLength, layout, totalStacks, barPadding, stackIndex]); + + const indexScaleComputed = layout !== 'horizontal' ? xScale : yScale; + const valueScaleComputed = layout !== 'horizontal' ? yScale : xScale; - if (xScale && !isCategoricalScale(xScale)) { + if (indexScaleComputed && !isCategoricalScale(indexScaleComputed)) { throw new Error( - 'BarStackGroup requires a band scale for x-axis. See https://cds.coinbase.com/components/graphs/XAxis/#scale-type', + `BarStackGroup requires a band scale for ${ + layout !== 'horizontal' ? 'x-axis' : 'y-axis' + }. See https://cds.coinbase.com/components/graphs/${ + layout !== 'horizontal' ? 'XAxis' : 'YAxis' + }/#scale-type`, ); } - if (!yScale || !drawingArea || stackConfigs.length === 0) return null; + if (!indexScaleComputed || !valueScaleComputed || !drawingArea || stackConfigs.length === 0) + return null; + + // In horizontal layout, render stacks in reverse order so top rows (lower categoryIndex) + // appear on top in SVG. Otherwise bottom rows would overlap and obscure top rows during animation. + const orderedConfigs = layout === 'horizontal' ? [...stackConfigs].reverse() : stackConfigs; - return stackConfigs.map(({ categoryIndex, x, width }) => ( + return orderedConfigs.map(({ categoryIndex, indexPos, thickness }) => ( )); }, diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index c5cdf63391..7f93a10889 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,8 +1,16 @@ import React, { memo, useMemo } from 'react'; -import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import { getBarPath } from '../utils'; +import { Path } from '../Path'; +import { + defaultBarEnterOpacityTransition, + defaultBarEnterTransition, + defaultTransition, + getBarPath, + getTransition, + withStaggerDelayTransition, +} from '../utils'; +import { type BarTransition, getNormalizedStagger } from '../utils/bar'; import type { BarComponentProps } from './Bar'; @@ -23,42 +31,127 @@ export type DefaultBarProps = BarComponentProps & { export const DefaultBar = memo( ({ x, + y, width, + height, borderRadius = 4, roundTop, roundBottom, - originY, + origin, d, fill = 'var(--color-fgPrimary)', fillOpacity = 1, dataX, dataY, + seriesId, + minSize = 1, + transitions, transition, ...props }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea, layout } = useCartesianChartContext(); + + const normalizedStagger = useMemo( + () => getNormalizedStagger(layout, x, y, drawingArea), + [layout, x, y, drawingArea], + ); + + const enterTransition = useMemo( + () => + getTransition( + transitions?.enter, + animate, + defaultBarEnterTransition, + ) as BarTransition | null, + [transitions?.enter, animate], + ); + const enterTransitionWithStagger = useMemo( + () => withStaggerDelayTransition(enterTransition, normalizedStagger), + [enterTransition, normalizedStagger], + ); + const enterOpacityTransition = useMemo(() => { + if (transitions?.enterOpacity === undefined && enterTransition === null) return null; + + const enterOpacityTransition: BarTransition | null = getTransition( + transitions?.enterOpacity, + animate, + defaultBarEnterOpacityTransition, + ); + + if (!enterOpacityTransition) return null; + + return { + ...enterOpacityTransition, + delay: enterOpacityTransition.delay ?? enterTransition?.delay, + staggerDelay: enterOpacityTransition.staggerDelay ?? enterTransition?.staggerDelay, + }; + }, [transitions?.enterOpacity, animate, enterTransition]); + const enterOpacityTransitionWithStagger = useMemo( + () => withStaggerDelayTransition(enterOpacityTransition, normalizedStagger), + [enterOpacityTransition, normalizedStagger], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedStagger, + ), + [transitions?.update, transition, animate, normalizedStagger], + ); const initialPath = useMemo(() => { - if (!animate) return undefined; - // Need a minimum height to allow for animation - const minHeight = 1; - const initialY = (originY ?? 0) - minHeight; - return getBarPath(x, initialY, width, minHeight, borderRadius, !!roundTop, !!roundBottom); - }, [animate, x, originY, width, borderRadius, roundTop, roundBottom]); + if (!animate) return; + const isHorizontalLayout = layout === 'horizontal'; + const baseline = origin ?? (isHorizontalLayout ? x : y + height); + + const initialX = isHorizontalLayout ? baseline : x; + const initialY = isHorizontalLayout ? y : baseline; + const initialWidth = isHorizontalLayout ? minSize : width; + const initialHeight = isHorizontalLayout ? height : minSize; - if (animate && initialPath) { - return ( - + return getBarPath( + initialX, + initialY, + initialWidth, + initialHeight, + borderRadius, + !!roundTop, + !!roundBottom, + layout, ); - } + }, [ + animate, + layout, + x, + y, + origin, + width, + height, + borderRadius, + roundTop, + roundBottom, + minSize, + ]); - return ; + return ( + + ); }, ); diff --git a/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx b/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx index 932a3c5b13..e226c93147 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx @@ -2,7 +2,16 @@ import { memo, useId, useMemo } from 'react'; import { m as motion } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import { getBarPath } from '../utils'; +import { + defaultBarEnterTransition, + defaultTransition, + getBarPath, + getNormalizedStagger, + getStackInitialClipRect, + getTransition, + withStaggerDelayTransition, +} from '../utils'; +import { usePathTransition } from '../utils/transition'; import type { BarStackComponentProps } from './BarStack'; @@ -32,34 +41,73 @@ export const DefaultBarStack = memo( borderRadius = 4, roundTop = true, roundBottom = true, - yOrigin, + origin, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea, layout } = useCartesianChartContext(); const clipPathId = useId(); + const normalizedStagger = useMemo( + () => getNormalizedStagger(layout, x, y, drawingArea), + [layout, x, y, drawingArea], + ); + + const enterTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition(transitions?.enter, animate, defaultBarEnterTransition), + normalizedStagger, + ), + [transitions?.enter, animate, normalizedStagger], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedStagger, + ), + [transitions?.update, transition, animate, normalizedStagger], + ); + const clipPathData = useMemo(() => { - return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); - }, [x, y, width, height, borderRadius, roundTop, roundBottom]); + return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom, layout); + }, [x, y, width, height, borderRadius, roundTop, roundBottom, layout]); const initialClipPathData = useMemo(() => { - if (!animate) return undefined; - return getBarPath(x, yOrigin ?? y + height, width, 1, borderRadius, roundTop, roundBottom); - }, [animate, x, yOrigin, y, height, width, borderRadius, roundTop, roundBottom]); + if (!animate) return; + const initialClipRect = getStackInitialClipRect({ x, y, width, height }, layout, origin); + + return getBarPath( + initialClipRect.x, + initialClipRect.y, + initialClipRect.width, + initialClipRect.height, + borderRadius, + roundTop, + roundBottom, + layout, + ); + }, [animate, layout, x, y, height, width, borderRadius, roundTop, roundBottom, origin]); + + const animatedClipPath = usePathTransition({ + currentPath: clipPathData, + initialPath: initialClipPathData, + transitions: { + enter: enterTransition, + update: updateTransition, + }, + }); return ( <> - {animate ? ( - - ) : ( - - )} + diff --git a/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx b/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx new file mode 100644 index 0000000000..9fc261b801 --- /dev/null +++ b/packages/web-visualization/src/chart/bar/PercentageBarChart.tsx @@ -0,0 +1,150 @@ +import { forwardRef, memo, useMemo } from 'react'; + +import type { BarChartBaseProps, BarChartProps } from './BarChart'; +import { BarChart } from './BarChart'; +import type { BarSeries } from './BarStack'; + +/** Extended series type that supports single data values. */ +export type PercentageBarSeries = Omit & { + /** + * Data for this series. + * + * Can be either: + * - Single number: `1400` + * - Array of numbers: `[10, 15, 20]` + */ + data: number | Array; +}; + +export type PercentageBarChartBaseProps = Omit< + BarChartBaseProps, + | 'series' + | 'stacked' + | 'layout' + | 'roundBaseline' + | 'inset' + | 'enableScrubbing' + | 'onScrubberPositionChange' +> & { + /** + * Configuration objects that define how to visualize the data. + * Each series contains its own data. + */ + series?: PercentageBarSeries[]; + /** + * Chart layout - describes the direction bars/areas grow. + * - 'vertical': Bars grow vertically. X is category axis, Y is value axis. + * - 'horizontal' (default): Bars grow horizontally. Y is category axis, X is value axis. + * @default 'horizontal' + */ + layout?: BarChartBaseProps['layout']; + /** + * Whether to round the baseline of a bar (where the value is 0). + * @default true + */ + roundBaseline?: BarChartBaseProps['roundBaseline']; + /** + * Inset around the entire chart (outside the axes). + * @default 0 + */ + inset?: BarChartBaseProps['inset']; +}; + +/** + * Returns the value for a group index from numeric shorthand or per-group series data. + * @param data - A single number (group `0` only) or an array of values per group. + * @param groupIndex - The group index to read. + * @returns The clamped value for that group, or `null` when the value is `null`, undefined, or out of range. + */ +const unwrapSeriesDataValue = ( + data: PercentageBarSeries['data'], + groupIndex: number, +): number | null => { + const raw = typeof data === 'number' ? (groupIndex === 0 ? data : null) : data[groupIndex]; + return raw != null ? Math.max(0, raw) : null; +}; + +export type PercentageBarChartProps = PercentageBarChartBaseProps & + Omit< + BarChartProps, + | 'series' + | 'stacked' + | 'layout' + | 'roundBaseline' + | 'inset' + | 'enableScrubbing' + | 'onScrubberPositionChange' + >; + +export const PercentageBarChart = memo( + forwardRef( + ( + { + series, + layout = 'horizontal', + roundBaseline = true, + inset = 0, + xAxis, + yAxis, + testID, + children, + ...props + }, + ref, + ) => { + const barSeries = useMemo(() => { + const groupCount = Math.max( + 0, + ...(series?.map(({ data }) => (typeof data === 'number' ? 1 : data.length)) ?? []), + ); + + const totals = Array.from( + { length: groupCount }, + (_, i) => + series?.reduce((sum, { data }) => sum + (unwrapSeriesDataValue(data, i) ?? 0), 0) ?? 0, + ); + + return series?.map((s) => ({ + ...s, + data: Array.from({ length: groupCount }, (_, i) => { + const val = unwrapSeriesDataValue(s.data, i); + return val != null && totals[i] > 0 ? (val / totals[i]) * 100 : null; + }), + })); + }, [series]); + + const isHorizontalLayout = layout === 'horizontal'; + + const xAxisConfig: BarChartProps['xAxis'] = useMemo(() => { + return isHorizontalLayout + ? { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...xAxis } + : { categoryPadding: 0, ...xAxis }; + }, [isHorizontalLayout, xAxis]); + + const yAxisConfig: BarChartProps['yAxis'] = useMemo(() => { + return isHorizontalLayout + ? { categoryPadding: 0, ...yAxis } + : { domain: { min: 0, max: 100 }, domainLimit: 'strict', ...yAxis }; + }, [isHorizontalLayout, yAxis]); + + return ( + + {children} + + ); + }, + ), +); + +PercentageBarChart.displayName = 'PercentageBarChart'; diff --git a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx index 893e453cbc..5f882d90dc 100644 --- a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,7 +1,9 @@ -import React, { memo, useId } from 'react'; +import React, { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; import { HStack, VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; +import { m as motion, type Transition } from 'framer-motion'; import { CartesianChart } from '../..'; import { XAxis, YAxis } from '../../axis'; @@ -19,6 +21,11 @@ import { Bar, type BarComponentProps } from '..'; export default { title: 'Components/Chart/BarChart', component: BarChart, + parameters: { + a11y: { + test: 'todo', + }, + }, }; const Example: React.FC< @@ -37,6 +44,10 @@ const Example: React.FC< const ThinSolidLine = memo((props: SolidLineProps) => ); +const baselineThresholdData = [40, 28, 21, 5, 48, 5, 28, 2, 29, 48, 18, 30, 29, 8].map( + (value) => value + 50, +); + const PositiveAndNegativeCashFlow = () => { const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); const gains = [ @@ -118,10 +129,10 @@ const MonthlyRewards = () => { const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; const series = [ - { id: 'purple', data: purple, color: '#b399ff' }, - { id: 'blue', data: blue, color: '#4f7cff' }, - { id: 'cyan', data: cyan, color: '#00c2df' }, - { id: 'green', data: green, color: '#33c481' }, + { id: 'purple', data: purple, color: 'rgb(var(--purple30))' }, + { id: 'blue', data: blue, color: 'rgb(var(--blue30))' }, + { id: 'cyan', data: cyan, color: 'rgb(var(--teal30))' }, + { id: 'green', data: green, color: 'rgb(var(--green30))' }, ]; const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { @@ -156,7 +167,7 @@ const MonthlyRewards = () => { series={series} showYAxis={false} stackMinSize={24} - width={403} + width={384} xAxis={{ tickLabelFormatter: (index) => { if (index == currentMonth) { @@ -164,7 +175,7 @@ const MonthlyRewards = () => { } return months[index]; }, - categoryPadding: 0.27, + categoryPadding: 0.25, }} /> ); @@ -223,6 +234,8 @@ const BandGridPositionExample = ({ ); const Candlesticks = () => { + const staggerDelay = 0.25; + const infoTextRef = React.useRef(null); const selectedIndexRef = React.useRef(undefined); const [timePeriod, setTimePeriod] = React.useState(tabs[0]); @@ -262,34 +275,50 @@ const Candlesticks = () => { number, ][]; - const CandlestickBarComponent = memo( - ({ x, y, width, height, originY, dataX, ...props }) => { - const { getYScale } = useCartesianChartContext(); - const yScale = getYScale(); + const CandlestickBarComponent = memo(({ x, y, width, height, dataX }) => { + const { getYScale, drawingArea } = useCartesianChartContext(); + const yScale = getYScale(); - const wickX = x + width / 2; + const normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); - const timePeriodValue = stockData[dataX as number]; + const transition: Transition = useMemo( + () => ({ + type: 'tween', + duration: 0.325, + delay: normalizedX * staggerDelay, + }), + [normalizedX], + ); - const open = parseFloat(timePeriodValue.open); - const close = parseFloat(timePeriodValue.close); + const wickX = x + width / 2; - const bullish = open < close; - const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; - const openY = yScale?.(open) ?? 0; - const closeY = yScale?.(close) ?? 0; + const timePeriodValue = stockData[dataX as number]; - const bodyHeight = Math.abs(openY - closeY); - const bodyY = openY < closeY ? openY : closeY; + const open = parseFloat(timePeriodValue.open); + const close = parseFloat(timePeriodValue.close); - return ( - - - - - ); - }, - ); + const bullish = open < close; + const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + const openY = yScale?.(open) ?? 0; + const closeY = yScale?.(close) ?? 0; + + const bodyHeight = Math.abs(openY - closeY); + const bodyY = openY < closeY ? openY : closeY; + + return ( + + + + + ); + }); const formatPrice = React.useCallback((price: number) => { return new Intl.NumberFormat('en-US', { @@ -360,8 +389,7 @@ const Candlesticks = () => { showXAxis showYAxis BarComponent={CandlestickBarComponent} - BarStackComponent={({ children, ...props }) => {children}} - animate={false} + BarStackComponent={({ children, origin, ...props }) => {children}} aria-labelledby={infoTextId} borderRadius={0} height={400} @@ -404,379 +432,726 @@ const Candlesticks = () => { ); }; -export const All = () => { +type SunlightChartData = Array<{ label: string; value: number }>; + +const sunlightData: SunlightChartData = [ + { label: 'Jan', value: 598 }, + { label: 'Feb', value: 635 }, + { label: 'Mar', value: 688 }, + { label: 'Apr', value: 753 }, + { label: 'May', value: 812 }, + { label: 'Jun', value: 855 }, + { label: 'Jul', value: 861 }, + { label: 'Aug', value: 828 }, + { label: 'Sep', value: 772 }, + { label: 'Oct', value: 710 }, + { label: 'Nov', value: 648 }, + { label: 'Dec', value: 605 }, +]; + +const dayLength = 1440; + +const MonthlySunlight = () => { + return ( + value), + yAxisId: 'sunlight', + color: 'rgb(var(--yellow40))', + }, + { + id: 'day', + data: sunlightData.map(() => dayLength), + yAxisId: 'day', + color: 'rgb(var(--blue100))', + }, + ]} + xAxis={{ + scaleType: 'band', + data: sunlightData.map(({ label }) => label), + }} + yAxis={[ + { + id: 'day', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + { + id: 'sunlight', + domain: { min: 0, max: dayLength }, + domainLimit: 'strict', + }, + ]} + > + + + + + + ); +}; + +const PriceRange = () => { + const candles = btcCandles.slice(0, 180).reverse(); + const data: [number, number][] = candles.map((candle) => [ + parseFloat(candle.low), + parseFloat(candle.high), + ]); + + const min = Math.min(...data.map(([low]) => low)); + const max = Math.max(...data.map(([, high]) => high)); + + const tickFormatter = React.useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +}; + +const PopulationPyramid = () => { + const ageGroups = [ + '100+ yrs', + '95-99 yrs', + '90-94 yrs', + '85-89 yrs', + '80-84 yrs', + '75-79 yrs', + '70-74 yrs', + '65-69 yrs', + '60-64 yrs', + '55-59 yrs', + '50-54 yrs', + '45-49 yrs', + '40-44 yrs', + '35-39 yrs', + '30-34 yrs', + '25-29 yrs', + '20-24 yrs', + '15-19 yrs', + '10-14 yrs', + '5-9 yrs', + '0-4 yrs', + ]; + + const malePopulation = [ + 14587, 48604, 83560, 128957, 184152, 248505, 498683, 706420, 852333, 939629, 1002195, 1001264, + 960282, 1161371, 1105023, 1061755, 1019343, 1023264, 1026330, 984773, 944071, + ]; + + const femalePopulation = [ + 14122, 46974, 80768, 124663, 178043, 240293, 482271, 683270, 824525, 909115, 969807, 969070, + 929571, 1122380, 1068050, 1026356, 985483, 989404, 992505, 952453, 913222, + ]; + + const numberWithSuffixFormatter = useMemo( + () => + new Intl.NumberFormat('en-US', { + notation: 'compact', + }), + [], + ); + + const tickLabelFormatter = useCallback( + (value: number) => numberWithSuffixFormatter.format(Math.abs(value)), + [numberWithSuffixFormatter], + ); + + const domainSymmetric = useCallback((bounds: { min: number; max: number }) => { + const extremum = Math.max(-bounds.min, bounds.max); + const roundedExtremum = Math.ceil(extremum / 100_000) * 100_000; + return { min: -roundedExtremum, max: roundedExtremum }; + }, []); + + const series = [ + { + id: 'male', + label: 'Male', + data: malePopulation.map((population) => -population), + color: 'rgb(var(--blue40))', + stackId: 'population', + }, + { + id: 'female', + label: 'Female', + data: femalePopulation, + color: 'rgb(var(--pink40))', + stackId: 'population', + }, + ]; + return ( - - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 12, - domain: { max: 50 }, - }} - /> - - - + + + + ); +}; + +export const All = () => { + return ( + + + + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 12, + domain: { max: 50 }, + }} + /> + + + + + `$${value}k`} + /> + + + + + + + `$${value}k`} + /> + + + + + + + + + + + + + + + + `$${value}k`} + width={80} + /> + `$${value}k`} + width={70} + /> + + + + + + + + Simple gain/loss chart. Bars below zero are red (negative), bars at or above zero are + green (positive). Uses hard transition at 0. + + } + title="Gradient - Gain/Loss" > - - `$${value}k`} + `$${value}k`, + showGrid: true, + }} /> - - - - - + + Continuous gradient applied to bars. Each bar's color is determined by its value, + transitioning smoothly from green (low) to yellow (mid) to red (high). + + } + title="Gradient - Continuous (Y-Axis)" > - - `$${value}k`} + [ + { offset: min, color: 'var(--color-accentBoldGreen)' }, + { offset: (min + max) / 2, color: 'var(--color-accentBoldYellow)' }, + { offset: max, color: 'var(--color-accentBoldRed)' }, + ], + }, + }, + ]} + xAxis={{ + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }} + yAxis={{ + requestedTickCount: 5, + tickLabelFormatter: (value) => `${value}°C`, + showGrid: true, + }} /> - - - - - - - - - - - - - - + + Hard transitions at 30 and 45. Bars below 30 are green (cool), 30-45 are yellow + (warm), and above 45 are red (hot). + + } + title="Gradient - Hard Transitions (Y-Axis)" > - - `$${value}k`} - width={80} + `${value}°C`, + showGrid: true, + }} /> - `$${value}k`} - width={70} + + + Gradient applied on X-axis (category index). Each bar gets a color based on its + position in the chart, creating a rainbow effect. + + } + title="Gradient - Continuous (X-Axis)" + > + - - - - - - - - Simple gain/loss chart. Bars below zero are red (negative), bars at or above zero are - green (positive). Uses hard transition at 0. - - } - title="Gradient - Gain/Loss" - > - + + Stacked bars with gradient. Each series can have its own gradient configuration, + allowing for complex color compositions. + + } + title="Gradient - Stacked Bars" + > + [ + { offset: min, color: '#3b82f6' }, + { offset: max, color: '#8b5cf6' }, + ], + }, }, - }, - ]} - xAxis={{ - data: [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', - ], - }} - yAxis={{ - requestedTickCount: 5, - tickLabelFormatter: (value) => `$${value}k`, - showGrid: true, - }} - /> - - - Continuous gradient applied to bars. Each bar's color is determined by its value, - transitioning smoothly from green (low) to yellow (mid) to red (high). - - } - title="Gradient - Continuous (Y-Axis)" - > - [ - { offset: min, color: 'var(--color-accentBoldGreen)' }, - { offset: (min + max) / 2, color: 'var(--color-accentBoldYellow)' }, - { offset: max, color: 'var(--color-accentBoldRed)' }, - ], + { + id: 'category-b', + data: [15, 25, 20, 30, 22, 28, 23], + gradient: { + axis: 'y', + stops: ({ min, max }: { min: number; max: number }) => [ + { offset: min, color: '#10b981' }, + { offset: max, color: '#059669' }, + ], + }, }, - }, - ]} - xAxis={{ - data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], - }} - yAxis={{ - requestedTickCount: 5, - tickLabelFormatter: (value) => `${value}°C`, - showGrid: true, - }} - /> - - - Hard transitions at 30 and 45. Bars below 30 are green (cool), 30-45 are yellow (warm), - and above 45 are red (hot). - - } - title="Gradient - Hard Transitions (Y-Axis)" - > - + + + + + + + + + + + + + + + + + `${value}°C`, - showGrid: true, - }} - /> - - - Gradient applied on X-axis (category index). Each bar gets a color based on its position - in the chart, creating a rainbow effect. - - } - title="Gradient - Continuous (X-Axis)" - > - `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 12, + domain: { max: 50 }, + }} + yAxis={{ + position: 'left', + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + showTickMarks: true, + showLine: true, + }} + /> + + + + + + [ + { offset: min, color: '#3b82f6' }, + { offset: max, color: '#8b5cf6' }, + ], + }, }, - }, - ]} - xAxis={{ - data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], - }} - yAxis={{ - requestedTickCount: 5, - showGrid: true, - }} - /> - - - Stacked bars with gradient. Each series can have its own gradient configuration, - allowing for complex color compositions. - - } - title="Gradient - Stacked Bars" - > - [ - { offset: min, color: '#3b82f6' }, - { offset: max, color: '#8b5cf6' }, - ], + { + id: 'category-b', + data: [15, 25, 20, 30, 22, 28, 23], + gradient: { + stops: ({ min, max }: { min: number; max: number }) => [ + { offset: min, color: '#10b981' }, + { offset: max, color: '#059669' }, + ], + }, }, - }, - { - id: 'category-b', - data: [15, 25, 20, 30, 22, 28, 23], - gradient: { - axis: 'y', - stops: ({ min, max }: { min: number; max: number }) => [ - { offset: min, color: '#10b981' }, - { offset: max, color: '#059669' }, - ], + ]} + xAxis={{ + requestedTickCount: 5, + showGrid: true, + }} + yAxis={{ + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }} + /> + + + - - - - - - - - - - + ]} + xAxis={{ + data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], + }} + yAxis={{ + baseline: 100, + domain: { min: 80, max: 140 }, + showGrid: true, + }} + /> + + + + + + + + + ); }; diff --git a/packages/web-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx b/packages/web-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx new file mode 100644 index 0000000000..bc54e8d540 --- /dev/null +++ b/packages/web-visualization/src/chart/bar/__stories__/PercentageBarChart.stories.tsx @@ -0,0 +1,595 @@ +import React, { memo, useId, useMemo } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { IconButton } from '@coinbase/cds-web/buttons'; +import { HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { useCartesianChartContext } from '../../ChartProvider'; +import { DefaultLegendEntry, DefaultLegendShape } from '../../legend'; +import { Path } from '../../Path'; +import { getBarPath } from '../../utils'; +import type { BarComponentProps } from '..'; +import { DefaultBar } from '../DefaultBar'; +import { PercentageBarChart, type PercentageBarSeries } from '../PercentageBarChart'; + +export default { + title: 'Components/Chart/PercentageBarChart', + component: PercentageBarChart, + parameters: { + a11y: { + test: 'todo', + }, + }, +}; + +const DOTTED_BAR_OUTLINE_STROKE_WIDTH = 2; + +const DottedBarComponent = memo((props: BarComponentProps) => { + const { + dataX, + x, + y, + width, + height, + borderRadius = 4, + roundTop = true, + roundBottom = true, + } = props; + const { layout } = useCartesianChartContext(); + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const outlineInset = DOTTED_BAR_OUTLINE_STROKE_WIDTH / 2; + + const outlineGeometry = useMemo(() => { + const insetWidth = width - 2 * outlineInset; + const insetHeight = height - 2 * outlineInset; + if (insetWidth <= 0 || insetHeight <= 0) { + return null; + } + const insetX = x + outlineInset; + const insetY = y + outlineInset; + const insetRadius = Math.max(0, borderRadius - outlineInset); + return { + d: getBarPath( + insetX, + insetY, + insetWidth, + insetHeight, + insetRadius, + roundTop, + roundBottom, + layout, + ), + height: insetHeight, + width: insetWidth, + x: insetX, + y: insetY, + }; + }, [borderRadius, height, layout, outlineInset, roundBottom, roundTop, width, x, y]); + + // Create unique IDs per bar so patterns are scoped to each bar + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + {/* Pattern positioned relative to this bar's origin */} + + + + + + + + + + + {outlineGeometry ? ( + + ) : ( + + )} + + ); +}); + +/** + * Builds an SVG path for a horizontal bar segment with a pill cap on one end + * and a slanted straight edge on the other. The two segments' inner edges + * are parallel, producing a parallelogram-shaped gap between them. + */ +function getSlantedHorizontalBarPath( + x: number, + y: number, + width: number, + height: number, + borderRadius: number, + pillLeft: boolean, + pillRight: boolean, + slantDx: number, +): string | undefined { + if (width <= 0 || height <= 0) return undefined; + if (pillLeft === pillRight) return undefined; + + const r = Math.min(borderRadius, height / 2, width / 2); + const s = Math.min(Math.max(0, slantDx), width - r * 2); + + const x0 = x; + const x1 = x + width; + const y0 = y; + const y1 = y + height; + + // Pill left, slanted right + if (pillLeft && !pillRight) { + return [ + `M ${x0 + r} ${y0}`, + `L ${x1} ${y0}`, + `L ${x1 - s} ${y1}`, + `L ${x0 + r} ${y1}`, + `A ${r} ${r} 0 0 1 ${x0} ${y1 - r}`, + `L ${x0} ${y0 + r}`, + `A ${r} ${r} 0 0 1 ${x0 + r} ${y0}`, + 'Z', + ].join(' '); + } + + // Slanted left, pill right + if (!pillLeft && pillRight) { + return [ + `M ${x0 + s} ${y0}`, + `L ${x1 - r} ${y0}`, + `A ${r} ${r} 0 0 1 ${x1} ${y0 + r}`, + `L ${x1} ${y1 - r}`, + `A ${r} ${r} 0 0 1 ${x1 - r} ${y1}`, + `L ${x0} ${y1}`, + 'Z', + ].join(' '); + } + + return undefined; +} + +const SLANT_DX = 8; +const BASELINE_THRESHOLD = 1; + +const SlantedStackBar = memo(function SlantedStackBar(props: BarComponentProps) { + const { layout } = useCartesianChartContext(); + const { + x, + y, + width, + height, + borderRadius = 4, + roundTop, + roundBottom, + dataX, + d: defaultD, + fill, + fillOpacity, + ...rest + } = props; + + const d = useMemo(() => { + if (layout !== 'horizontal') { + return ( + defaultD ?? getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + } + + const isLeftmost = Array.isArray(dataX) && Math.abs(dataX[0]) < BASELINE_THRESHOLD; + + return ( + getSlantedHorizontalBarPath( + x, + y, + width, + height, + borderRadius, + isLeftmost, + !isLeftmost, + SLANT_DX, + ) ?? + defaultD ?? + getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) + ); + }, [layout, defaultD, dataX, x, y, width, height, borderRadius, roundTop, roundBottom]); + + if (!d) return null; + + return ( + + ); +}); + +const dottedBarSeries: PercentageBarSeries[] = [ + { + id: 'segment-a', + data: 60, + label: 'Segment A', + color: 'rgb(var(--teal60))', + BarComponent: DottedBarComponent, + }, + { id: 'segment-b', data: 30, label: 'Segment B', color: 'rgb(var(--chartreuse50))' }, + { id: 'segment-c', data: 10, label: 'Segment C', color: 'rgb(var(--indigo40))' }, +]; + +const Example: React.FC< + React.PropsWithChildren<{ title: string; description?: string | React.ReactNode }> +> = ({ children, title, description }) => { + return ( + + + {title} + + {description} + {children} + + ); +}; + +const Basics = () => ( + +); + +const StackGap = () => ( + +); + +const BorderRadius = () => ( + +); + +const DataExample = () => ( + +); + +const BarStackSpacing = () => ( + +); + +const MinimumBarSize = () => ( + +); + +const TaxesStyleConfirmedVsNeedReview = () => { + const series: PercentageBarSeries[] = [ + { + id: 'confirmed', + data: 28, + label: 'Confirmed', + color: 'var(--color-fgPositive)', + }, + { + id: 'needs-review', + data: 2, + label: 'Needs review', + color: 'var(--color-fgWarning)', + }, + ]; + + return ( + + + + Estimated gain + + +$30,000 + + + + + + + Confirmed + + + +$28,000 + + + + + + + Needs review + + + + Up to $2,000 + + 11 transfers + + + + + + + + ); +}; + +const SlantedStackGap = () => ( + +); + +const DottedBarFirstSeriesOnly = () => ( + +); + +const DottedBarChartLevel = () => ( + +); + +const VerticalMix = () => { + const series: PercentageBarSeries[] = [ + { + id: 'btc', + data: [55, 52, 48, 45, 50, 58, 62, 57, 53, 49, 44, 46], + label: 'BTC', + color: assets.btc.color, + }, + { + id: 'eth', + data: [30, 33, 35, 38, 32, 27, 25, 29, 34, 37, 40, 38], + label: 'ETH', + color: assets.eth.color, + }, + { + id: 'other', + data: [15, 15, 17, 17, 18, 15, 13, 14, 13, 14, 16, 16], + label: 'Other', + color: 'var(--color-fgMuted)', + }, + ]; + + return ( + + ); +}; + +const buySellSeries = [ + { id: 'buy', data: 76, color: 'var(--color-fgPositive)', legendShape: 'circle' as const }, + { id: 'sell', data: 24, color: 'var(--color-fgNegative)', legendShape: 'square' as const }, +]; + +const BuyVsSellLegend = memo(function BuyVsSellLegend() { + const [buy, sell] = buySellSeries; + return ( + + + {buy.data}% bought + + } + seriesId={buy.id} + shape={buy.legendShape} + /> + + {sell.data}% sold + + } + seriesId={sell.id} + shape={sell.legendShape} + /> + + ); +}); + +const BuyVsSell = () => ( + + + + +); + +export const All = () => { + return ( + + + + + + + + + + + + + + + + + + + + + Taxes-style copy with confirmed vs needs review segments. + + } + title="Taxes style: confirmed vs needs review" + > + + + + Pill-shaped outer ends with slanted inner edges + + } + title="Slanted stack gap" + > + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/bar/__tests__/BarChart.test.tsx b/packages/web-visualization/src/chart/bar/__tests__/BarChart.test.tsx new file mode 100644 index 0000000000..d58aaac826 --- /dev/null +++ b/packages/web-visualization/src/chart/bar/__tests__/BarChart.test.tsx @@ -0,0 +1,265 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import type { BarComponentProps } from '../Bar'; +import { BarChart } from '../BarChart'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in axis label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('BarChart', () => { + it('renders bars when enter transition is disabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('bar-chart'); + const barPaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(barPaths.length).toBeGreaterThan(0); + + const clipRect = svg.querySelector('clipPath rect'); + expect(clipRect).toBeInTheDocument(); + expect(Number(clipRect?.getAttribute('width'))).toBeGreaterThan(0); + }); + + it('passes custom transitions to custom bar components', () => { + const customTransitions = { + enter: { type: 'tween' as const, duration: 0.25 }, + update: { type: 'spring' as const, stiffness: 320, damping: 30 }, + }; + const CustomBar = jest.fn((props: BarComponentProps) => ); + + render( + + + , + ); + + expect(CustomBar).toHaveBeenCalled(); + const firstCallProps = CustomBar.mock.calls[0][0]; + expect(firstCallProps.transitions).toEqual(customTransitions); + }); + + it('shows axes and axis labels when enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('bar-chart-with-axes'); + expect(svg.querySelector('[data-axis="x"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="x-axis-label"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).toBeInTheDocument(); + }); + + it('hides axes when showXAxis and showYAxis are false', () => { + render( + + + , + ); + + const svg = screen.getByTestId('bar-chart-no-axes'); + expect(svg.querySelector('[data-axis="x"]')).not.toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).not.toBeInTheDocument(); + }); + + it('renders stacked bars for multiple series', () => { + render( + + + , + ); + + const svg = screen.getByTestId('bar-chart-stacked'); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThanOrEqual(2); + }); + + it('renders horizontal layout bars from zero baseline with categorical y-axis labels', () => { + const CustomBar = jest.fn((props: BarComponentProps) => ); + + render( + + + , + ); + + const svg = screen.getByTestId('bar-chart-horizontal-layout'); + expect(svg.querySelector('[data-axis="y"]')).toBeInTheDocument(); + expect(screen.getByText('A')).toBeInTheDocument(); + + const renderedCategories = new Set( + CustomBar.mock.calls + .map(([props]) => props.dataY) + .filter((value): value is number => typeof value === 'number'), + ); + expect(renderedCategories.has(0)).toBe(true); + expect(renderedCategories.has(1)).toBe(true); + expect(renderedCategories.has(2)).toBe(true); + + const hasWideBar = CustomBar.mock.calls.some(([props]) => props.width > props.height); + expect(hasWideBar).toBe(true); + }); + + it('uses value-axis baseline for non-stacked bar tuples', () => { + const CustomBar = jest.fn((props: BarComponentProps) => ); + + render( + + + , + ); + + expect(CustomBar).toHaveBeenCalled(); + expect(CustomBar.mock.calls[0][0].dataY).toEqual([10, 20]); + }); + + it('uses value-axis baseline for single-series stack groups', () => { + const CustomBar = jest.fn((props: BarComponentProps) => ); + + render( + + + , + ); + + expect(CustomBar).toHaveBeenCalled(); + expect(CustomBar.mock.calls[0][0].dataY).toEqual([10, 20]); + }); + + it('stacks bars around non-zero axis baseline', () => { + const CustomBar = jest.fn((props: BarComponentProps) => ); + + render( + + + , + ); + + const dataBySeries = new Map( + CustomBar.mock.calls.map(([props]) => [props.seriesId, props.dataY] as const), + ); + + expect(dataBySeries.get('series-a')).toEqual([20, 30]); + expect(dataBySeries.get('series-b')).toEqual([30, 40]); + expect(dataBySeries.get('series-c')).toEqual([40, 70]); + }); +}); diff --git a/packages/web-visualization/src/chart/bar/__tests__/BarChartBaseline.test.tsx b/packages/web-visualization/src/chart/bar/__tests__/BarChartBaseline.test.tsx new file mode 100644 index 0000000000..81b4d7127f --- /dev/null +++ b/packages/web-visualization/src/chart/bar/__tests__/BarChartBaseline.test.tsx @@ -0,0 +1,126 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { BarChart } from '../BarChart'; + +jest.mock('../BarPlot', () => ({ + BarPlot: () => null, +})); + +jest.mock('../../CartesianChart', () => ({ + CartesianChart: jest.fn(({ children }) => children), +})); + +describe('BarChart baseline domain defaults', () => { + const mockedCartesianChart = jest.mocked(CartesianChart); + const getSingleAxisConfig = (axis: Config | Config[] | undefined): Config | undefined => + Array.isArray(axis) ? axis[0] : axis; + + beforeEach(() => { + mockedCartesianChart.mockClear(); + }); + + it('extends lower bound to baseline in vertical layout', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: 55, max: 84 })).toEqual({ min: 30, max: 84 }); + }); + + it('keeps bounds unchanged when baseline is already inside range', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: 20, max: 55 })).toEqual({ min: 20, max: 55 }); + }); + + it('extends upper bound to baseline when values are below it', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const yAxisConfig = getSingleAxisConfig(cartesianChartProps?.yAxis); + const yDomain = yAxisConfig?.domain; + expect(yAxisConfig?.baseline).toBe(30); + expect(typeof yDomain).toBe('function'); + if (typeof yDomain !== 'function') throw new Error('Expected y-axis domain function'); + expect(yDomain({ min: -98, max: -52 })).toEqual({ min: -98, max: 30 }); + }); + + it('extends lower bound to baseline on horizontal value axis', () => { + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + const xAxisConfig = getSingleAxisConfig(cartesianChartProps?.xAxis); + const xDomain = xAxisConfig?.domain; + expect(xAxisConfig?.baseline).toBe(30); + expect(typeof xDomain).toBe('function'); + if (typeof xDomain !== 'function') throw new Error('Expected x-axis domain function'); + expect(xDomain({ min: 55, max: 84 })).toEqual({ min: 30, max: 84 }); + }); + + it('preserves function domains on value axis', () => { + const customDomain = jest.fn((bounds: { min: number; max: number }) => bounds); + + render( + + + , + ); + + const cartesianChartProps = mockedCartesianChart.mock.calls.at(-1)?.[0]; + expect(cartesianChartProps?.xAxis).toEqual( + expect.objectContaining({ + domain: customDomain, + }), + ); + }); +}); diff --git a/packages/web-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx b/packages/web-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx new file mode 100644 index 0000000000..3caa544bee --- /dev/null +++ b/packages/web-visualization/src/chart/bar/__tests__/PercentageBarChart.test.tsx @@ -0,0 +1,97 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen, within } from '@testing-library/react'; + +import { PercentageBarChart } from '../PercentageBarChart'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 400, + height: 24, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('PercentageBarChart', () => { + it('renders chart shell', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-chart')).toBeInTheDocument(); + }); + + it('renders in vertical layout', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-vertical')).toBeInTheDocument(); + }); + + it('renders legend entries for each series', () => { + render( + + + , + ); + + expect(screen.getByTestId('percentage-bar-legend')).toBeInTheDocument(); + const legend = screen.getByLabelText('Legend'); + expect(within(legend).getAllByText('A', { exact: true })).toHaveLength(1); + expect(within(legend).getAllByText('B', { exact: true })).toHaveLength(1); + }); +}); diff --git a/packages/web-visualization/src/chart/bar/index.ts b/packages/web-visualization/src/chart/bar/index.ts index 2c4224e3c6..ed2ef254ee 100644 --- a/packages/web-visualization/src/chart/bar/index.ts +++ b/packages/web-visualization/src/chart/bar/index.ts @@ -6,4 +6,5 @@ export * from './BarStack'; export * from './BarStackGroup'; export * from './DefaultBar'; export * from './DefaultBarStack'; +export * from './PercentageBarChart'; // codegen:end diff --git a/packages/web-visualization/src/chart/gradient/Gradient.tsx b/packages/web-visualization/src/chart/gradient/Gradient.tsx index 6acc27905b..3bf5e22b94 100644 --- a/packages/web-visualization/src/chart/gradient/Gradient.tsx +++ b/packages/web-visualization/src/chart/gradient/Gradient.tsx @@ -2,18 +2,29 @@ import { memo, useMemo } from 'react'; import { m as motion, type Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import type { GradientDefinition } from '../utils'; -import { getGradientConfig } from '../utils/gradient'; +import { defaultTransition, type GradientDefinition, instantTransition } from '../utils'; +import { getGradientAxis, getGradientConfig } from '../utils/gradient'; export type GradientBaseProps = { + /** + * Whether to animate gradient changes. + */ + animate?: boolean; /** * Gradient definition with stops, axis, and other configuration. */ gradient: GradientDefinition; + /** + * X-axis ID to use for gradient processing. + * When provided, the gradient will align with the specified x-axis range. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Y-axis ID to use for gradient processing. * When provided, the gradient will align with the specified y-axis range. * This ensures gradients work correctly when the axis has a custom range configuration. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; }; @@ -24,12 +35,9 @@ export type GradientProps = GradientBaseProps & { * Will be used in `url(#${id})` references. */ id: string; - /** - * Whether to animate gradient changes. - */ - animate?: boolean; /** * Transition configuration for animation. + * @default defaultTransition */ transition?: Transition; }; @@ -39,27 +47,37 @@ export type GradientProps = GradientBaseProps & { * The gradient can be referenced via `fill="url(#${id})"` or `stroke="url(#${id})"`. */ export const Gradient = memo( - ({ id, gradient, yAxisId, animate: animateProp, transition }) => { - const context = useCartesianChartContext(); - const animate = animateProp ?? context.animate; + ({ id, gradient, xAxisId, yAxisId, animate: animateProp, transition: transitionProp }) => { + const { + animate: animateContext, + getXScale, + getYScale, + drawingArea, + getYAxis, + getXAxis, + layout, + } = useCartesianChartContext(); + const animate = animateProp ?? animateContext; + const transition = useMemo(() => { + if (!animate) return instantTransition; + return transitionProp ?? defaultTransition; + }, [transitionProp, animate]); - const xScale = context.getXScale(); - const yScale = context.getYScale(yAxisId); + const xScale = getXScale(xAxisId); + const yScale = getYScale(yAxisId); + const xAxis = getXAxis(xAxisId); + const yAxis = getYAxis(yAxisId); // Process gradient definition into stops const stops = useMemo(() => { if (!xScale || !yScale) return; - return getGradientConfig(gradient, xScale, yScale); - }, [gradient, xScale, yScale]); - - const drawingArea = context.drawingArea; - const yAxis = context.getYAxis(yAxisId); - const xAxis = context.getXAxis(); + return getGradientConfig(gradient, xScale, yScale, layout); + }, [gradient, xScale, yScale, layout]); // If gradient processing failed, don't render if (!stops) return null; - const axis = gradient.axis ?? 'y'; + const axis = getGradientAxis(gradient, layout); let coordinates: Record; @@ -100,22 +118,15 @@ export const Gradient = memo( } return ( - + {stops.map((stop, index) => { const offset = `${stop.offset * 100}%`; - const opacity = stop.opacity; - - if (!animate) { - return ( - - ); - } - return ( ( offset, }} stopColor={stop.color} - stopOpacity={opacity ?? 1} + stopOpacity={stop.opacity ?? 1} transition={transition} /> ); })} - + ); }, ); diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts index 6b18c5af72..7fa7b37e08 100644 --- a/packages/web-visualization/src/chart/index.ts +++ b/packages/web-visualization/src/chart/index.ts @@ -5,6 +5,7 @@ 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'; diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendEntry.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendEntry.tsx new file mode 100644 index 0000000000..25d447a1ea --- /dev/null +++ b/packages/web-visualization/src/chart/legend/DefaultLegendEntry.tsx @@ -0,0 +1,56 @@ +import { memo } from 'react'; +import { cx } from '@coinbase/cds-web'; +import { HStack, type HStackDefaultElement, type HStackProps } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; +import { css } from '@linaria/core'; + +import { DefaultLegendShape } from './DefaultLegendShape'; +import type { LegendEntryProps } from './Legend'; + +const legendEntryCss = css` + align-items: center; +`; + +export type DefaultLegendEntryProps = LegendEntryProps & + Omit, 'children' | 'color'>; + +export const DefaultLegendEntry = memo( + ({ + seriesId, + label, + color, + shape, + ShapeComponent = DefaultLegendShape, + gap = 1, + className, + classNames, + style, + styles, + testID, + ...props + }: DefaultLegendEntryProps) => { + return ( + + + {typeof label === 'string' ? ( + + {label} + + ) : ( + label + )} + + ); + }, +); diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx new file mode 100644 index 0000000000..6914d17a19 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx @@ -0,0 +1,66 @@ +import React, { memo } from 'react'; +import { cx } from '@coinbase/cds-web'; +import { Box, type BoxProps } from '@coinbase/cds-web/layout'; +import { css } from '@linaria/core'; + +import type { LegendShape, LegendShapeVariant } from '../utils/chart'; + +import type { LegendShapeProps } from './Legend'; + +const containerCss = css` + width: 10px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +`; + +const pillCss = css` + width: 6px; + height: 24px; + border-radius: 3px; +`; + +const circleCss = css` + width: 10px; + height: 10px; + border-radius: 5px; +`; + +const squareCss = css` + width: 10px; + height: 10px; +`; + +const squircleCss = css` + width: 10px; + height: 10px; + border-radius: 2px; +`; + +const stylesByVariant: Record = { + pill: pillCss, + circle: circleCss, + square: squareCss, + squircle: squircleCss, +}; + +const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant => + typeof shape === 'string' && shape in stylesByVariant; + +export type DefaultLegendShapeProps = LegendShapeProps & + Omit, 'children' | 'color'>; + +export const DefaultLegendShape = memo( + ({ color = 'var(--color-fgPrimary)', shape = 'circle', className, style, ...props }) => { + if (!isVariantShape(shape)) return shape; + + const variantStyle = stylesByVariant[shape]; + + return ( + + + + ); + }, +); diff --git a/packages/web-visualization/src/chart/legend/Legend.tsx b/packages/web-visualization/src/chart/legend/Legend.tsx new file mode 100644 index 0000000000..d2c8739a33 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/Legend.tsx @@ -0,0 +1,224 @@ +import { forwardRef, memo, useMemo } from 'react'; +import { + Box, + type BoxBaseProps, + type BoxDefaultElement, + type BoxProps, +} from '@coinbase/cds-web/layout'; + +import { useCartesianChartContext } from '../ChartProvider'; +import type { LegendShape } from '../utils'; + +import { DefaultLegendEntry } from './DefaultLegendEntry'; +import { DefaultLegendShape } from './DefaultLegendShape'; + +export type LegendShapeProps = { + /** + * Color of the legend shape. + * @default 'var(--color-fgPrimary)' + */ + color?: string; + /** + * Shape to display. Can be a preset shape or a custom ReactNode. + * @default 'circle' + */ + shape?: LegendShape; + /** + * Custom class name for the shape element. + */ + className?: string; + /** + * Custom styles for the shape element. + */ + style?: React.CSSProperties; +}; + +export type LegendShapeComponent = React.FC; + +export type LegendEntryProps = { + /** + * Id of the series. + */ + seriesId: string; + /** + * Label of the series. + * If a ReactNode is provided, it replaces the default Text component. + */ + label: React.ReactNode; + /** + * Color of the series. + * @default 'var(--color-fgPrimary)' + */ + color?: string; + /** + * Shape of the series. + */ + shape?: LegendShape; + /** + * Custom component to render the legend shape. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + /** + * Custom class name for the root element. + */ + className?: string; + /** Custom class names for individual elements of the LegendEntry component */ + classNames?: { + /** Root element */ + root?: string; + /** Shape element */ + shape?: string; + /** + * Label element + * @note not applied when label is a ReactNode. + */ + label?: string; + }; + /** + * Custom styles for the root element. + */ + style?: React.CSSProperties; + /** Custom styles for individual elements of the LegendEntry component */ + styles?: { + /** Root element */ + root?: React.CSSProperties; + /** Shape element */ + shape?: React.CSSProperties; + /** + * Label element + * @note not applied when label is a ReactNode. + */ + label?: React.CSSProperties; + }; +}; + +export type LegendEntryComponent = React.FC; + +export type LegendBaseProps = Omit & { + /** + * Array of series IDs to display in the legend. + * By default, all series will be displayed. + */ + seriesIds?: string[]; + /** + * Custom component to render each legend entry. + * @default DefaultLegendEntry + */ + EntryComponent?: LegendEntryComponent; + /** + * Custom component to render the legend shape within each entry. + * Only used when EntryComponent is not provided or is DefaultLegendEntry. + * @default DefaultLegendShape + */ + ShapeComponent?: LegendShapeComponent; + /** + * Accessibility label for the legend group. + * @default 'Legend' + */ + accessibilityLabel?: string; +}; + +export type LegendProps = Omit, 'children'> & + LegendBaseProps & { + /** Custom class names for individual elements of the Legend component */ + classNames?: { + /** Root element */ + root?: string; + /** Entry element */ + entry?: string; + /** Entry shape element */ + entryShape?: string; + /** + * Entry label element + * @note not applied when label is a ReactNode. + */ + entryLabel?: string; + }; + /** Custom styles for individual elements of the Legend component */ + styles?: { + /** Root element */ + root?: React.CSSProperties; + /** Entry element */ + entry?: React.CSSProperties; + /** Entry shape element */ + entryShape?: React.CSSProperties; + /** + * Entry label element + * @note not applied when label is a ReactNode. + */ + entryLabel?: React.CSSProperties; + }; + }; + +export const Legend = memo( + forwardRef( + ( + { + flexDirection = 'row', + justifyContent = 'center', + alignItems = flexDirection === 'row' ? 'center' : 'flex-start', + flexWrap = 'wrap', + columnGap = 2, + rowGap = 0.75, + seriesIds, + EntryComponent = DefaultLegendEntry, + ShapeComponent = DefaultLegendShape, + accessibilityLabel = 'Legend', + className, + classNames, + style, + styles, + ...props + }, + ref, + ) => { + const { series } = useCartesianChartContext(); + + const filteredSeries = useMemo(() => { + if (seriesIds === undefined) return series; + return series.filter((s) => seriesIds.includes(s.id)); + }, [series, seriesIds]); + + if (filteredSeries.length === 0) return; + + return ( + + {filteredSeries.map((s) => ( + + ))} + + ); + }, + ), +); diff --git a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx new file mode 100644 index 0000000000..de2060d71f --- /dev/null +++ b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx @@ -0,0 +1,747 @@ +import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { Chip } from '@coinbase/cds-web/chips'; +import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; + +import { XAxis, YAxis } from '../../axis'; +import { BarChart, type BarComponentProps, BarPlot, DefaultBar } from '../../bar'; +import { CartesianChart } from '../../CartesianChart'; +import { useCartesianChartContext } from '../../ChartProvider'; +import { LineChart } from '../../line'; +import { Scrubber } from '../../scrubber'; +import { useScrubberContext } from '../../utils'; +import type { LegendShapeVariant, Series } from '../../utils/chart'; +import { DefaultLegendShape } from '../DefaultLegendShape'; +import { Legend, type LegendEntryProps } from '../Legend'; + +export default { + component: Legend, + title: 'Components/Chart/Legend', +}; + +const Example: React.FC> = ({ children, title }) => { + return ( + + + {title} + + {children} + + ); +}; + +const spectrumColors = [ + 'blue', + 'green', + 'orange', + 'yellow', + 'gray', + 'indigo', + 'pink', + 'purple', + 'red', + 'teal', + 'chartreuse', +]; + +const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square']; + +const Shapes = () => { + return ( + + + {shapes.map((shape) => ( + + {spectrumColors.map((color) => ( + + + + ))} + + ))} + + + ); +}; + +const Basic = () => { + const pages = useMemo( + () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'], + [], + ); + const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []); + const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []); + + const numberFormatter = useCallback( + (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value), + [], + ); + + return ( + + + + + + ); +}; + +const AutoScale = () => { + const precipitationData = [ + { + id: 'northeast', + label: 'Northeast', + data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89], + color: 'rgb(var(--blue40))', + }, + { + id: 'upperMidwest', + label: 'Upper Midwest', + data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48], + color: 'rgb(var(--green40))', + }, + { + id: 'ohioValley', + label: 'Ohio Valley', + data: [4.74, 1.83, 3.1, 5.42, 5.69, 3.29, 5.02, 2.57, 4.13, 0.79, 4.31, 3.67], + color: 'rgb(var(--orange40))', + }, + { + id: 'southeast', + label: 'Southeast', + data: [5.48, 3.11, 5.73, 2.97, 5.45, 3.28, 7.18, 5.67, 7.93, 1.33, 2.69, 3.21], + color: 'rgb(var(--yellow40))', + }, + { + id: 'northernRockiesAndPlains', + label: 'Northern Rockies and Plains', + data: [0.64, 1.01, 1.06, 2.12, 3.34, 2.65, 1.54, 1.89, 0.95, 0.57, 1.23, 0.67], + color: 'rgb(var(--indigo40))', + }, + { + id: 'south', + label: 'South', + data: [4.19, 1.79, 2.93, 3.84, 5.25, 3.4, 4.27, 1.84, 3.08, 0.52, 4.5, 2.62], + color: 'rgb(var(--pink40))', + }, + { + id: 'southwest', + label: 'Southwest', + data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25], + color: 'rgb(var(--purple40))', + }, + { + id: 'northwest', + label: 'Northwest', + data: [5.69, 3.67, 3.32, 1.95, 2.08, 1.31, 0.28, 0.81, 0.95, 2.03, 5.45, 5.8], + color: 'rgb(var(--red40))', + }, + { + id: 'west', + label: 'West', + data: [3.39, 4.7, 3.09, 1.07, 0.55, 0.12, 0.23, 0.26, 0.22, 0.4, 2.7, 2.54], + color: 'rgb(var(--teal40))', + }, + ]; + + const xAxisData = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + + return ( + + + + + + ); +}; + +const Position = () => { + return ( + + } + legendPosition="bottom" + series={[ + { + id: 'revenue', + label: 'Revenue', + data: [455, 520, 380, 455, 285, 235], + yAxisId: 'revenue', + color: 'rgb(var(--yellow40))', + legendShape: 'squircle', + }, + { + id: 'profitMargin', + label: 'Profit Margin', + data: [23, 20, 16, 38, 12, 9], + yAxisId: 'profitMargin', + color: 'var(--color-fgPositive)', + legendShape: 'squircle', + }, + ]} + xAxis={{ + data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], + scaleType: 'band', + }} + yAxis={[ + { + id: 'revenue', + domain: { min: 0 }, + }, + { + id: 'profitMargin', + domain: { max: 100, min: 0 }, + }, + ]} + > + + `$${value}k`} + width={60} + /> + `${value}%`} + /> + + + + ); +}; + +const ShapeVariants = () => { + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun']; + + return ( + + + + ); +}; + +const DynamicData = () => { + const timeLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const series: Series[] = [ + { + id: 'candidate-a', + label: 'Candidate A', + data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38], + color: 'rgb(var(--blue40))', + legendShape: 'circle', + }, + { + id: 'candidate-b', + label: 'Candidate B', + data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35], + color: 'rgb(var(--orange40))', + legendShape: 'circle', + }, + { + id: 'candidate-c', + label: 'Candidate C', + data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27], + color: 'rgb(var(--gray40))', + legendShape: 'circle', + }, + ]; + + const ValueLegendEntry = memo(function ValueLegendEntry({ + seriesId, + label, + color, + shape, + }: LegendEntryProps) { + const { scrubberPosition } = useScrubberContext(); + const { series, dataLength } = useCartesianChartContext(); + + const dataIndex = scrubberPosition ?? dataLength - 1; + + const seriesData = series.find((s) => s.id === seriesId); + const rawValue = seriesData?.data?.[dataIndex]; + + const formattedValue = + rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue as number)}%`; + + return ( + + + {label} + + {formattedValue} + + + ); + }); + + return ( + + + } + legendPosition="top" + series={series} + xAxis={{ + data: timeLabels, + }} + yAxis={{ + domain: { max: 100, min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `${value}%`, + }} + > + + + + ); +}; + +const Interactive = () => { + const [emphasizedId, setEmphasizedId] = useState(null); + + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + const seriesConfig = useMemo( + () => [ + { + id: 'revenue', + label: 'Revenue', + data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350], + baseColor: '--blue', + }, + { + id: 'expenses', + label: 'Expenses', + data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195], + baseColor: '--orange', + }, + { + id: 'profit', + label: 'Profit', + data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155], + baseColor: '--green', + }, + ], + [], + ); + + const handleToggle = useCallback((seriesId: string) => { + setEmphasizedId((prev) => (prev === seriesId ? null : seriesId)); + }, []); + + const ChipLegendEntry = memo(function ChipLegendEntry({ seriesId, label }: LegendEntryProps) { + const chipRef = useRef(null); + const isEmphasized = emphasizedId === seriesId; + const config = seriesConfig.find((s) => s.id === seriesId); + const baseColor = config?.baseColor ?? '--gray'; + + // Restore focus when chip becomes emphasized + useEffect(() => { + if (isEmphasized && chipRef.current) { + chipRef.current.focus(); + } + }, [isEmphasized]); + + return ( + handleToggle(seriesId)} + style={{ + backgroundColor: `rgb(var(${baseColor}10))`, + borderWidth: 0, + color: 'var(--color-fg)', + outlineColor: `rgb(var(${baseColor}50))`, + }} + > + + + {label} + + + ); + }); + + const series = useMemo(() => { + return seriesConfig.map((config) => { + const isEmphasized = emphasizedId === config.id; + const isDimmed = emphasizedId !== null && !isEmphasized; + + return { + id: config.id, + label: config.label, + data: config.data, + color: `rgb(var(${config.baseColor}40))`, + opacity: isDimmed ? 0.3 : 1, + }; + }); + }, [emphasizedId, seriesConfig]); + + return ( + + } + legendPosition="top" + series={series} + xAxis={{ + data: months, + }} + yAxis={{ + domain: { min: 0 }, + showGrid: true, + tickLabelFormatter: (value) => `$${value}k`, + }} + /> + + ); +}; + +const Accessible = () => { + const months = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []); + + const chartAccessibilityLabel = + 'Monthly financial performance chart showing revenue and expenses over 6 months.'; + + return ( + + + + ); +}; + +const LegendShapes = () => { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + // Actual revenue (first 9 months) + const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null]; + + // Forecasted revenue (last 3 months) + const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680]; + + const numberFormatter = useCallback( + (value: number) => + `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`, + [], + ); + + // Pattern settings for dotted fill + const patternSize = 4; + const dotSize = 1; + const patternId = useId(); + const maskId = useId(); + const legendPatternId = useId(); + + // Custom legend indicator that matches the dotted bar pattern + const DottedLegendIndicator = ( + + + + + + + + + + + + + + + ); + + // Custom bar component that renders bars with dotted pattern fill + const DottedBarComponent = memo((props) => { + const { dataX, x, y } = props; + // Create unique IDs per bar so patterns are scoped to each bar + const uniqueMaskId = `${maskId}-${dataX}`; + const uniquePatternId = `${patternId}-${dataX}`; + return ( + <> + + {/* Pattern positioned relative to this bar's origin */} + + + + + + + + + + + + + ); + }); + + return ( + + + + ); +}; + +export const All = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/legend/__tests__/Legend.test.tsx b/packages/web-visualization/src/chart/legend/__tests__/Legend.test.tsx new file mode 100644 index 0000000000..c0423d8183 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/__tests__/Legend.test.tsx @@ -0,0 +1,111 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { Line } from '../../line/Line'; +import { Legend } from '../Legend'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; +}); + +describe('Legend', () => { + it('renders default legend when enabled on CartesianChart', () => { + render( + + + + + , + ); + + expect(screen.getByLabelText('Legend')).toBeInTheDocument(); + expect(screen.getByText('Test Series')).toBeInTheDocument(); + }); + + it('does not render legend when legend is false', () => { + render( + + + + + , + ); + + expect(screen.queryByLabelText('Legend')).not.toBeInTheDocument(); + }); + + it('uses custom legend accessibility label', () => { + render( + + + + + , + ); + + expect(screen.getByLabelText('Chart legend')).toBeInTheDocument(); + }); + + it('filters legend entries with seriesIds', () => { + render( + + + + + + + , + ); + + expect(screen.getByText('Series A')).toBeInTheDocument(); + expect(screen.queryByText('Series B')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web-visualization/src/chart/legend/index.ts b/packages/web-visualization/src/chart/legend/index.ts new file mode 100644 index 0000000000..cad992cc86 --- /dev/null +++ b/packages/web-visualization/src/chart/legend/index.ts @@ -0,0 +1,3 @@ +export * from './DefaultLegendEntry'; +export * from './DefaultLegendShape'; +export * from './Legend'; diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 404720344c..283bbf301f 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -38,8 +38,10 @@ export const DottedLine = memo( strokeWidth = 2, vectorEffect = 'non-scaling-stroke', gradient, + xAxisId, yAxisId, animate, + transitions, transition, d, ...props @@ -54,7 +56,8 @@ export const DottedLine = memo( animate={animate} gradient={gradient} id={gradientId} - transition={transition} + transition={transitions?.update ?? transition} + xAxisId={xAxisId} yAxisId={yAxisId} /> @@ -71,6 +74,7 @@ export const DottedLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} vectorEffect={vectorEffect} {...props} /> diff --git a/packages/web-visualization/src/chart/line/Line.tsx b/packages/web-visualization/src/chart/line/Line.tsx index a58c5b835c..5ebb225645 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -1,17 +1,15 @@ import React, { memo, useMemo } from 'react'; import type { SVGProps } from 'react'; import type { SharedProps } from '@coinbase/cds-common/types'; -import { m as motion, type Transition } from 'framer-motion'; import { Area, type AreaComponent } from '../area/Area'; import { useCartesianChartContext } from '../ChartProvider'; import type { PathProps } from '../Path'; import { Point, type PointBaseProps, type PointProps } from '../point'; import { - accessoryFadeTransitionDelay, - accessoryFadeTransitionDuration, type ChartPathCurveType, evaluateGradientAtValue, + getGradientAxis, getGradientConfig, getLineData, getLinePath, @@ -48,6 +46,9 @@ export type LineBaseProps = SharedProps & { /** * Baseline value for the area. * When set, overrides the default baseline. + * + * @deprecated this prop has no functionality. Use 'baseline' on axis config instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v5 */ areaBaseline?: number; /** @@ -109,25 +110,22 @@ export type LineBaseProps = SharedProps & { animate?: boolean; }; -export type LineProps = LineBaseProps & { - /** - * Transition configuration for line animations. - */ - transition?: Transition; - /** - * Handler for when a point is clicked. - * Passed through to Point components rendered via points. - */ - onPointClick?: PointProps['onClick']; - /** - * Custom style for the line. - */ - style?: React.CSSProperties; - /** - * Custom className for the line. - */ - className?: string; -}; +export type LineProps = LineBaseProps & + Pick & { + /** + * Handler for when a point is clicked. + * Passed through to Point components rendered via points. + */ + onPointClick?: PointProps['onClick']; + /** + * Custom style for the line. + */ + style?: React.CSSProperties; + /** + * Custom className for the line. + */ + className?: string; + }; export type LineComponentProps = Pick< LineProps, @@ -136,6 +134,7 @@ export type LineComponentProps = Pick< | 'strokeWidth' | 'gradient' | 'animate' + | 'transitions' | 'transition' | 'style' | 'className' @@ -145,9 +144,16 @@ export type LineComponentProps = Pick< * Path of the line. */ d: SVGProps['d']; + /** + * ID of the x-axis to use. + * If not provided, defaults to the default x-axis. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * ID of the y-axis to use. * If not provided, defaults to the default y-axis. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; }; @@ -160,7 +166,6 @@ export const Line = memo( curve = 'bump', type = 'solid', areaType = 'gradient', - areaBaseline, stroke: strokeProp, strokeOpacity, onPointClick, @@ -170,11 +175,12 @@ export const Line = memo( opacity = 1, points, connectNulls, + transitions, transition, gradient: gradientProp, ...props }) => { - const { animate, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = + const { layout, animate, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = useCartesianChartContext(); const matchedSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); @@ -184,23 +190,42 @@ export const Line = memo( ); const sourceData = useMemo(() => getSeriesData(seriesId), [getSeriesData, seriesId]); - const xAxis = useMemo(() => getXAxis(), [getXAxis]); - const xScale = useMemo(() => getXScale(), [getXScale]); + const xAxis = useMemo( + () => getXAxis(matchedSeries?.xAxisId), + [getXAxis, matchedSeries?.xAxisId], + ); + const xScale = useMemo( + () => getXScale(matchedSeries?.xAxisId), + [getXScale, matchedSeries?.xAxisId], + ); const yScale = useMemo( () => getYScale(matchedSeries?.yAxisId), [getYScale, matchedSeries?.yAxisId], ); + const yAxis = useMemo( + () => getYAxis(matchedSeries?.yAxisId), + [getYAxis, matchedSeries?.yAxisId], + ); // Convert sourceData to number array (line only supports numbers, not tuples) const chartData = useMemo(() => getLineData(sourceData), [sourceData]); + const categoryAxisIsX = useMemo(() => { + return layout !== 'horizontal'; + }, [layout]); + + const categoryAxis = useMemo(() => { + return categoryAxisIsX ? xAxis : yAxis; + }, [categoryAxisIsX, xAxis, yAxis]); + const path = useMemo(() => { if (!xScale || !yScale || chartData.length === 0) return ''; - // Get numeric x-axis data if available - const xData = - xAxis?.data && Array.isArray(xAxis.data) && typeof xAxis.data[0] === 'number' - ? (xAxis.data as number[]) + // Get numeric category-axis data if available + const indexAxis = categoryAxis; + const indexData = + indexAxis?.data && Array.isArray(indexAxis.data) && typeof indexAxis.data[0] === 'number' + ? (indexAxis.data as number[]) : undefined; return getLinePath({ @@ -208,10 +233,12 @@ export const Line = memo( xScale, yScale, curve, - xData, + xData: categoryAxisIsX ? indexData : undefined, + yData: !categoryAxisIsX ? indexData : undefined, connectNulls, + layout, }); - }, [chartData, xScale, yScale, curve, xAxis?.data, connectNulls]); + }, [xScale, yScale, chartData, categoryAxis, curve, categoryAxisIsX, connectNulls, layout]); const LineComponent = useMemo((): LineComponent => { if (SelectedLineComponent) { @@ -229,25 +256,28 @@ export const Line = memo( // Get series color for stroke const stroke = strokeProp ?? matchedSeries?.color ?? 'var(--color-fgPrimary)'; - const xData = useMemo(() => { - const data = xAxis?.data; + const categoryData = useMemo(() => { + const data = categoryAxis?.data; + return data && Array.isArray(data) && data.length > 0 && typeof data[0] === 'number' ? (data as number[]) : null; - }, [xAxis?.data]); + }, [categoryAxis]); const gradientConfig = useMemo(() => { if (!gradient || !xScale || !yScale) return; - const gradientScale = gradient.axis === 'x' ? xScale : yScale; - const stops = getGradientConfig(gradient, xScale, yScale); + const gradientAxis = getGradientAxis(gradient, layout); + const gradientScale = gradientAxis === 'x' ? xScale : yScale; + const stops = getGradientConfig(gradient, xScale, yScale, layout); if (!stops) return; return { + axis: gradientAxis, scale: gradientScale, stops, }; - }, [gradient, xScale, yScale]); + }, [gradient, xScale, yScale, layout]); if (!xScale || !yScale || !path) return; @@ -256,7 +286,6 @@ export const Line = memo( {showArea && ( ( gradient={gradient} seriesId={seriesId} transition={transition} + transitions={transitions} type={areaType} /> )} @@ -273,37 +303,32 @@ export const Line = memo( stroke={stroke} strokeOpacity={strokeOpacity ?? opacity} transition={transition} + transitions={transitions} + xAxisId={matchedSeries?.xAxisId} yAxisId={matchedSeries?.yAxisId} {...props} /> {points && ( - + {chartData.map((value: number | null, index: number) => { if (value === null) return; - const xValue = xData && xData[index] !== undefined ? xData[index] : index; + const indexValue = + categoryData && categoryData[index] !== undefined ? categoryData[index] : index; let pointFill = stroke; - if (gradientConfig && gradient) { - // Use the appropriate data value based on gradient axis - const axis = gradient.axis ?? 'y'; - const dataValue = axis === 'x' ? xValue : value; + if (gradientConfig) { + // Match gradient sampling to the chart axis roles for each layout. + const gradientAxis = gradientConfig.axis; + const dataValue = + gradientAxis === 'x' + ? categoryAxisIsX + ? indexValue + : value + : categoryAxisIsX + ? value + : indexValue; const evaluatedColor = evaluateGradientAtValue( gradientConfig.stops, @@ -318,9 +343,10 @@ export const Line = memo( // Build defaults that would be passed to Point const defaults: PointBaseProps = { - dataX: xValue, - dataY: value, + dataX: categoryAxisIsX ? indexValue : value, + dataY: categoryAxisIsX ? value : indexValue, fill: pointFill, + xAxisId: matchedSeries?.xAxisId, yAxisId: matchedSeries?.yAxisId, opacity, testID: undefined, @@ -333,6 +359,7 @@ export const Line = memo( key={`${seriesId}-${index}`} onClick={onPointClick} transition={transition} + transitions={transitions} {...defaults} /> ); @@ -350,12 +377,13 @@ export const Line = memo( key={`${seriesId}-${index}`} onClick={pointConfig.onClick ?? onPointClick} transition={transition} + transitions={transitions} {...defaults} {...pointConfig} /> ); })} - + )} ); diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx index 301f02eaaf..6a5a76b040 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -7,7 +7,7 @@ import { type CartesianChartBaseProps, type CartesianChartProps, } from '../CartesianChart'; -import { type AxisConfigProps, defaultChartInset, getChartInset, type Series } from '../utils'; +import { type CartesianAxisConfigProps, type Series } from '../utils'; import { Line, type LineProps } from './Line'; @@ -28,6 +28,7 @@ export type LineSeries = Series & | 'opacity' | 'points' | 'connectNulls' + | 'transitions' | 'transition' | 'onPointClick' > @@ -46,6 +47,7 @@ export type LineChartBaseProps = Omit & XAxisProps; + xAxis?: Partial & XAxisProps; /** * Configuration for y-axis. * Accepts axis config and axis props. * To show the axis, set `showYAxis` to true. */ - yAxis?: Partial & YAxisProps; + yAxis?: Partial & YAxisProps; }; export type LineChartProps = LineChartBaseProps & @@ -96,6 +98,7 @@ export const LineChart = memo( strokeWidth, strokeOpacity, connectNulls, + transitions, transition, opacity, showXAxis, @@ -108,8 +111,6 @@ export const LineChart = memo( }, ref, ) => { - const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]); - // Convert LineSeries to Series for Chart context const chartSeries = useMemo(() => { return series?.map( @@ -118,9 +119,11 @@ export const LineChart = memo( data: s.data, label: s.label, color: s.color, + xAxisId: s.xAxisId, yAxisId: s.yAxisId, stackId: s.stackId, gradient: s.gradient, + legendShape: s.legendShape, }), ); }, [series]); @@ -133,6 +136,8 @@ export const LineChart = memo( domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, + id: xAxisId, ...xAxisVisualProps } = xAxis || {}; @@ -143,60 +148,66 @@ export const LineChart = memo( domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, id: yAxisId, ...yAxisVisualProps } = yAxis || {}; - const xAxisConfig: Partial = { + const xAxisConfig: Partial = { scaleType: xScaleType, data: xData, categoryPadding: xCategoryPadding, domain: xDomain, domainLimit: xDomainLimit, range: xRange, + baseline: xBaseline, }; - const yAxisConfig: Partial = { + const yAxisConfig: Partial = { scaleType: yScaleType, data: yData, categoryPadding: yCategoryPadding, domain: yDomain, domainLimit: yDomainLimit, range: yRange, + baseline: yBaseline, }; return ( {/* Render axes first for grid lines to appear behind everything else */} - {showXAxis && } + {showXAxis && } {showYAxis && } - {series?.map(({ id, data, label, color, yAxisId, ...linePropsFromSeries }) => ( - - ))} + {series?.map( + ({ id, data, label, color, xAxisId, yAxisId, legendShape, ...linePropsFromSeries }) => ( + + ), + )} {children} ); diff --git a/packages/web-visualization/src/chart/line/ReferenceLine.tsx b/packages/web-visualization/src/chart/line/ReferenceLine.tsx index 7fd0fd68ad..054911c6c5 100644 --- a/packages/web-visualization/src/chart/line/ReferenceLine.tsx +++ b/packages/web-visualization/src/chart/line/ReferenceLine.tsx @@ -127,6 +127,7 @@ export type HorizontalReferenceLineProps = ReferenceLineBaseProps & { /** * The ID of the y-axis to use for positioning. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** @@ -168,6 +169,10 @@ export type ReferenceLineProps = (HorizontalReferenceLineProps | VerticalReferen * Custom class name for the root element. */ root?: string; + /** + * Custom class name for the line path. + */ + line?: string; /** * Custom class name for the text label. */ @@ -181,6 +186,10 @@ export type ReferenceLineProps = (HorizontalReferenceLineProps | VerticalReferen * Custom styles for the root element. */ root?: React.CSSProperties; + /** + * Custom styles for the line path. + */ + line?: React.CSSProperties; /** * Custom styles for the text label. */ @@ -245,9 +254,11 @@ export const ReferenceLine = memo( {label && ( ( {label && ( ( strokeOpacity = 1, strokeWidth = 2, gradient, + xAxisId, yAxisId, animate, + transitions, transition, d, ...props @@ -51,7 +53,8 @@ export const SolidLine = memo( animate={animate} gradient={gradient} id={gradientId} - transition={transition} + transition={transitions?.update ?? transition} + xAxisId={xAxisId} yAxisId={yAxisId} /> @@ -67,6 +70,7 @@ export const SolidLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} {...props} /> diff --git a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx index 903cf1ea23..973d9ab7e0 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -1,10 +1,21 @@ -import { forwardRef, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; -import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { + forwardRef, + memo, + StrictMode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { assets, ethBackground } from '@coinbase/cds-common/internal/data/assets'; import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; import { prices } from '@coinbase/cds-common/internal/data/prices'; 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 { DataCard } from '@coinbase/cds-web/alpha/data-card/DataCard'; import { ListCell } from '@coinbase/cds-web/cells'; import { useBreakpoints } from '@coinbase/cds-web/hooks/useBreakpoints'; import { Box, HStack, VStack } from '@coinbase/cds-web/layout'; @@ -21,9 +32,7 @@ import { Text } from '@coinbase/cds-web/typography'; import { m } from 'framer-motion'; import { - type AxisBounds, DefaultScrubberBeacon, - DefaultScrubberLabel, defaultTransition, PeriodSelector, PeriodSelectorActiveIndicator, @@ -31,7 +40,6 @@ import { projectPoint, Scrubber, type ScrubberBeaconProps, - type ScrubberLabelProps, type ScrubberRef, useCartesianChartContext, useScrubberContext, @@ -49,9 +57,16 @@ import { type SolidLineProps, } from '..'; +const sampleData = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + export default { component: LineChart, title: 'Components/Chart/LineChart', + parameters: { + a11y: { + test: 'todo', + }, + }, }; const Example: React.FC< @@ -78,7 +93,7 @@ function MultipleLine() { const chartAccessibilityLabel = `Website visitors across ${pageViews.length} pages.`; - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { return `${pages[index]} has ${pageViews[index]} views and ${uniqueVisitors[index]} unique visitors.`; }, @@ -124,7 +139,89 @@ function MultipleLine() { tickLabelFormatter: numberFormatter, }} > - + + + ); +} + +function HorizontalLine() { + const dataset = [ + { month: 'Jan', seoul: 21 }, + { month: 'Feb', seoul: 28 }, + { month: 'Mar', seoul: 41 }, + { month: 'Apr', seoul: 73 }, + { month: 'May', seoul: 99 }, + { month: 'June', seoul: 144 }, + { month: 'July', seoul: 319 }, + { month: 'Aug', seoul: 249 }, + { month: 'Sept', seoul: 131 }, + { month: 'Oct', seoul: 55 }, + { month: 'Nov', seoul: 48 }, + { month: 'Dec', seoul: 25 }, + ]; + + return ( + d.seoul), color: 'var(--color-accentBoldBlue)' }, + ]} + xAxis={{ label: 'rainfall (mm)' }} + yAxis={{ + data: dataset.map((d) => d.month), + }} + /> + ); +} + +function HorizontalLineGradientImplicitAxis() { + const dataset = [ + { month: 'Jan', seoul: 21 }, + { month: 'Feb', seoul: 28 }, + { month: 'Mar', seoul: 41 }, + { month: 'Apr', seoul: 73 }, + { month: 'May', seoul: 99 }, + { month: 'June', seoul: 144 }, + { month: 'July', seoul: 319 }, + { month: 'Aug', seoul: 249 }, + { month: 'Sept', seoul: 131 }, + { month: 'Oct', seoul: 55 }, + { month: 'Nov', seoul: 48 }, + { month: 'Dec', seoul: 25 }, + ]; + const values = dataset.map((d) => d.seoul); + const min = Math.min(...values); + const max = Math.max(...values); + + return ( + d.month), + }} + > + ); } @@ -135,7 +232,7 @@ function DataFormat() { const chartAccessibilityLabel = `Chart with custom X and Y data. ${yData.length} data points`; - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { return `Point ${index + 1}: X value ${xData[index]}, Y value ${yData[index]}`; }, @@ -168,7 +265,7 @@ function DataFormat() { showGrid: true, }} > - + ); } @@ -231,7 +328,7 @@ function LiveUpdates() { return `Live Bitcoin price chart. Current price: $${priceData[priceData.length - 1].toFixed(2)}`; }, [priceData]); - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { const price = priceData[index]; return `Bitcoin price at position ${index + 1}: $${price.toFixed(2)}`; @@ -254,7 +351,11 @@ function LiveUpdates() { }, ]} > - + ); } @@ -326,7 +427,7 @@ function Interaction() { series={[ { id: 'prices', - data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], + data: sampleData, }, ]} > @@ -338,7 +439,6 @@ function Interaction() { function Points() { const keyMarketShiftIndices = [4, 6, 7, 9, 10]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; return ( @@ -374,21 +474,16 @@ function Points() { } function BasicAccessible() { - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); - // Chart-level accessibility label provides overview const chartAccessibilityLabel = useMemo(() => { - const currentPrice = data[data.length - 1]; - return `Price chart showing trend over ${data.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; - }, [data]); + const currentPrice = sampleData[sampleData.length - 1]; + return `Price chart showing trend over ${sampleData.length} data points. Current value: ${currentPrice}. Use arrow keys to adjust view`; + }, []); // Scrubber-level accessibility label provides specific position info - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Price at position ${index + 1} of ${data.length}: ${data[index]}`; - }, - [data], - ); + const getScrubberAccessibilityLabel = useCallback((index: number) => { + return `Price at position ${index + 1} of ${sampleData.length}: ${sampleData[index]}`; + }, []); return ( - + ); } function AccessibleWithHeader() { const headerId = useId(); - const data = useMemo(() => [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], []); // Display label provides overview const displayLabel = useMemo( - () => `Revenue chart showing trend. Current value: ${data[data.length - 1]}`, - [data], + () => `Revenue chart showing trend. Current value: ${sampleData[sampleData.length - 1]}`, + [], ); // Scrubber-specific accessibility label - const scrubberAccessibilityLabel = useCallback( - (index: number) => { - return `Viewing position ${index + 1} of ${data.length}, value: ${data[index]}`; - }, - [data], - ); + const getScrubberAccessibilityLabel = useCallback((index: number) => { + return `Viewing position ${index + 1} of ${sampleData.length}, value: ${sampleData[index]}`; + }, []); return ( @@ -444,14 +535,14 @@ function AccessibleWithHeader() { series={[ { id: 'revenue', - data: data, + data: sampleData, }, ]} yAxis={{ showGrid: true, }} > - + ); @@ -471,7 +562,6 @@ function Gradients() { 'teal', 'chartreuse', ]; - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; const [currentSpectrumColor, setCurrentSpectrumColor] = useState('pink'); @@ -503,17 +593,17 @@ function Gradients() { series={[ { id: 'continuousGradient', - data: data, + data: sampleData, gradient: { stops: [ { offset: 0, color: `rgb(var(--${currentSpectrumColor}80))` }, - { offset: Math.max(...data), color: `rgb(var(--${currentSpectrumColor}20))` }, + { offset: Math.max(...sampleData), color: `rgb(var(--${currentSpectrumColor}20))` }, ], }, }, { id: 'discreteGradient', - data: data.map((d) => d + 50), + data: sampleData.map((d) => d + 50), // You can create a "discrete" gradient by having multiple stops at the same offset gradient: { stops: ({ min, max }) => [ @@ -535,7 +625,7 @@ function Gradients() { }, { id: 'xAxisGradient', - data: data.map((d) => d + 100), + data: sampleData.map((d) => d + 100), gradient: { // You can also configure by the x-axis. axis: 'x', @@ -580,7 +670,7 @@ function GainLossChart() { const chartAccessibilityLabel = `Gain/Loss chart showing price changes. Current value: ${tickLabelFormatter(data[data.length - 1])}`; - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { const value = data[index]; const status = value >= 0 ? 'gain' : 'loss'; @@ -618,18 +708,17 @@ function GainLossChart() { > - + ); } function HighLowPrice() { - const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; - const minPrice = Math.min(...data); - const maxPrice = Math.max(...data); + const minPrice = Math.min(...sampleData); + const maxPrice = Math.max(...sampleData); - const minPriceIndex = data.indexOf(minPrice); - const maxPriceIndex = data.indexOf(maxPrice); + const minPriceIndex = sampleData.indexOf(minPrice); + const maxPriceIndex = sampleData.indexOf(maxPrice); const formatPrice = useCallback((price: number) => { return `$${price.toLocaleString('en-US', { @@ -645,7 +734,7 @@ function HighLowPrice() { series={[ { id: 'prices', - data: data, + data: sampleData, }, ]} > @@ -863,6 +952,7 @@ function Compact() { return ( { const price = scrubberPriceFormatter.format(sparklineTimePeriodDataValues[index]); const date = formatDate(sparklineTimePeriodDataTimestamps[index]); @@ -1088,7 +1178,7 @@ function AssetPriceWithDottedArea() { @@ -1130,7 +1220,7 @@ function AssetPriceWidget() { const chartAccessibilityLabel = `Bitcoin price chart. Current price: ${formatPrice(latestPrice)}. Change: ${formatPercentChange(percentChange)}`; - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { return `Bitcoin price at position ${index + 1}: ${formatPrice(prices[index])}`; }, @@ -1200,7 +1290,7 @@ function AssetPriceWidget() { > @@ -1224,7 +1314,7 @@ function ServiceAvailability() { const chartAccessibilityLabel = `Availability chart showing ${availabilityEvents.length} data points over time`; - const scrubberAccessibilityLabel = useCallback( + const getScrubberAccessibilityLabel = useCallback( (index: number) => { const event = availabilityEvents[index]; const formattedDate = event.date.toLocaleDateString('en-US', { @@ -1290,7 +1380,7 @@ function ServiceAvailability() { })} seriesId="availability" /> - + ); } @@ -1560,377 +1650,330 @@ function MonotoneAssetPrice() { ); } -function CustomLabelComponent() { - const CustomLabelComponent = memo((props: ScrubberLabelProps) => { - const { drawingArea } = useCartesianChartContext(); - - if (!drawingArea) return; - - return ( - - ); - }); +export const All = () => { return ( - - `Day ${dataIndex + 1}`} - /> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `Day ${dataX}`, + }} + yAxis={{ + showGrid: true, + showLine: true, + showTickMarks: true, + }} + /> + + + + + + + + + + + + ({ min, max: max - 24 }), + }} + > + ( + + )} + dataY={10} + stroke="var(--color-fg)" + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); -} +}; + +function DataCardWithLineChart() { + const exampleThumbnail = ( + + ); + + const getLineChartSeries = () => [ + { + id: 'price', + data: prices.slice(0, 30).map((price: string) => parseFloat(price)), + color: 'var(--color-accentBoldBlue)', + }, + ]; + + const lineChartSeries = useMemo(() => getLineChartSeries(), []); + const lineChartSeries2 = useMemo(() => getLineChartSeries(), []); + const ref = useRef(null); -export const All = () => { return ( - + - - - - - - - - - - - - - - - - - +
    + + ↗ 25.25% + + } + > - - - - - - - - - - - - - - + + + ↗ 25.25% + + } + > `Day ${dataX}`, - }} - yAxis={{ - showGrid: true, - showLine: true, - showTickMarks: true, - }} - /> - - - - - - - - - - - - ({ min, max: max - 24 }), - }} - > - ( - - )} - dataY={10} - stroke="var(--color-fg)" + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -export const Transitions = () => { - const dataCount = 20; - const maxDataOffset = 15000; - const minStepOffset = 2500; - const maxStepOffset = 10000; - const domainLimit = 20000; - const updateInterval = 500; - - const myTransitionConfig = { type: 'spring', stiffness: 700, damping: 20 }; - const negativeColor = 'rgb(var(--gray15))'; - const positiveColor = 'var(--color-fgPositive)'; - - function generateNextValue(previousValue: number) { - const range = maxStepOffset - minStepOffset; - const offset = Math.random() * range + minStepOffset; - - let direction; - if (previousValue >= maxDataOffset) { - direction = -1; - } else if (previousValue <= -maxDataOffset) { - direction = 1; - } else { - direction = Math.random() < 0.5 ? -1 : 1; - } - - let newValue = previousValue + offset * direction; - newValue = Math.max(-maxDataOffset, Math.min(maxDataOffset, newValue)); - return newValue; - } - - function generateInitialData() { - const data = []; - - let previousValue = Math.random() * 2 * maxDataOffset - maxDataOffset; - data.push(previousValue); - - for (let i = 1; i < dataCount; i++) { - const newValue = generateNextValue(previousValue); - data.push(newValue); - previousValue = newValue; - } - - return data; - } - - const MyGradient = memo((props: DottedAreaProps) => { - const areaGradient = { - stops: ({ min, max }: AxisBounds) => [ - { offset: min, color: negativeColor, opacity: 1 }, - { offset: 0, color: negativeColor, opacity: 0 }, - { offset: 0, color: positiveColor, opacity: 0 }, - { offset: max, color: positiveColor, opacity: 1 }, - ], - }; - - return ; - }); - - function CustomTransitionsChart() { - const [data, setData] = useState(generateInitialData); - - useEffect(() => { - const intervalId = setInterval(() => { - setData((currentData) => { - const lastValue = currentData[currentData.length - 1] ?? 0; - const newValue = generateNextValue(lastValue); - - return [...currentData.slice(1), newValue]; - }); - }, updateInterval); - - return () => clearInterval(intervalId); - }, []); - - const tickLabelFormatter = useCallback( - (value: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - maximumFractionDigits: 0, - }).format(value), - [], - ); - - const valueAtIndexFormatter = useCallback( - (dataIndex: number) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(data[dataIndex]), - [data], - ); - - const lineGradient = { - stops: [ - { offset: 0, color: negativeColor }, - { offset: 0, color: positiveColor }, - ], - }; - - return ( - + ↗ 25.25% + + } > - - - - - ); - } - - return ; -}; + + + ); +} diff --git a/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx b/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx index 9741ce29a3..040ae41f6d 100644 --- a/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/ReferenceLine.stories.tsx @@ -6,7 +6,9 @@ import { VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; import { useCartesianChartContext } from '../../ChartProvider'; +import { Scrubber } from '../../scrubber'; import { ChartText } from '../../text/ChartText'; +import { useScrubberContext } from '../../utils'; import { DefaultReferenceLineLabel } from '../DefaultReferenceLineLabel'; import { DottedLine } from '../DottedLine'; import { LineChart } from '../LineChart'; @@ -306,6 +308,82 @@ const DraggableReferenceLine = memo( }, ); +const FADE_ZONE = 128; + +const StartPriceLabel = memo>((props) => { + const { scrubberPosition } = useScrubberContext(); + const { getXScale, drawingArea } = useCartesianChartContext(); + const isScrubbing = scrubberPosition !== undefined; + + const opacity = useMemo(() => { + if (!isScrubbing) return 0; + const xScale = getXScale(); + if (!xScale) return 1; + const scrubX = xScale(scrubberPosition) ?? 0; + const rightEdge = drawingArea.x + drawingArea.width; + return rightEdge - scrubX >= FADE_ZONE ? 1 : 0; + }, [isScrubbing, scrubberPosition, getXScale, drawingArea]); + + return ( + + ); +}); + +const StartPriceReferenceLine = () => { + const hourData = useMemo(() => sparklineInteractiveData.hour, []); + const startPrice = hourData[0].value; + const endPrice = hourData[hourData.length - 1].value; + const isPositive = endPrice >= startPrice; + const seriesColor = isPositive ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)'; + + const formattedStartPrice = useMemo( + () => + startPrice.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }), + [startPrice], + ); + + return ( + d.value), + color: seriesColor, + }, + ]} + xAxis={{ + range: ({ min, max }) => ({ min, max: max - 24 }), + }} + > + + } + dataY={startPrice} + label={formattedStartPrice} + labelDx={-12} + labelHorizontalAlignment="right" + stroke="var(--color-fgMuted)" + /> + + ); +}; + const PriceTargetChart = () => { const priceData = useMemo(() => sparklineInteractiveData.year.map((d) => d.value), []); @@ -474,6 +552,9 @@ export const All = () => { + + + ); }; diff --git a/packages/web-visualization/src/chart/line/__tests__/LineChart.test.tsx b/packages/web-visualization/src/chart/line/__tests__/LineChart.test.tsx new file mode 100644 index 0000000000..a0458a870d --- /dev/null +++ b/packages/web-visualization/src/chart/line/__tests__/LineChart.test.tsx @@ -0,0 +1,450 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import type { LineComponentProps } from '../Line'; +import { LineChart } from '../LineChart'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in axis label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('LineChart', () => { + it('renders line content when enter transition is disabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart'); + const linePath = svg.querySelector('path'); + expect(linePath).toBeInTheDocument(); + expect(linePath?.getAttribute('d')).toBeTruthy(); + + const clipRect = svg.querySelector('clipPath rect'); + expect(clipRect).toBeInTheDocument(); + expect(Number(clipRect?.getAttribute('width'))).toBeGreaterThan(0); + }); + + it('passes custom transitions to custom line components', () => { + const customTransitions = { + enter: { type: 'tween' as const, duration: 0.25 }, + update: { type: 'spring' as const, stiffness: 320, damping: 30 }, + }; + const CustomLine = jest.fn((props: LineComponentProps) => ); + + render( + + + , + ); + + expect(CustomLine).toHaveBeenCalled(); + const firstCallProps = CustomLine.mock.calls[0][0]; + expect(firstCallProps.transitions).toEqual(customTransitions); + }); + + it('allows series-level transitions to override chart transitions', () => { + const chartTransitions = { + enter: { type: 'tween' as const, duration: 0.2 }, + update: { type: 'spring' as const, stiffness: 200, damping: 20 }, + }; + const seriesTransitions = { + enter: { type: 'tween' as const, duration: 0.5 }, + update: { type: 'spring' as const, stiffness: 500, damping: 45 }, + }; + const CustomLine = jest.fn((props: LineComponentProps) => ); + + render( + + + , + ); + + const callProps = CustomLine.mock.calls.map(([props]) => props as LineComponentProps); + const seriesAProps = callProps.find((props) => props.stroke === '#111111'); + const seriesBProps = callProps.find((props) => props.stroke === '#222222'); + + expect(seriesAProps).toBeDefined(); + expect(seriesBProps).toBeDefined(); + expect(seriesAProps?.transitions).toEqual(seriesTransitions); + expect(seriesBProps?.transitions).toEqual(chartTransitions); + }); + + it('shows axes and axis labels when enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-with-axes'); + expect(svg.querySelector('[data-axis="x"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="x-axis-label"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="y-axis-label"]')).toBeInTheDocument(); + }); + + it('hides axes when showXAxis and showYAxis are false', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-no-axes'); + expect(svg.querySelector('[data-axis="x"]')).not.toBeInTheDocument(); + expect(svg.querySelector('[data-axis="y"]')).not.toBeInTheDocument(); + }); + + it('renders points when points is enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-points'); + const pointsGroup = svg.querySelector('[data-component="line-points-group"]'); + expect(pointsGroup).toBeInTheDocument(); + expect(pointsGroup?.querySelectorAll('circle').length).toBeGreaterThan(0); + }); + + it('renders area fill when showArea is enabled', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-with-area'); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(1); + }); + + it('renders gradient definitions for gradient line series', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-gradient'); + const gradient = svg.querySelector('linearGradient'); + const stops = svg.querySelectorAll('linearGradient stop'); + const linePath = svg.querySelector('path[d]'); + + expect(gradient).toBeInTheDocument(); + expect(stops.length).toBeGreaterThanOrEqual(2); + expect(linePath?.getAttribute('stroke')).toMatch(/^url\(#/); + }); + + it('renders gradient stops from function-based gradient config', () => { + render( + + [ + { offset: min, color: '#111111' }, + { offset: (min + max) / 2, color: '#777777' }, + { offset: max, color: '#ffffff' }, + ], + }, + }, + ]} + testID="line-chart-function-gradient" + width={600} + /> + , + ); + + const svg = screen.getByTestId('line-chart-function-gradient'); + const stops = svg.querySelectorAll('linearGradient stop'); + expect(stops.length).toBe(3); + }); + + it('applies x-axis gradients to point colors when gradient axis is x', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-x-gradient'); + const points = Array.from(svg.querySelectorAll('[data-component="line-points-group"] circle')); + expect(points).toHaveLength(5); + expect(points.at(0)?.getAttribute('fill')).toBe('#ff0000'); + expect(points.at(-1)?.getAttribute('fill')).toBe('#00ff00'); + }); + + it('defaults gradients to the x-axis in horizontal layout when axis is omitted', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-horizontal-default-gradient-axis'); + const points = Array.from(svg.querySelectorAll('[data-component="line-points-group"] circle')); + expect(points).toHaveLength(3); + expect(points.at(0)?.getAttribute('fill')).toBe('#ff0000'); + expect(points.at(-1)?.getAttribute('fill')).toBe('#00ff00'); + }); + + it('renders dotted lines when type is dotted', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-dotted'); + const dottedPath = svg.querySelector('path[stroke-dasharray]'); + expect(dottedPath).toBeInTheDocument(); + expect(dottedPath?.getAttribute('stroke-dasharray')).toBe('0 4'); + }); + + it('applies series-level line style overrides', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-series-overrides'); + const linePath = svg.querySelector('path[d]'); + + expect(linePath?.getAttribute('stroke')).toBe('#ff00ff'); + expect(linePath?.getAttribute('stroke-width')).toBe('6'); + expect(linePath?.getAttribute('stroke-dasharray')).toBe('0 4'); + }); + + it('allows series-level showArea override over chart defaults', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-show-area-override'); + const drawablePaths = Array.from(svg.querySelectorAll('path')).filter((path) => + Boolean(path.getAttribute('d')), + ); + expect(drawablePaths.length).toBeGreaterThan(1); + }); + + it('maps line points correctly for horizontal layout', () => { + render( + + + , + ); + + const svg = screen.getByTestId('line-chart-horizontal-layout'); + const pointElements = Array.from( + svg.querySelectorAll('[data-component="line-points-group"] circle'), + ); + + expect(pointElements.length).toBe(3); + + pointElements.forEach((point) => { + const cx = Number(point.getAttribute('cx')); + const cy = Number(point.getAttribute('cy')); + + expect(cx).toBeGreaterThanOrEqual(0); + expect(cx).toBeLessThanOrEqual(600); + expect(cy).toBeGreaterThanOrEqual(0); + expect(cy).toBeLessThanOrEqual(400); + }); + }); +}); diff --git a/packages/web-visualization/src/chart/line/__tests__/ReferenceLine.test.tsx b/packages/web-visualization/src/chart/line/__tests__/ReferenceLine.test.tsx new file mode 100644 index 0000000000..91cdf4bc7e --- /dev/null +++ b/packages/web-visualization/src/chart/line/__tests__/ReferenceLine.test.tsx @@ -0,0 +1,101 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { ReferenceLine } from '../ReferenceLine'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('ReferenceLine', () => { + it('renders a horizontal reference line with label', () => { + render( + + + + + , + ); + + const svg = screen.getByTestId('reference-line-horizontal-chart'); + const referencePath = svg.querySelector('path[stroke="#ff0000"]'); + expect(referencePath).toBeInTheDocument(); + expect(referencePath?.getAttribute('d')).toContain('L'); + expect(screen.getByText('Target')).toBeInTheDocument(); + }); + + it('renders a vertical reference line with label', () => { + render( + + + + + , + ); + + const svg = screen.getByTestId('reference-line-vertical-chart'); + const referencePath = svg.querySelector('path[stroke="#00aa00"]'); + expect(referencePath).toBeInTheDocument(); + expect(referencePath?.getAttribute('d')).toContain('L'); + expect(screen.getByText('Marker')).toBeInTheDocument(); + }); + + it('does not render when target y-axis does not exist', () => { + render( + + + + + , + ); + + const svg = screen.getByTestId('reference-line-missing-axis-chart'); + const referencePath = svg.querySelector('path[stroke="#123123"]'); + expect(referencePath).not.toBeInTheDocument(); + }); +}); diff --git a/packages/web-visualization/src/chart/point/Point.tsx b/packages/web-visualization/src/chart/point/Point.tsx index d2348b9700..aa42a4d395 100644 --- a/packages/web-visualization/src/chart/point/Point.tsx +++ b/packages/web-visualization/src/chart/point/Point.tsx @@ -6,7 +6,13 @@ import { m as motion, type Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartTextChildren, ChartTextProps } from '../text/ChartText'; -import { type PointLabelPosition, projectPoint } from '../utils'; +import { + defaultAccessoryEnterTransition, + defaultTransition, + getTransition, + type PointLabelPosition, + projectPoint, +} from '../utils'; import { DefaultPointLabel } from './DefaultPointLabel'; @@ -45,8 +51,15 @@ export type PointBaseProps = SharedProps & { /** * Optional Y-axis id to specify which axis to plot along. * @default first y-axis defined in chart props. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; + /** + * Optional X-axis id to specify which axis to plot along. + * @default first x-axis defined in chart props. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Radius of the point. * @default 5 @@ -215,8 +228,25 @@ export type PointProps = PointBaseProps & */ accessibilityLabel?: string; /** - * Transition configuration for animation. - * @default defaultTransition + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ transition?: Transition; }; @@ -225,6 +255,7 @@ export const Point = memo( ({ dataX, dataY, + xAxisId, yAxisId, fill = 'var(--color-fgPrimary)', radius = 5, @@ -244,6 +275,7 @@ export const Point = memo( labelFont, testID, animate: animateProp, + transitions, transition, ...svgProps }) => { @@ -255,7 +287,22 @@ export const Point = memo( } = useCartesianChartContext(); const animate = animateProp ?? animationEnabled; - const xScale = getXScale(); + const enterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [animate, transitions?.enter], + ); + + const updateTransition = useMemo( + () => + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + [animate, transitions?.update, transition], + ); + + const xScale = getXScale(xAxisId); const yScale = getYScale(yAxisId); const pixelCoordinate = useMemo(() => { @@ -347,7 +394,6 @@ export const Point = memo( cx={pixelCoordinate.x} cy={pixelCoordinate.y} fill={fill} - initial={false} onClick={ onClick ? (event: any) => @@ -361,7 +407,10 @@ export const Point = memo( strokeWidth={strokeWidth} style={mergedStyles} tabIndex={onClick ? 0 : -1} - transition={transition} + transition={{ + cx: updateTransition, + cy: updateTransition, + }} variants={variants} whileHover={onClick ? 'hovered' : 'default'} whileTap={onClick ? 'pressed' : 'default'} @@ -385,7 +434,7 @@ export const Point = memo( pixelCoordinate.x, pixelCoordinate.y, accessibilityLabel, - transition, + updateTransition, ]); if (!xScale || !yScale) { @@ -394,28 +443,34 @@ export const Point = memo( return ( - - {innerPoint} - - {label && ( - - {label} - - )} + {innerPoint} + + {label && ( + + {label} + + )} + ); }, diff --git a/packages/web-visualization/src/chart/point/__tests__/Point.test.tsx b/packages/web-visualization/src/chart/point/__tests__/Point.test.tsx new file mode 100644 index 0000000000..ee79ad2799 --- /dev/null +++ b/packages/web-visualization/src/chart/point/__tests__/Point.test.tsx @@ -0,0 +1,122 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { Point } from '../Point'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG text measurement in label rendering. + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +describe('Point', () => { + it('renders point with custom visual props and label', () => { + render( + + + + + , + ); + + const svg = screen.getByTestId('point-chart'); + const pointGroup = svg.querySelector('[data-testid="point-node"]'); + const circle = pointGroup?.querySelector('circle'); + + expect(pointGroup).toBeInTheDocument(); + expect(circle).toBeInTheDocument(); + expect(circle?.getAttribute('fill')).toBe('#123456'); + expect(circle?.getAttribute('r')).toBe('8'); + expect(screen.getByText('Peak')).toBeInTheDocument(); + }); + + it('calls onClick with point metadata on click', () => { + const onClick = jest.fn(); + + render( + + + + + , + ); + + const pointElement = screen.getByRole('button', { name: 'Interactive point' }); + fireEvent.click(pointElement); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][1]).toMatchObject({ + dataX: 1, + dataY: 20, + }); + }); + + it('calls onClick with point metadata on Enter key', () => { + const onClick = jest.fn(); + + render( + + + + + , + ); + + const pointElement = screen.getByRole('button', { name: 'Keyboard point' }); + fireEvent.keyDown(pointElement, { key: 'Enter' }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick.mock.calls[0][1]).toMatchObject({ + dataX: 3, + dataY: 40, + }); + }); +}); diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx index 8f7a3531d6..ff0a8ea8f8 100644 --- a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx +++ b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx @@ -1,4 +1,5 @@ import { forwardRef, memo, useImperativeHandle, useMemo } from 'react'; +import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; import { m as motion, type Transition, @@ -7,17 +8,18 @@ import { } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; -import { defaultTransition, projectPoint } from '../utils'; +import { defaultTransition, getTransition, instantTransition, projectPoint } from '../utils'; import type { ScrubberBeaconProps, ScrubberBeaconRef } from './Scrubber'; -const radius = 5; -const strokeWidth = 2; +const defaultRadius = 5; +const defaultStrokeWidth = 2; +const defaultStroke = 'var(--color-bg)'; const pulseOpacityStart = 0.5; const pulseOpacityEnd = 0; -const pulseRadiusStart = 10; -const pulseRadiusEnd = 15; +const pulseRadiusStartMultiplier = 2; +const pulseRadiusEndMultiplier = 3; const defaultPulseTransition: Transition = { duration: 1.6, @@ -26,7 +28,18 @@ const defaultPulseTransition: Transition = { const defaultPulseRepeatDelay = 0.4; -export type DefaultScrubberBeaconProps = ScrubberBeaconProps; +export type DefaultScrubberBeaconProps = ScrubberBeaconProps & { + /** + * Radius of the beacon circle. + * @default 5 + */ + radius?: number; + /** + * Stroke width of the beacon circle. + * @default 2 + */ + strokeWidth?: number; +}; export const DefaultScrubberBeacon = memo( forwardRef( @@ -38,19 +51,30 @@ export const DefaultScrubberBeacon = memo( dataY, isIdle, idlePulse, + animate: animateProp, transitions, opacity = 1, + radius = defaultRadius, + stroke = defaultStroke, + strokeWidth = defaultStrokeWidth, className, style, - testID, + testID = `${seriesId}-beacon`, }, ref, ) => { - const [scope, animate] = useAnimate(); - const { getSeries, getXScale, getYScale, drawingArea } = useCartesianChartContext(); + const [scope, animateFn] = useAnimate(); + const { + animate: animateContext, + getSeries, + getXScale, + getYScale, + drawingArea, + } = useCartesianChartContext(); + const animate = animateProp ?? animateContext; const targetSeries = getSeries(seriesId); - const xScale = getXScale(); + const xScale = getXScale(targetSeries?.xAxisId); const yScale = getYScale(targetSeries?.yAxisId); const color = useMemo( @@ -58,10 +82,14 @@ export const DefaultScrubberBeacon = memo( [colorProp, targetSeries], ); - const updateTransition = useMemo( - () => transitions?.update ?? defaultTransition, - [transitions?.update], - ); + const prevIsIdle = usePreviousValue(isIdle); + const isIdleTransition = prevIsIdle !== undefined && isIdle !== prevIsIdle; + + const updateTransition = useMemo(() => { + if (isIdleTransition) return instantTransition; + if (!isIdle) return instantTransition; + return getTransition(transitions?.update, animate, defaultTransition); + }, [transitions?.update, isIdle, animate, isIdleTransition]); const pulseTransition = useMemo( () => transitions?.pulse ?? defaultPulseTransition, [transitions?.pulse], @@ -76,13 +104,16 @@ export const DefaultScrubberBeacon = memo( return projectPoint({ x: dataX, y: dataY, xScale, yScale }); }, [dataX, dataY, xScale, yScale]); + const pulseRadiusStart = radius * pulseRadiusStartMultiplier; + const pulseRadiusEnd = radius * pulseRadiusEndMultiplier; + useImperativeHandle( ref, () => ({ pulse: () => { // Only pulse when idle and idlePulse is not enabled if (isIdle && !idlePulse && scope.current) { - animate( + animateFn( scope.current, { opacity: [pulseOpacityStart, pulseOpacityEnd], @@ -93,7 +124,7 @@ export const DefaultScrubberBeacon = memo( } }, }), - [isIdle, idlePulse, scope, animate, pulseTransition], + [isIdle, idlePulse, scope, animateFn, pulseTransition, pulseRadiusStart, pulseRadiusEnd], ); // Create continuous pulse transition by repeating the base pulse transition with delay @@ -120,68 +151,52 @@ export const DefaultScrubberBeacon = memo( if (!pixelCoordinate) return; - if (isIdle) { - return ( - - - - - - - ); - } + : { opacity: pulseOpacityEnd, r: pulseRadiusStart } + } + cx={0} + cy={0} + data-testid={`${testID}-pulse`} + fill={color} + initial={{ + opacity: shouldPulse ? pulseOpacityStart : pulseOpacityEnd, + r: pulseRadiusStart, + }} + /> + ); return ( - + {pulseCircle} + + )} + ); diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx index 3d0386eb91..4a5c5a2f95 100644 --- a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx +++ b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx @@ -1,4 +1,5 @@ import { memo } from 'react'; +import { m as motion } from 'framer-motion'; import { ChartText, type ChartTextProps } from '../text'; @@ -31,22 +32,27 @@ export const DefaultScrubberBeaconLabel = memo( bottom: labelVerticalInset, }, label, + transition, + y, ...chartTextProps }) => { return ( - - {label} - + + + {label} + + ); }, ); diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx index 0e60180149..153863394b 100644 --- a/packages/web-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx +++ b/packages/web-visualization/src/chart/scrubber/DefaultScrubberLabel.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useCartesianChartContext } from '../ChartProvider'; import { DefaultReferenceLineLabel } from '../line'; @@ -9,18 +9,29 @@ export type DefaultScrubberLabelProps = ScrubberLabelProps; /** * DefaultScrubberLabel is the default label component for the scrubber line. - * It will center the label vertically with the top available area. + * In vertical layout, it positions the label above the scrubber line. + * In horizontal layout, it centers the label in the chart's right inset. */ export const DefaultScrubberLabel = memo( - ({ verticalAlignment = 'middle', dy, ...props }) => { - const { drawingArea } = useCartesianChartContext(); + ({ dx: dxProp, dy: dyProp, ...props }) => { + const { drawingArea, layout, width: chartWidth } = useCartesianChartContext(); + const isHorizontalLayout = layout === 'horizontal'; - return ( - - ); + const dx = useMemo(() => { + if (dxProp !== undefined) return dxProp; + if (isHorizontalLayout) { + const drawingAreaEnd = drawingArea.x + drawingArea.width; + const rightOffset = chartWidth - drawingAreaEnd; + return rightOffset / 2; + } + return 0; + }, [drawingArea.width, drawingArea.x, dxProp, isHorizontalLayout, chartWidth]); + const dy = useMemo(() => { + if (dyProp !== undefined) return dyProp; + if (isHorizontalLayout) return 0; + return -0.5 * drawingArea.y; + }, [dyProp, isHorizontalLayout, drawingArea.y]); + + return ; }, ); diff --git a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx index cc4b1a05e9..bbb5c934d0 100644 --- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx @@ -8,13 +8,13 @@ import { type ReferenceLineBaseProps, type ReferenceLineLabelComponentProps, } from '../line'; -import type { ChartTextProps } from '../text'; +import type { ChartTextChildren, ChartTextProps } from '../text'; import { - accessoryFadeTransitionDelay, - accessoryFadeTransitionDuration, type ChartInset, type ChartScaleFunction, + defaultAccessoryEnterTransition, getPointOnScale, + getTransition, type Series, useScrubberContext, } from '../utils'; @@ -41,7 +41,7 @@ export type ScrubberBeaconRef = { pulse: () => void; }; -export type ScrubberBeaconProps = SharedProps & { +export type ScrubberBeaconBaseProps = { /** * Id of the series. */ @@ -52,10 +52,14 @@ export type ScrubberBeaconProps = SharedProps & { color?: string; /** * X coordinate in data space. + * In vertical layout this is the scrubber index-axis value. + * In horizontal layout this is the series value. */ dataX: number; /** * Y coordinate in data space. + * In vertical layout this is the series value. + * In horizontal layout this is the scrubber index-axis value. */ dataY: number; /** @@ -64,44 +68,66 @@ export type ScrubberBeaconProps = SharedProps & { isIdle?: boolean; /** * Pulse the beacon while it is at rest. + * + * @note Only has an effect when `isIdle` is `true`. Pulse animations work + * regardless of the chart's `animate` prop. */ idlePulse?: boolean; /** - * Transition configuration for beacon animations. + * Whether position animations are enabled. + * @default to ChartContext's animate value */ - transitions?: { - /** - * Transition used for beacon position updates. - * @default defaultTransition - */ - update?: Transition; - /** - * Transition used for the pulse animation. - * @default { duration: 1.6, ease: 'easeInOut' } - */ - pulse?: Transition; - /** - * Delay, in seconds between pulse transitions - * when `idlePulse` is enabled. - * @default 0.4 - */ - pulseRepeatDelay?: number; - }; + animate?: boolean; /** * Opacity of the beacon. * @default 1 */ opacity?: number; /** - * Custom className for styling. + * Stroke color of the beacon circle. + * @default 'var(--color-bg)' */ - className?: string; - /** - * Custom inline styles. - */ - style?: React.CSSProperties; + stroke?: string; }; +export type ScrubberBeaconProps = SharedProps & + ScrubberBeaconBaseProps & { + /** + * Transition configuration for beacon animations. + */ + transitions?: { + /** + * Transition for the initial enter/reveal animation. + * Set to `null` to disable. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + /** + * Transition used for the pulse animation. + * @default transition { duration: 1.6, ease: 'easeInOut' } + */ + pulse?: Transition; + /** + * Delay, in seconds between pulse transitions + * when `idlePulse` is enabled. + * @default 0.4 + */ + pulseRepeatDelay?: number; + }; + /** + * Custom className for styling. + */ + className?: string; + /** + * Custom inline styles. + */ + style?: React.CSSProperties; + }; + export type ScrubberBeaconComponent = React.FC< ScrubberBeaconProps & { ref?: React.Ref } >; @@ -109,16 +135,29 @@ export type ScrubberBeaconComponent = React.FC< export type ScrubberBeaconLabelProps = Pick & Pick< ChartTextProps, - 'x' | 'y' | 'dx' | 'horizontalAlignment' | 'onDimensionsChange' | 'opacity' | 'font' + | 'x' + | 'y' + | 'dx' + | 'horizontalAlignment' + | 'onDimensionsChange' + | 'opacity' + | 'font' + | 'className' + | 'style' > & { /** * Label for the series. */ - label: string; + label: ChartTextChildren; /** * Id of the series. */ seriesId: Series['id']; + /** + * Transition configuration for position animations. + * When provided, the label component should animate its y position using this transition. + */ + transition?: Transition; }; export type ScrubberBeaconLabelComponent = React.FC; @@ -135,6 +174,12 @@ export type ScrubberBaseProps = SharedProps & * By default, all series will be highlighted. */ seriesIds?: string[]; + /** + * Hides the beacon labels while keeping the line label visible (if provided). + * @default true in horizontal layout, false in vertical layout. + * @note Beacon labels are always hidden in horizontal layout, and cannot be overridden. + */ + hideBeaconLabels?: boolean; /** * Hides the scrubber line. * @note This hides Scrubber's ReferenceLine including the label. @@ -160,6 +205,12 @@ export type ScrubberBaseProps = SharedProps & * Measured in pixels. */ beaconLabelHorizontalOffset?: ScrubberBeaconLabelGroupBaseProps['labelHorizontalOffset']; + /** + * Preferred side for beacon labels. + * @note labels will switch to the opposite side if there's not enough space on the preferred side. + * @default 'right' + */ + beaconLabelPreferredSide?: ScrubberBeaconLabelGroupBaseProps['labelPreferredSide']; /** * Label text displayed above the scrubber line. * Can be a static string or a function that receives the current dataIndex. @@ -173,7 +224,7 @@ export type ScrubberBaseProps = SharedProps & labelFont?: ChartTextProps['font']; /** * Bounds inset for the scrubber line label to prevent cutoff at chart edges. - * @default { top: 4, bottom: 20, left: 12, right: 12 } when labelElevated is true, otherwise none + * @default inset { top: 4, bottom: 20, left: 12, right: 12 } when labelElevated is true, otherwise none */ labelBoundsInset?: number | ChartInset; /** @@ -185,33 +236,53 @@ export type ScrubberBaseProps = SharedProps & */ lineStroke?: ReferenceLineBaseProps['stroke']; /** - * Transition configuration for the scrubber beacon. + * Stroke color of the scrubber beacon circle. + * @default 'var(--color-bg)' */ - beaconTransitions?: ScrubberBeaconProps['transitions']; + beaconStroke?: string; }; export type ScrubberProps = ScrubberBaseProps & { + /** + * Transition configuration for the scrubber. + * Controls enter, update, and pulse animations for beacons and beacon labels. + */ + transitions?: ScrubberBeaconProps['transitions']; + /** + * Transition configuration for the scrubber beacon. + * @deprecated Use `transitions` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + */ + beaconTransitions?: ScrubberBeaconProps['transitions']; /** * Accessibility label for the scrubber. Can be a static string or a function that receives the current dataIndex. * If not provided, label will be used if it resolves to a string. */ accessibilityLabel?: string | ((dataIndex: number) => string); - /** - * Custom styles for scrubber elements. - */ + /** Custom styles for individual elements of the Scrubber component */ styles?: { + /** Overlay element */ overlay?: React.CSSProperties; + /** Beacon circle element */ beacon?: React.CSSProperties; + /** Scrubber line element */ line?: React.CSSProperties; + /** Scrubber line label element */ + label?: React.CSSProperties; + /** Beacon label element */ beaconLabel?: React.CSSProperties; }; - /** - * Custom class names for scrubber elements. - */ + /** Custom class names for individual elements of the Scrubber component */ classNames?: { + /** Overlay element */ overlay?: string; + /** Beacon circle element */ beacon?: string; + /** Scrubber line element */ line?: string; + /** Scrubber line label element */ + label?: string; + /** Beacon label element */ beaconLabel?: string; }; }; @@ -226,6 +297,7 @@ export const Scrubber = memo( ( { seriesIds, + hideBeaconLabels, hideLine, label, accessibilityLabel, @@ -239,12 +311,15 @@ export const Scrubber = memo( overlayOffset = 2, beaconLabelMinGap, beaconLabelHorizontalOffset, + beaconLabelPreferredSide, labelFont, labelBoundsInset, beaconLabelFont, testID, idlePulse, beaconTransitions, + transitions = beaconTransitions, + beaconStroke, styles, classNames, }, @@ -253,8 +328,17 @@ export const Scrubber = memo( const beaconGroupRef = React.useRef(null); const { scrubberPosition } = useScrubberContext(); - const { getXScale, getXAxis, animate, series, drawingArea, dataLength } = - useCartesianChartContext(); + const { + layout, + getXScale, + getYScale, + getXAxis, + getYAxis, + animate, + series, + drawingArea, + dataLength, + } = useCartesianChartContext(); // Expose imperative handle with pulse method useImperativeHandle(ref, () => ({ @@ -270,24 +354,29 @@ export const Scrubber = memo( return seriesIds; }, [series, seriesIds]); - const { dataX, dataIndex } = useMemo(() => { - const xScale = getXScale() as ChartScaleFunction; - const xAxis = getXAxis(); - if (!xScale) return { dataX: undefined, dataIndex: undefined }; + const { dataValue, dataIndex } = useMemo(() => { + const categoryAxisIsX = layout !== 'horizontal'; + const indexScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction; + const indexAxis = categoryAxisIsX ? getXAxis() : getYAxis(); + if (!indexScale) return { dataValue: undefined, dataIndex: undefined }; const dataIndex = scrubberPosition ?? Math.max(0, dataLength - 1); - // Convert index to actual x value if axis has data - let dataX: number; - if (xAxis?.data && Array.isArray(xAxis.data) && xAxis.data[dataIndex] !== undefined) { - const dataValue = xAxis.data[dataIndex]; - dataX = typeof dataValue === 'string' ? dataIndex : dataValue; + // Convert index to actual data value if axis has data + let dataValue: number; + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex] !== undefined + ) { + const val = indexAxis.data[dataIndex]; + dataValue = typeof val === 'string' ? dataIndex : val; } else { - dataX = dataIndex; + dataValue = dataIndex; } - return { dataX, dataIndex }; - }, [getXScale, getXAxis, scrubberPosition, dataLength]); + return { dataValue, dataIndex }; + }, [getXScale, getYScale, getXAxis, getYAxis, scrubberPosition, dataLength, layout]); // Compute resolved accessibility label const resolvedAccessibilityLabel = useMemo(() => { @@ -318,12 +407,19 @@ export const Scrubber = memo( [series, filteredSeriesIds], ); - // Check if we have at least the default X scale - const defaultXScale = getXScale(); - if (!defaultXScale) return null; + const groupEnterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [transitions?.enter, animate], + ); + const shouldAnimateGroup = animate && groupEnterTransition !== null; + + const categoryAxisIsX = layout !== 'horizontal'; + const showBeaconLabels = !hideBeaconLabels && categoryAxisIsX && beaconLabels.length > 0; + const indexScale = categoryAxisIsX ? getXScale() : getYScale(); + if (!indexScale) return null; - const pixelX = - dataX !== undefined && defaultXScale ? getPointOnScale(dataX, defaultXScale) : undefined; + const pixelPos = + dataValue !== undefined && indexScale ? getPointOnScale(dataValue, indexScale) : undefined; return ( - {!hideOverlay && scrubberPosition !== undefined && pixelX !== undefined && ( + {!hideOverlay && scrubberPosition !== undefined && pixelPos !== undefined && ( - )} - {!hideLine && scrubberPosition !== undefined && dataX !== undefined && ( - )} + {!hideLine && + scrubberPosition !== undefined && + dataValue !== undefined && + dataIndex !== undefined && ( + + )} - {beaconLabels.length > 0 && ( + {showBeaconLabels && ( )} diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx index 13c47d75e6..7d0e09e382 100644 --- a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx +++ b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconGroup.tsx @@ -6,6 +6,7 @@ import { useCartesianChartContext } from '../ChartProvider'; import { type ChartScaleFunction, evaluateGradientAtValue, + getGradientAxis, getGradientConfig, useScrubberContext, } from '../utils'; @@ -13,48 +14,50 @@ import { import { DefaultScrubberBeacon } from './DefaultScrubberBeacon'; import type { ScrubberBeaconComponent, ScrubberBeaconProps, ScrubberBeaconRef } from './Scrubber'; -// Helper component to calculate beacon data for a specific series -const BeaconWithData = memo<{ - seriesId: string; +type BeaconWithDataProps = Pick< + ScrubberBeaconProps, + 'seriesId' | 'idlePulse' | 'animate' | 'transitions' | 'stroke' | 'className' | 'style' | 'testID' +> & { dataIndex: number; - dataX: number; + dataIndexValue: number; isIdle: boolean; BeaconComponent: ScrubberBeaconComponent; - idlePulse?: boolean; - transitions?: ScrubberBeaconProps['transitions']; - className?: string; - style?: React.CSSProperties; - testID?: string; beaconRef: (ref: ScrubberBeaconRef | null) => void; -}>( +}; + +// Helper component to calculate beacon data for a specific series +const BeaconWithData = memo( ({ seriesId, dataIndex, - dataX, + dataIndexValue, isIdle, BeaconComponent, idlePulse, + animate, transitions, className, style, testID, beaconRef, + stroke, }) => { - const { getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); + const { layout, getSeries, getSeriesData, getXScale, getYScale, getXAxis, getYAxis } = + useCartesianChartContext(); const series = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); const sourceData = useMemo(() => getSeriesData(seriesId), [getSeriesData, seriesId]); const gradient = series?.gradient; - // Get dataY from series data - const dataY = useMemo(() => { + // Get dataValue from series data + const dataValue = useMemo(() => { if (sourceData && dataIndex >= 0 && dataIndex < sourceData.length) { - const dataValue = sourceData[dataIndex]; + const value = sourceData[dataIndex]; - if (typeof dataValue === 'number') { - return dataValue; - } else if (Array.isArray(dataValue)) { - const validValues = dataValue.filter((val): val is number => val !== null); + if (typeof value === 'number') { + return value; + } else if (Array.isArray(value)) { + const validValues = value.filter((val): val is number => val !== null); if (validValues.length >= 1) { return validValues[validValues.length - 1]; } @@ -65,22 +68,32 @@ const BeaconWithData = memo<{ // Evaluate gradient color const color = useMemo(() => { - if (dataY === undefined) return series?.color ?? 'var(--color-fgPrimary)'; + if (dataValue === undefined) return series?.color ?? 'var(--color-fgPrimary)'; if (gradient) { - const xScale = getXScale(); + const xScale = getXScale(series?.xAxisId); const yScale = getYScale(series?.yAxisId); if (xScale && yScale) { - const gradientScale = gradient.axis === 'x' ? xScale : yScale; - const stops = getGradientConfig(gradient, xScale, yScale); + const categoryAxisIsX = layout !== 'horizontal'; + const gradientAxis = getGradientAxis(gradient, layout); + const gradientScale = gradientAxis === 'x' ? xScale : yScale; + const stops = getGradientConfig(gradient, xScale, yScale, layout); if (stops) { - const gradientAxis = gradient.axis ?? 'y'; - const dataValue = gradientAxis === 'x' ? dataX : dataY; + // Determine the correct data value to evaluate against based on gradient axis and layout + let evalValue: number; + if (gradientAxis === 'x') { + // X-axis gradient: In vertical it's the index, in horizontal it's the value. + evalValue = categoryAxisIsX ? dataIndexValue : dataValue; + } else { + // Y-axis gradient: In vertical it's the value, in horizontal it's the index. + evalValue = categoryAxisIsX ? dataValue : dataIndexValue; + } + const evaluatedColor = evaluateGradientAtValue( stops, - dataValue, + evalValue, gradientScale as ChartScaleFunction, ); if (evaluatedColor) { @@ -91,20 +104,33 @@ const BeaconWithData = memo<{ } return series?.color ?? 'var(--color-fgPrimary)'; - }, [gradient, dataX, dataY, series?.color, series?.yAxisId, getXScale, getYScale]); + }, [ + gradient, + dataIndexValue, + dataValue, + series?.color, + series?.xAxisId, + series?.yAxisId, + getXScale, + getYScale, + layout, + ]); - if (dataY === undefined) return null; + if (dataValue === undefined) return null; + const categoryAxisIsX = layout !== 'horizontal'; return ( { const ScrubberBeaconRefs = useRefMap(); const { scrubberPosition } = useScrubberContext(); - const { getXScale, getXAxis, dataLength, series } = useCartesianChartContext(); + const { layout, getXScale, getYScale, getXAxis, getYAxis, dataLength, series, animate } = + useCartesianChartContext(); // Expose imperative handle with pulse method useImperativeHandle(ref, () => ({ @@ -182,24 +215,29 @@ export const ScrubberBeaconGroup = memo( return series?.filter((s) => seriesIds.includes(s.id)) ?? []; }, [series, seriesIds]); - const { dataX, dataIndex } = useMemo(() => { - const xScale = getXScale(); - const xAxis = getXAxis(); - if (!xScale) return { dataX: undefined, dataIndex: undefined }; + const { dataIndexValue, dataIndex } = useMemo(() => { + const categoryAxisIsX = layout !== 'horizontal'; + const indexScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction; + const indexAxis = categoryAxisIsX ? getXAxis() : getYAxis(); + if (!indexScale) return { dataIndexValue: undefined, dataIndex: undefined }; const dataIndex = scrubberPosition ?? Math.max(0, dataLength - 1); - // Convert index to actual x value if axis has data - let dataX: number; - if (xAxis?.data && Array.isArray(xAxis.data) && xAxis.data[dataIndex] !== undefined) { - const dataValue = xAxis.data[dataIndex]; - dataX = typeof dataValue === 'string' ? dataIndex : dataValue; + // Convert index to actual data value if axis has data + let dataIndexValue: number; + if ( + indexAxis?.data && + Array.isArray(indexAxis.data) && + indexAxis.data[dataIndex] !== undefined + ) { + const val = indexAxis.data[dataIndex]; + dataIndexValue = typeof val === 'string' ? dataIndex : val; } else { - dataX = dataIndex; + dataIndexValue = dataIndex; } - return { dataX, dataIndex }; - }, [getXScale, getXAxis, scrubberPosition, dataLength]); + return { dataIndexValue, dataIndex }; + }, [getXScale, getYScale, getXAxis, getYAxis, scrubberPosition, dataLength, layout]); const isIdle = scrubberPosition === undefined; @@ -214,19 +252,21 @@ export const ScrubberBeaconGroup = memo( [ScrubberBeaconRefs], ); - if (dataX === undefined || dataIndex === undefined) return null; + if (dataIndexValue === undefined || dataIndex === undefined) return null; return filteredSeries.map((s) => ( void; BeaconLabelComponent: ScrubberBeaconLabelComponent; labelHorizontalOffset: number; labelFont?: ChartTextProps['font']; + updateTransition: Transition | null; + className?: string; + style?: CSSProperties; }>( ({ index, @@ -38,6 +53,9 @@ const PositionedLabel = memo<{ BeaconLabelComponent, labelHorizontalOffset, labelFont, + updateTransition, + className, + style, }) => { const pos = positions[index]; @@ -53,6 +71,7 @@ const PositionedLabel = memo<{ return ( onDimensionsChange(seriesId, d)} seriesId={seriesId} + style={style} + transition={updateTransition ?? instantTransition} x={x} y={y} /> @@ -86,6 +107,12 @@ export type ScrubberBeaconLabelGroupBaseProps = SharedProps & { * Font style for the beacon labels. */ labelFont?: ChartTextProps['font']; + /** + * Preferred side for labels. + * @note labels will switch to the opposite side if there's not enough space on the preferred side. + * @default 'right' + */ + labelPreferredSide?: ScrubberLabelPosition; }; export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & { @@ -94,6 +121,18 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & * @default DefaultScrubberBeaconLabel */ BeaconLabelComponent?: ScrubberBeaconLabelComponent; + /** + * Transition configuration for beacon label animations. + */ + transitions?: ScrubberBeaconProps['transitions']; + /** + * Custom class name for each beacon label. + */ + className?: string; + /** + * Custom inline styles for each beacon label. + */ + style?: CSSProperties; }; export const ScrubberBeaconLabelGroup = memo( @@ -102,12 +141,35 @@ export const ScrubberBeaconLabelGroup = memo( labelMinGap = 4, labelHorizontalOffset = 16, labelFont, + labelPreferredSide = 'right', BeaconLabelComponent = DefaultScrubberBeaconLabel, + transitions, + className, + style, }) => { - const { getSeries, getSeriesData, getXScale, getYScale, getXAxis, drawingArea, dataLength } = - useCartesianChartContext(); + const { + getSeries, + getSeriesData, + getXScale, + getYScale, + getXAxis, + drawingArea, + dataLength, + animate, + } = useCartesianChartContext(); const { scrubberPosition } = useScrubberContext(); + const isIdle = scrubberPosition === undefined; + + const prevIsIdle = usePreviousValue(isIdle); + const isIdleTransition = prevIsIdle !== undefined && isIdle !== prevIsIdle; + + const updateTransition = useMemo(() => { + if (isIdleTransition) return instantTransition; + if (!isIdle) return instantTransition; + return getTransition(transitions?.update, animate, defaultTransition); + }, [transitions?.update, isIdle, animate, isIdleTransition]); + const [labelDimensions, setLabelDimensions] = useState>({}); const handleDimensionsChange = useCallback((seriesId: string, dimensions: LabelDimensions) => { @@ -238,13 +300,19 @@ export const ScrubberBeaconLabelGroup = memo( }, [seriesInfo, dataIndex, dataX, xScale, labelDimensions, drawingArea, labelMinGap]); const currentPosition = useMemo(() => { - if (!xScale || dataX === undefined) return 'right'; + if (!xScale || dataX === undefined) return labelPreferredSide; const pixelX = getPointOnScale(dataX, xScale); const maxWidth = Math.max(...Object.values(labelDimensions).map((dim) => dim.width)); - return getLabelPosition(pixelX, maxWidth, drawingArea, labelHorizontalOffset); - }, [dataX, xScale, labelDimensions, drawingArea, labelHorizontalOffset]); + return getLabelPosition( + pixelX, + maxWidth, + drawingArea, + labelHorizontalOffset, + labelPreferredSide, + ); + }, [dataX, xScale, labelDimensions, drawingArea, labelHorizontalOffset, labelPreferredSide]); return seriesInfo.map((info, index) => { const labelInfo = labels.find((label) => label.seriesId === info.seriesId); @@ -253,6 +321,7 @@ export const ScrubberBeaconLabelGroup = memo( ( position={currentPosition} positions={allLabelPositions} seriesId={info.seriesId} + style={style} + updateTransition={updateTransition} /> ); }); diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx index cf72454622..e47a3881bc 100644 --- a/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx +++ b/packages/web-visualization/src/chart/scrubber/ScrubberProvider.tsx @@ -1,7 +1,12 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useCartesianChartContext } from '../ChartProvider'; -import { isCategoricalScale, ScrubberContext, type ScrubberContextValue } from '../utils'; +import { + type ChartScaleFunction, + isCategoricalScale, + ScrubberContext, + type ScrubberContextValue, +} from '../utils'; export type ScrubberProviderProps = Partial< Pick @@ -29,25 +34,26 @@ export const ScrubberProvider: React.FC = ({ throw new Error('ScrubberProvider must be used within a ChartContext'); } - const { getXScale, getXAxis, series } = chartContext; + const { layout, getXScale, getYScale, getXAxis, getYAxis, series } = chartContext; const [scrubberPosition, setScrubberPosition] = useState(undefined); - const getDataIndexFromX = useCallback( - (mouseX: number): number => { - const xScale = getXScale(); - const xAxis = getXAxis(); + const getDataIndexFromPosition = useCallback( + (mousePosition: number): number => { + const categoryAxisIsX = layout !== 'horizontal'; + const categoryScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction; + const categoryAxis = categoryAxisIsX ? getXAxis() : getYAxis(); - if (!xScale || !xAxis) return 0; + if (!categoryScale || !categoryAxis) return 0; - if (isCategoricalScale(xScale)) { - const categories = xScale.domain?.() ?? xAxis.data ?? []; - const bandwidth = xScale.bandwidth?.() ?? 0; + if (isCategoricalScale(categoryScale)) { + const categories = categoryScale.domain?.() ?? categoryAxis.data ?? []; + const bandwidth = categoryScale.bandwidth?.() ?? 0; let closestIndex = 0; let closestDistance = Infinity; for (let i = 0; i < categories.length; i++) { - const xPos = xScale(i); - if (xPos !== undefined) { - const distance = Math.abs(mouseX - (xPos + bandwidth / 2)); + const pos = categoryScale(i); + if (pos !== undefined) { + const distance = Math.abs(mousePosition - (pos + bandwidth / 2)); if (distance < closestDistance) { closestDistance = distance; closestIndex = i; @@ -57,7 +63,7 @@ export const ScrubberProvider: React.FC = ({ return closestIndex; } else { // For numeric scales with axis data, find the nearest data point - const axisData = xAxis.data; + const axisData = categoryAxis.data; if (axisData && Array.isArray(axisData) && typeof axisData[0] === 'number') { // We have numeric axis data - find the closest data point const numericData = axisData as number[]; @@ -65,10 +71,10 @@ export const ScrubberProvider: React.FC = ({ let closestDistance = Infinity; for (let i = 0; i < numericData.length; i++) { - const xValue = numericData[i]; - const xPos = xScale(xValue); - if (xPos !== undefined) { - const distance = Math.abs(mouseX - xPos); + const dataValue = numericData[i]; + const pos = categoryScale(dataValue); + if (pos !== undefined) { + const distance = Math.abs(mousePosition - pos); if (distance < closestDistance) { closestDistance = distance; closestIndex = i; @@ -77,37 +83,44 @@ export const ScrubberProvider: React.FC = ({ } return closestIndex; } else { - const xValue = xScale.invert(mouseX); - const dataIndex = Math.round(xValue); - const domain = xAxis.domain; - return Math.max(domain.min ?? 0, Math.min(dataIndex, domain.max ?? 0)); + const dataValue = (categoryScale as any).invert(mousePosition); + const dataIndexVal = Math.round(dataValue); + const domain = categoryAxis.domain; + return Math.max(domain.min ?? 0, Math.min(dataIndexVal, domain.max ?? 0)); } } }, - [getXScale, getXAxis], + [layout, getXScale, getYScale, getXAxis, getYAxis], ); const handlePointerMove = useCallback( - (clientX: number, target: SVGSVGElement) => { + (clientX: number, clientY: number, target: SVGSVGElement) => { if (!enableScrubbing || !series || series.length === 0) return; const rect = target.getBoundingClientRect(); - const x = clientX - rect.left; + const position = layout === 'horizontal' ? clientY - rect.top : clientX - rect.left; - const dataIndex = getDataIndexFromX(x); + const dataIndex = getDataIndexFromPosition(position); if (dataIndex !== scrubberPosition) { setScrubberPosition(dataIndex); onScrubberPositionChange?.(dataIndex); } }, - [enableScrubbing, series, getDataIndexFromX, scrubberPosition, onScrubberPositionChange], + [ + enableScrubbing, + series, + layout, + getDataIndexFromPosition, + scrubberPosition, + onScrubberPositionChange, + ], ); const handleMouseMove = useCallback( (event: MouseEvent) => { const target = event.currentTarget as SVGSVGElement; - handlePointerMove(event.clientX, target); + handlePointerMove(event.clientX, event.clientY, target); }, [handlePointerMove], ); @@ -119,7 +132,7 @@ export const ScrubberProvider: React.FC = ({ event.preventDefault(); const touch = event.touches[0]; const target = event.currentTarget as SVGSVGElement; - handlePointerMove(touch.clientX, target); + handlePointerMove(touch.clientX, touch.clientY, target); }, [handlePointerMove], ); @@ -130,7 +143,7 @@ export const ScrubberProvider: React.FC = ({ // Handle initial touch const touch = event.touches[0]; const target = event.currentTarget as SVGSVGElement; - handlePointerMove(touch.clientX, target); + handlePointerMove(touch.clientX, touch.clientY, target); }, [enableScrubbing, handlePointerMove], ); @@ -148,12 +161,13 @@ export const ScrubberProvider: React.FC = ({ (event: KeyboardEvent) => { if (!enableScrubbing) return; - const xScale = getXScale(); - const xAxis = getXAxis(); + const categoryAxisIsX = layout !== 'horizontal'; + const categoryScale = (categoryAxisIsX ? getXScale() : getYScale()) as ChartScaleFunction; + const categoryAxis = categoryAxisIsX ? getXAxis() : getYAxis(); - if (!xScale || !xAxis) return; + if (!categoryScale || !categoryAxis) return; - const isBand = isCategoricalScale(xScale); + const isBand = isCategoricalScale(categoryScale); // Determine the actual data indices we can navigate to let minIndex: number; @@ -162,13 +176,13 @@ export const ScrubberProvider: React.FC = ({ if (isBand) { // For categorical scales, use the categories - const categories = xScale.domain?.() ?? xAxis.data ?? []; + const categories = categoryScale.domain?.() ?? categoryAxis.data ?? []; minIndex = 0; maxIndex = Math.max(0, categories.length - 1); dataPoints = categories.length; } else { // For numeric scales, check if we have specific data points - const axisData = xAxis.data; + const axisData = categoryAxis.data; if (axisData && Array.isArray(axisData)) { // We have specific data points - use their indices minIndex = 0; @@ -176,7 +190,7 @@ export const ScrubberProvider: React.FC = ({ dataPoints = axisData.length; } else { // Fall back to domain-based navigation for continuous scales without specific data - const domain = xAxis.domain; + const domain = categoryAxis.domain; minIndex = domain.min ?? 0; maxIndex = domain.max ?? 0; dataPoints = maxIndex - minIndex + 1; @@ -193,11 +207,11 @@ export const ScrubberProvider: React.FC = ({ let newIndex: number | undefined; switch (event.key) { - case 'ArrowLeft': + case categoryAxisIsX ? 'ArrowLeft' : 'ArrowUp': event.preventDefault(); newIndex = Math.max(minIndex, currentIndex - stepSize); break; - case 'ArrowRight': + case categoryAxisIsX ? 'ArrowRight' : 'ArrowDown': event.preventDefault(); newIndex = Math.min(maxIndex, currentIndex + stepSize); break; @@ -222,7 +236,16 @@ export const ScrubberProvider: React.FC = ({ onScrubberPositionChange?.(newIndex); } }, - [enableScrubbing, getXScale, getXAxis, scrubberPosition, onScrubberPositionChange], + [ + enableScrubbing, + layout, + getXScale, + getYScale, + getXAxis, + getYAxis, + scrubberPosition, + onScrubberPositionChange, + ], ); const handleBlur = useCallback(() => { diff --git a/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx b/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx new file mode 100644 index 0000000000..c001e402de --- /dev/null +++ b/packages/web-visualization/src/chart/scrubber/__stories__/Scrubber.stories.tsx @@ -0,0 +1,879 @@ +import { memo, StrictMode, useCallback, useMemo, useRef } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import type { Rect } from '@coinbase/cds-common/types'; +import { Button } from '@coinbase/cds-web/buttons'; +import { useTheme } from '@coinbase/cds-web/hooks/useTheme'; +import { Box, VStack } from '@coinbase/cds-web/layout'; +import { Text } from '@coinbase/cds-web/typography'; +import { m as motion } from 'framer-motion'; + +import { + ChartText, + type ChartTextChildren, + DefaultScrubberBeacon, + DefaultScrubberBeaconLabel, + type DefaultScrubberBeaconLabelProps, + DefaultScrubberLabel, + getLineData, + type ScrubberBeaconLabelProps, + type ScrubberBeaconProps, + type ScrubberLabelPosition, + type ScrubberLabelProps, + type ScrubberRef, + useCartesianChartContext, + useScrubberContext, +} from '../..'; +import { LineChart, SolidLine } from '../../line'; +import { Scrubber } from '../Scrubber'; + +const sampleData = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; + +export default { + component: Scrubber, + title: 'Components/Chart/Scrubber', +}; + +const Example: React.FC< + React.PropsWithChildren<{ title: string; description?: string | React.ReactNode }> +> = ({ children, title, description }) => { + return ( + + + {title} + + {description} + {children} + + ); +}; + +const BasicScrubber = () => { + return ( + ({ min, max: max - 8 }), + }} + yAxis={{ + showGrid: true, + }} + > + + + ); +}; + +const SeriesFilter = () => { + return ( + + + + ); +}; + +const WithLabels = () => { + return ( + + `Day ${dataIndex + 1}`} /> + + ); +}; + +const IdlePulse = () => { + return ( + + + + ); +}; + +const ImperativePulse = () => { + const scrubberRef = useRef(null); + + return ( + + + + + + + ); +}; + +const BeaconStroke = () => { + return ( + + + + + + ); +}; + +const CustomBeacon = () => { + const InvertedBeacon = memo((props: ScrubberBeaconProps) => ( + + )); + + return ( + ({ min, max: max - 16 }), + }} + yAxis={{ + showGrid: true, + domain: { min: 0, max: 100 }, + }} + > + + + ); +}; + +const CustomBeaconLabel = () => { + const MyScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel = useMemo(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex]; + return `${label} · ${dataAtPosition}%`; + } + return label; + }, [label, seriesData, dataIndex]); + + return ( + + ); + }, + ); + + return ( + + + + ); +}; + +const PercentageBeaconLabels = ({ preferredSide }: { preferredSide?: ScrubberLabelPosition }) => { + const PercentageScrubberBeaconLabel = memo( + ({ seriesId, color, label, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const percentageLabel: ChartTextChildren = useMemo(() => { + if (seriesData !== undefined) { + const dataAtPosition = seriesData[dataIndex]; + return ( + <> + {dataAtPosition}% {label} + + ); + } + return label; + }, [label, seriesData, dataIndex]); + + return ( + + ); + }, + ); + + const theme = useTheme(); + + const isLightTheme = theme.activeColorScheme === 'light'; + const background = isLightTheme ? 'rgb(var(--gray90))' : 'rgb(var(--gray0))'; + const scrubberLineStroke = isLightTheme ? 'rgb(var(--gray0))' : 'rgb(var(--gray90))'; + + return ( + + ({ min, max: max - 92 }), + }} + > + + + + ); +}; + +const HideBeaconLabels = () => { + return ( + + `Day ${dataIndex + 1}`} + /> + + ); +}; + +const LabelElevated = () => { + return ( + + `Day ${dataIndex + 1}`} /> + + ); +}; + +const CustomLabelComponent = () => { + const MyLabelComponent = memo((props: ScrubberLabelProps) => { + const { drawingArea } = useCartesianChartContext(); + + if (!drawingArea) return null; + + return ( + + ); + }); + + return ( + + `Day ${dataIndex + 1}`} + /> + + ); +}; + +const LabelFonts = () => { + return ( + + `Day ${dataIndex + 1}`} + labelFont="legal" + /> + + ); +}; + +const LabelBoundsInset = () => { + return ( + + + + + + + + + + + + + ); +}; + +const CustomLine = () => { + return ( + + + + ); +}; + +const HiddenScrubberWhenIdle = () => { + const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => { + const { scrubberPosition } = useScrubberContext(); + const isScrubbing = scrubberPosition !== undefined; + + return ; + }); + + const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => { + const { scrubberPosition } = useScrubberContext(); + const isScrubbing = scrubberPosition !== undefined; + + return ; + }); + + return ( + + + + ); +}; + +const HideOverlay = () => { + return ( + + + + ); +}; + +const matchupBlueData = [ + 47, 50, 51, 52, 53, 53, 53, 53, 52, 51, 51, 52, 53, 55, 57, 58, 59, 61, 63, 65, 64, 64, 64, 64, + 64, 63, 63, 63, 64, 66, 68, 70, 71, 72, 74, 76, 76, 75, 74, 73, 74, 75, 75, 78, +]; +const matchupRedData = matchupBlueData.map((value) => 100 - value); +const matchupTeamLabels: Record = { + blue: 'BLUE', + red: 'RED', +}; + +type TeamBeaconLabelProps = Omit< + DefaultScrubberBeaconLabelProps, + 'label' | 'verticalAlignment' | 'font' | 'inset' | 'elevated' | 'borderRadius' | 'background' +> & { + teamLabel: string; + percentageLabel: string; +}; + +const TeamBeaconLabel = memo( + ({ + color = 'var(--color-fgPrimary)', + teamLabel, + percentageLabel, + transition, + x, + y, + dx, + horizontalAlignment, + onDimensionsChange, + ...chartTextProps + }) => { + const teamLabelDimensionsRef = useRef(null); + const percentageLabelDimensionsRef = useRef(null); + + const emitCombinedDimensions = useCallback(() => { + if (!onDimensionsChange) { + return; + } + + const teamRect = teamLabelDimensionsRef.current; + const percentageRect = percentageLabelDimensionsRef.current; + + if (!teamRect || !percentageRect) { + return; + } + + const minX = Math.min(teamRect.x, percentageRect.x); + const minY = Math.min(teamRect.y, percentageRect.y); + const maxX = Math.max(teamRect.x + teamRect.width, percentageRect.x + percentageRect.width); + const maxY = Math.max(teamRect.y + teamRect.height, percentageRect.y + percentageRect.height); + + onDimensionsChange({ + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }); + }, [onDimensionsChange]); + + const handleTeamLabelDimensionsChange = useCallback( + (rect: Rect) => { + teamLabelDimensionsRef.current = rect; + emitCombinedDimensions(); + }, + [emitCombinedDimensions], + ); + + const handlePercentageLabelDimensionsChange = useCallback( + (rect: Rect) => { + percentageLabelDimensionsRef.current = rect; + emitCombinedDimensions(); + }, + [emitCombinedDimensions], + ); + + return ( + + + {teamLabel} + + + {percentageLabel} + + + ); + }, +); + +const MatchupBeaconLabels = () => { + const MatchupScrubberBeaconLabel = memo( + ({ seriesId, color, ...props }: ScrubberBeaconLabelProps) => { + const { getSeriesData, dataLength } = useCartesianChartContext(); + const { scrubberPosition } = useScrubberContext(); + + const seriesData = useMemo( + () => getLineData(getSeriesData(seriesId)), + [getSeriesData, seriesId], + ); + + const dataIndex = useMemo(() => { + return scrubberPosition ?? Math.max(0, dataLength - 1); + }, [scrubberPosition, dataLength]); + + const teamLabel = matchupTeamLabels[seriesId] ?? String(seriesId).toUpperCase(); + + const value: number | null = useMemo(() => { + if (seriesData === undefined) { + return null; + } + + return seriesData[dataIndex]; + }, [dataIndex, seriesData]); + + return ( + + ); + }, + ); + + return ( + ({ min, max: max - 64 }), + }} + yAxis={{ + domain: { min: 0, max: 100 }, + }} + > + + + ); +}; + +export const All = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/web-visualization/src/chart/scrubber/__tests__/Scrubber.test.tsx b/packages/web-visualization/src/chart/scrubber/__tests__/Scrubber.test.tsx new file mode 100644 index 0000000000..df4ece3dd2 --- /dev/null +++ b/packages/web-visualization/src/chart/scrubber/__tests__/Scrubber.test.tsx @@ -0,0 +1,344 @@ +import { DefaultThemeProvider } from '@coinbase/cds-web/utils/test'; +import { render, screen } from '@testing-library/react'; + +import { CartesianChart } from '../../CartesianChart'; +import { Line } from '../../line/Line'; +import { ReferenceLine } from '../../line/ReferenceLine'; +import { DefaultScrubberBeacon } from '../DefaultScrubberBeacon'; +import { DefaultScrubberLabel } from '../DefaultScrubberLabel'; +import { Scrubber } from '../Scrubber'; + +jest.mock('@coinbase/cds-web/hooks/useDimensions', () => ({ + useDimensions: jest.fn(() => ({ + observe: jest.fn(), + width: 600, + height: 400, + })), +})); + +// Mock ResizeObserver +const mockResizeObserver = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), +})); +const mockResizeObserverEntry = jest.fn(); + +beforeAll(() => { + global.ResizeObserver = mockResizeObserver as unknown as typeof ResizeObserver; + global.ResizeObserverEntry = mockResizeObserverEntry as unknown as typeof ResizeObserverEntry; + + // Mock getBBox for SVG elements (JSDOM doesn't support it) + // @ts-expect-error - SVGElement prototype modification for testing + window.SVGElement.prototype.getBBox = jest.fn(() => ({ + x: 0, + y: 0, + width: 50, + height: 20, + })); +}); + +const renderChartWithScrubber = (scrubberProps?: React.ComponentProps) => { + return render( + + + + + + , + ); +}; + +const renderMultiSeriesChartWithScrubber = ( + scrubberProps?: React.ComponentProps, +) => { + return render( + + + + + + + , + ); +}; + +const renderHorizontalReferenceLineWithDefaultScrubberLabel = () => { + return render( + + + + + , + ); +}; + +const renderHorizontalMultiSeriesChartWithScrubber = ( + scrubberProps?: React.ComponentProps, +) => { + return render( + + + + + + + , + ); +}; + +describe('Scrubber', () => { + describe('basic rendering', () => { + it('renders scrubber within chart', () => { + renderChartWithScrubber(); + + const svg = screen.getByTestId('test-chart'); + expect(svg).toBeInTheDocument(); + }); + + it('renders with custom testID', () => { + renderChartWithScrubber({ testID: 'custom-scrubber' }); + + const svg = screen.getByTestId('test-chart'); + const scrubberGroup = svg.querySelector('[data-testid="custom-scrubber"]'); + expect(scrubberGroup).toBeInTheDocument(); + }); + + it('renders beacon for series', () => { + renderChartWithScrubber({ testID: 'scrubber' }); + + const svg = screen.getByTestId('test-chart'); + const scrubberGroup = svg.querySelector('[data-testid="scrubber"]'); + expect(scrubberGroup).toBeInTheDocument(); + }); + }); + + describe('hideOverlay', () => { + it('does not render overlay when hideOverlay is true', () => { + renderChartWithScrubber({ hideOverlay: true, testID: 'scrubber' }); + + const svg = screen.getByTestId('test-chart'); + const overlay = svg.querySelector('[data-testid="scrubber-overlay"]'); + expect(overlay).not.toBeInTheDocument(); + }); + }); + + describe('series filtering', () => { + it('renders beacons only for specified seriesIds', () => { + renderMultiSeriesChartWithScrubber({ seriesIds: ['alpha'], testID: 'scrubber' }); + + const svg = screen.getByTestId('multi-series-chart'); + expect(svg.querySelector('[data-testid="scrubber-alpha"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="scrubber-beta"]')).not.toBeInTheDocument(); + }); + + it('renders beacons for all series when seriesIds is not provided', () => { + renderMultiSeriesChartWithScrubber({ testID: 'scrubber' }); + + const svg = screen.getByTestId('multi-series-chart'); + expect(svg.querySelector('[data-testid="scrubber-alpha"]')).toBeInTheDocument(); + expect(svg.querySelector('[data-testid="scrubber-beta"]')).toBeInTheDocument(); + }); + }); + + describe('horizontal layout labels', () => { + it('positions default scrubber line label in the right inset', () => { + renderHorizontalReferenceLineWithDefaultScrubberLabel(); + + const textNode = screen.getByText('Price').closest('text'); + expect(textNode).toBeInTheDocument(); + expect(textNode).toHaveAttribute('text-anchor', 'middle'); + expect(textNode).toHaveAttribute('dx', '24'); + expect(textNode).toHaveAttribute('dy', '0'); + + const x = Number(textNode?.getAttribute('x')); + const dx = Number(textNode?.getAttribute('dx')); + expect(x + dx).toBe(576); + expect(x).toBeGreaterThan(540); + }); + + it('always hides beacon labels in horizontal layout', () => { + renderHorizontalMultiSeriesChartWithScrubber({ hideBeaconLabels: false }); + + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + expect(screen.queryByText('Beta')).not.toBeInTheDocument(); + }); + }); +}); + +describe('DefaultScrubberBeacon', () => { + const renderBeacon = (props?: Partial>) => { + return render( + + + + + + , + ); + }; + + describe('basic rendering', () => { + it('renders beacon with default testID based on seriesId', () => { + renderBeacon(); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + expect(beacon).toBeInTheDocument(); + }); + + it('renders pulse circle with testID', () => { + renderBeacon(); + + const svg = screen.getByTestId('test-chart'); + const pulse = svg.querySelector('[data-testid="test-beacon-pulse"]'); + expect(pulse).toBeInTheDocument(); + }); + + it('allows custom testID override', () => { + renderBeacon({ testID: 'custom-beacon' }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="custom-beacon"]'); + expect(beacon).toBeInTheDocument(); + + const pulse = svg.querySelector('[data-testid="custom-beacon-pulse"]'); + expect(pulse).toBeInTheDocument(); + }); + + it('renders beacon circle', () => { + renderBeacon(); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circle = beacon?.querySelector('circle'); + expect(circle).toBeInTheDocument(); + }); + }); + + describe('custom props', () => { + const getMainBeaconCircle = (beacon: Element | null) => { + const circles = beacon?.querySelectorAll('circle'); + return Array.from(circles ?? []).find((c) => c.hasAttribute('stroke')); + }; + + it('applies custom radius to beacon circle', () => { + renderBeacon({ radius: 10 }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circle = getMainBeaconCircle(beacon); + expect(circle?.getAttribute('r')).toBe('10'); + }); + + it('applies custom stroke to beacon circle', () => { + renderBeacon({ stroke: '#00ff00' }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circle = getMainBeaconCircle(beacon); + expect(circle?.getAttribute('stroke')).toBe('#00ff00'); + }); + + it('applies custom strokeWidth to beacon circle', () => { + renderBeacon({ strokeWidth: 4 }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circle = getMainBeaconCircle(beacon); + expect(circle?.getAttribute('stroke-width')).toBe('4'); + }); + + it('uses default values when props not provided', () => { + renderBeacon(); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circle = getMainBeaconCircle(beacon); + + expect(circle?.getAttribute('r')).toBe('5'); + expect(circle?.getAttribute('stroke-width')).toBe('2'); + expect(circle?.getAttribute('stroke')).toBe('var(--color-bg)'); + }); + }); + + describe('animate prop', () => { + it('renders static circle when animate is false', () => { + renderBeacon({ animate: false }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + const circles = beacon?.querySelectorAll('circle'); + expect(circles?.length).toBeGreaterThan(0); + }); + }); + + describe('isIdle state', () => { + it('renders beacon when isIdle is true', () => { + renderBeacon({ isIdle: true }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + expect(beacon).toBeInTheDocument(); + }); + + it('renders beacon when isIdle is false (scrubbing)', () => { + renderBeacon({ isIdle: false }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + expect(beacon).toBeInTheDocument(); + }); + }); + + describe('opacity', () => { + it('applies custom opacity', () => { + renderBeacon({ opacity: 0.5 }); + + const svg = screen.getByTestId('test-chart'); + const beacon = svg.querySelector('[data-testid="test-beacon"]'); + expect(beacon?.getAttribute('opacity')).toBe('0.5'); + }); + }); +}); diff --git a/packages/web-visualization/src/chart/text/ChartText.tsx b/packages/web-visualization/src/chart/text/ChartText.tsx index e7fbcb0923..ddc125f855 100644 --- a/packages/web-visualization/src/chart/text/ChartText.tsx +++ b/packages/web-visualization/src/chart/text/ChartText.tsx @@ -176,7 +176,7 @@ export const ChartText = memo( fontWeight, elevated, color = 'var(--color-fgMuted)', - background = elevated ? 'var(--color-bg)' : 'transparent', + background = elevated ? 'var(--color-bgElevation1)' : 'transparent', borderRadius, inset: insetInput, onDimensionsChange, diff --git a/packages/web-visualization/src/chart/utils/__tests__/axis.test.ts b/packages/web-visualization/src/chart/utils/__tests__/axis.test.ts index f2e612a4d6..f859cba4e4 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/axis.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/axis.test.ts @@ -1,4 +1,10 @@ -import { formatAxisTick, getAxisTicksData } from '../axis'; +import { + formatAxisTick, + getAxisTicksData, + getCartesianAxisDomain, + getCartesianAxisScale, + withBaselineDomain, +} from '../axis'; import { type CategoricalScale, getCategoricalScale, @@ -489,6 +495,161 @@ describe('getAxisTicksData', () => { }); }); +describe('getCartesianAxisDomain', () => { + const series = [ + { id: 's1', data: [10, 20, 30] }, + { id: 's2', data: [5, 15, 25] }, + ]; + + // New layout semantics: + // - 'vertical': Bars grow vertically (up/down). X is category axis, Y is value axis. + // - 'horizontal': Bars grow horizontally (left/right). Y is category axis, X is value axis. + + it('should return correct domain for x-axis in vertical layout (category axis)', () => { + const domain = getCartesianAxisDomain( + { id: 'x', scaleType: 'band', domainLimit: 'strict' }, + series, + 'x', + 'vertical', + ); + // For x in vertical, it's the index domain: 0 to dataLength - 1 + expect(domain).toEqual({ min: 0, max: 2 }); + }); + + it('should return correct domain for y-axis in vertical layout (value axis)', () => { + const domain = getCartesianAxisDomain( + { id: 'y', scaleType: 'linear', domainLimit: 'nice' }, + series, + 'y', + 'vertical', + ); + // For y in vertical, it's the value domain: min/max of all data + expect(domain).toEqual({ min: 5, max: 30 }); + }); + + it('should return correct domain for x-axis in horizontal layout (value axis)', () => { + const domain = getCartesianAxisDomain( + { id: 'x', scaleType: 'linear', domainLimit: 'nice' }, + series, + 'x', + 'horizontal', + ); + // For x in horizontal, it's the value domain: min/max of all data + expect(domain).toEqual({ min: 5, max: 30 }); + }); + + it('should return correct domain for y-axis in horizontal layout (category axis)', () => { + const domain = getCartesianAxisDomain( + { id: 'y', scaleType: 'band', domainLimit: 'strict' }, + series, + 'y', + 'horizontal', + ); + // For y in horizontal, it's the index domain: 0 to dataLength - 1 + expect(domain).toEqual({ min: 0, max: 2 }); + }); + + it('does not apply baseline adjustments by default', () => { + const domain = getCartesianAxisDomain( + { id: 'y', scaleType: 'linear', domainLimit: 'strict', baseline: 30 }, + [{ id: 's1', data: [-100, -50] }], + 'y', + 'vertical', + ); + expect(domain).toEqual({ min: -100, max: -50 }); + }); +}); + +describe('withBaselineDomain', () => { + it('extends max when baseline is above computed bounds', () => { + const domain = withBaselineDomain(undefined, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: -100, max: -50 })).toEqual({ min: -100, max: 30 }); + }); + + it('extends min when baseline is below computed bounds', () => { + const domain = withBaselineDomain(undefined, 0); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: 25, max: 80 })).toEqual({ min: 0, max: 80 }); + }); + + it('does not change bounds when baseline is already in range', () => { + const domain = withBaselineDomain(undefined, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: 20, max: 55 })).toEqual({ min: 20, max: 55 }); + }); + + it('preserves explicit max while extending only implicit side', () => { + const domain = withBaselineDomain({ max: -50 }, 30); + expect(typeof domain).toBe('function'); + if (typeof domain !== 'function') throw new Error('Expected function domain'); + + expect(domain({ min: -100, max: -80 })).toEqual({ min: -100, max: -50 }); + }); + + it('preserves fully explicit bounds', () => { + expect(withBaselineDomain({ min: -100, max: -50 }, 30)).toEqual({ + min: -100, + max: -50, + }); + }); + + it('preserves function domain identity', () => { + const domainFn = (bounds: { min: number; max: number }) => bounds; + expect(withBaselineDomain(domainFn, 30)).toBe(domainFn); + }); +}); + +describe('getCartesianAxisScale', () => { + const range = { min: 0, max: 400 }; + const dataDomain = { min: 0, max: 100 }; + + it('should NOT invert y-axis range in horizontal layout (y is category axis)', () => { + const scale = getCartesianAxisScale({ + type: 'y', + range, + dataDomain, + layout: 'horizontal', + }); + // Y axis is the category axis in horizontal layout - no inversion needed + // First category (index 0) at top (SVG y=0), last category at bottom (y=400) + expect(scale(0)).toBe(0); + expect(scale(100)).toBe(400); + }); + + it('should NOT invert x-axis range in horizontal layout (x is value axis)', () => { + const scale = getCartesianAxisScale({ + type: 'x', + range, + dataDomain, + layout: 'horizontal', + }); + // X axis is the value axis in horizontal layout - no inversion needed (left-to-right is natural) + expect(scale(0)).toBe(0); + expect(scale(100)).toBe(400); + }); + + it('should invert y-axis range in vertical layout (y is value axis)', () => { + const scale = getCartesianAxisScale({ + type: 'y', + range, + dataDomain, + layout: 'vertical', + }); + // Y axis is the value axis in vertical layout - inversion needed + // Higher values should appear at top (lower SVG y coordinate) + // scale(0) -> 400 (bottom), scale(100) -> 0 (top) + expect(scale(0)).toBe(400); + expect(scale(100)).toBe(0); + }); +}); + describe('formatAxisTick', () => { it('should use custom formatter when provided', () => { const formatter = (value: number) => `$${value}`; diff --git a/packages/web-visualization/src/chart/utils/__tests__/bar.test.ts b/packages/web-visualization/src/chart/utils/__tests__/bar.test.ts index a4952fb69b..ebd4e80065 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/bar.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/bar.test.ts @@ -1,54 +1,381 @@ -import { getBarSizeAdjustment } from '../bar'; +import { + getBars, + getBarSizeAdjustment, + getBaselinePx, + getNormalizedStagger, + getStackGroups, + getStackOrigin, +} from '../bar'; describe('getBarSizeAdjustment', () => { - it('should return 0 when barCount is 0', () => { - const result = getBarSizeAdjustment(0, 10); - expect(result).toBe(0); + it('returns 0 when barCount is 0', () => { + expect(getBarSizeAdjustment(0, 10)).toBe(0); }); - it('should return 0 when barCount is 1', () => { - const result = getBarSizeAdjustment(1, 10); - expect(result).toBe(0); + it('returns 0 when barCount is 1', () => { + expect(getBarSizeAdjustment(1, 10)).toBe(0); }); - it('should calculate correct adjustment for 2 bars', () => { - const result = getBarSizeAdjustment(2, 10); - // (10 * (2 - 1)) / 2 = 10 / 2 = 5 - expect(result).toBe(5); + it('calculates correct adjustment for 2 bars', () => { + // (10 * (2 - 1)) / 2 = 5 + expect(getBarSizeAdjustment(2, 10)).toBe(5); }); - it('should calculate correct adjustment for 3 bars', () => { - const result = getBarSizeAdjustment(3, 12); - // (12 * (3 - 1)) / 3 = 24 / 3 = 8 - expect(result).toBe(8); + it('calculates correct adjustment for 3 bars', () => { + // (12 * (3 - 1)) / 3 = 8 + expect(getBarSizeAdjustment(3, 12)).toBe(8); }); - it('should calculate correct adjustment for 4 bars', () => { - const result = getBarSizeAdjustment(4, 15); - // (15 * (4 - 1)) / 4 = 45 / 4 = 11.25 - expect(result).toBe(11.25); + it('calculates correct adjustment for 4 bars', () => { + // (15 * (4 - 1)) / 4 = 11.25 + expect(getBarSizeAdjustment(4, 15)).toBe(11.25); }); - it('should handle zero gap size', () => { - const result = getBarSizeAdjustment(3, 0); - expect(result).toBe(0); + it('handles zero gap size', () => { + expect(getBarSizeAdjustment(3, 0)).toBe(0); }); - it('should handle negative gap size', () => { - const result = getBarSizeAdjustment(3, -6); - // (-6 * (3 - 1)) / 3 = -12 / 3 = -4 - expect(result).toBe(-4); + it('handles negative gap size', () => { + expect(getBarSizeAdjustment(3, -6)).toBe(-4); }); - it('should handle fractional bar count', () => { - const result = getBarSizeAdjustment(2.5, 10); - // (10 * (2.5 - 1)) / 2.5 = 15 / 2.5 = 6 - expect(result).toBe(6); + it('handles fractional bar count', () => { + expect(getBarSizeAdjustment(2.5, 10)).toBe(6); }); - it('should handle large numbers', () => { - const result = getBarSizeAdjustment(100, 1000); - // (1000 * (100 - 1)) / 100 = 99000 / 100 = 990 - expect(result).toBe(990); + it('handles large numbers', () => { + expect(getBarSizeAdjustment(100, 1000)).toBe(990); + }); +}); + +describe('getStackGroups', () => { + it('groups series by stackId and axis IDs', () => { + const groups = getStackGroups([ + { id: 'a', stackId: 'price', xAxisId: 'x1', yAxisId: 'y1' }, + { id: 'b', stackId: 'price', xAxisId: 'x1', yAxisId: 'y1' }, + { id: 'c', stackId: 'price', xAxisId: 'x1', yAxisId: 'y2' }, + ]); + + expect(groups).toHaveLength(2); + expect(groups[0].stackId).toBe('price:x1:y1'); + expect(groups[0].series.map((s) => s.id)).toEqual(['a', 'b']); + expect(groups[1].stackId).toBe('price:x1:y2'); + expect(groups[1].series.map((s) => s.id)).toEqual(['c']); + }); + + it('falls back to individual stackId when missing', () => { + const groups = getStackGroups([{ id: 'a' }, { id: 'b' }]); + + expect(groups).toHaveLength(2); + expect(groups[0].stackId).toContain('individual-a'); + expect(groups[1].stackId).toContain('individual-b'); + }); + + it('uses provided default axis id for missing axis values', () => { + const groups = getStackGroups( + [ + { id: 'a', stackId: 's1' }, + { id: 'b', stackId: 's1' }, + ], + 'custom-default', + ); + + expect(groups).toHaveLength(1); + expect(groups[0].stackId).toBe('s1:custom-default:custom-default'); + }); +}); + +describe('getBaselinePx', () => { + const rect = { x: 10, y: 20, width: 100, height: 200 }; + + function createValueScale(domain: [number, number], map: (value: number) => number | undefined) { + return Object.assign((value: number) => map(value), { domain: () => domain }) as any; + } + + it('uses domain min for fully positive vertical domains', () => { + const valueScale = createValueScale([5, 15], (value) => 220 - value * 10); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(170); + }); + + it('uses domain max for fully negative horizontal domains', () => { + const valueScale = createValueScale([-20, -5], (value) => 60 + value); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(55); + }); + + it('uses zero for domains that cross zero', () => { + const valueScale = createValueScale([-10, 10], (value) => 120 + value * 5); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(110); + }); + + it('clamps vertical baseline to chart bounds when scale output is outside rect', () => { + const valueScale = createValueScale([-5, 5], () => -1000); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(rect.y); + }); + + it('uses orientation-aware fallback when scale returns undefined', () => { + const valueScale = createValueScale([-5, 5], () => undefined); + expect(getBaselinePx(valueScale, rect, 'vertical')).toBe(rect.y + rect.height); + expect(getBaselinePx(valueScale, rect, 'horizontal')).toBe(rect.x); + }); + + it('uses explicit baseline value when provided', () => { + const valueScale = createValueScale([-10, 50], (value) => 300 - value * 2); + expect(getBaselinePx(valueScale, rect, 'vertical', 30)).toBe(220); + }); +}); + +describe('getStackOrigin', () => { + it('returns undefined when barMinSize is 0', () => { + expect(getStackOrigin([0, 10], 0)).toBeUndefined(); + }); + + it('returns undefined when origins array is empty', () => { + expect(getStackOrigin([], 6)).toBeUndefined(); + }); + + describe('horizontal positive: buy+sell with minSize=6, gap=4', () => { + // buy origin=0, sell origin=10 → range=[0, 16] + it('rangeStart is min origin (0)', () => { + const [start] = getStackOrigin([0, 10], 6)!; + expect(start).toBe(0); + }); + + it('rangeEnd is max origin + minSize (16)', () => { + const [, end] = getStackOrigin([0, 10], 6)!; + expect(end).toBe(16); + }); + }); + + describe('single bar', () => { + it('single positive horizontal bar → [baseline, baseline + minSize]', () => { + const origins = [0]; + const range = getStackOrigin(origins, 6)!; + expect(range).toEqual([0, 6]); + }); + + it('single positive vertical bar → [baseline - minSize, baseline]', () => { + const baseline = 300; + const origins = [baseline - 6]; + const range = getStackOrigin(origins, 6)!; + expect(range).toEqual([baseline - 6, baseline]); + }); + }); + + describe('two positive horizontal bars (minSize=6, gap=4)', () => { + it('range covers [0, 16] — both initial bar positions', () => { + const origins = [0, 10]; + expect(getStackOrigin(origins, 6)).toEqual([0, 16]); + }); + }); + + describe('two positive vertical bars (minSize=6, gap=4)', () => { + // origins = [294, 284] → range = [284, 300] + it('range covers from furthest bar top to baseline', () => { + const baseline = 300; + const origins = [294, 284]; + expect(getStackOrigin(origins, 6)).toEqual([284, baseline]); + }); + }); + + describe('two negative horizontal bars (minSize=6, gap=4, baseline=150)', () => { + // near gets idx=0: origin = 150 - 1*6 - 0*4 = 144 + // far gets idx=1: origin = 150 - 2*6 - 1*4 = 134 + // range = [134, 144+6] = [134, 150] + it('range covers from furthest bar to baseline', () => { + const origins = [144, 134]; + expect(getStackOrigin(origins, 6)).toEqual([134, 150]); + }); + }); + + it('supports per-bar min sizes', () => { + expect(getStackOrigin([0, 10], [4, 8])).toEqual([0, 18]); + }); +}); + +describe('getBars horizontal barMinSize from baseline (regression)', () => { + /** + * Applying the vertical "above baseline" restack to horizontal stacks once shifted + * the whole stack left by ~its full width (e.g. x ≈ -1008 with a [0, 1008] value range). + */ + function linearValueScale(domain: [number, number], range: [number, number]) { + const [d0, d1] = domain; + const [r0, r1] = range; + return Object.assign((v: number) => r0 + ((v - d0) / (d1 - d0)) * (r1 - r0), { + domain: () => domain, + }) as any; + } + + const WIDE_CHART_WIDTH = 1008; + + it('anchors a buy/sell-style percentage stack at x=0 on a wide linear range (barMinSize + stackGap)', () => { + const valueScale = linearValueScale([0, 100], [0, WIDE_CHART_WIDTH]); + const bars = getBars({ + series: [ + { id: 'buy', data: [76], stackId: 'bs' }, + { id: 'sell', data: [24], stackId: 'bs' }, + ] as any, + seriesData: { + buy: [[0, 76]], + sell: [[76, 100]], + }, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 6, + valueScale, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 4, + barMinSize: 6, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + expect(bars).toHaveLength(2); + const buyBar = bars.find((b) => b.seriesId === 'buy')!; + const sellBar = bars.find((b) => b.seriesId === 'sell')!; + + expect(buyBar.x).toBeCloseTo(0, 4); + expect(buyBar.x).toBeGreaterThanOrEqual(-0.01); + expect(sellBar.x).toBeGreaterThan(buyBar.x); + + const minX = Math.min(...bars.map((b) => b.x)); + const maxX = Math.max(...bars.map((b) => b.x + b.width)); + expect(minX).toBeCloseTo(0, 4); + expect(maxX).toBeCloseTo(WIDE_CHART_WIDTH, 4); + }); + + it('does not push a horizontal stack to negative x when only the trailing segment needs barMinSize', () => { + const valueScale = linearValueScale([0, 100], [0, WIDE_CHART_WIDTH]); + const bars = getBars({ + series: [ + { id: 'big', data: [99.9], stackId: 's' }, + { id: 'tiny', data: [0.1], stackId: 's' }, + ] as any, + seriesData: { + big: [[0, 99.9]], + tiny: [[99.9, 100]], + }, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 6, + valueScale, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 2, + barMinSize: 24, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + expect(Math.min(...bars.map((b) => b.x))).toBeGreaterThanOrEqual(-0.01); + const bigBar = bars.find((b) => b.seriesId === 'big')!; + expect(bigBar.x).toBeCloseTo(0, 4); + }); +}); + +describe('getBars stackMinSize entrance behavior', () => { + const valueScale = Object.assign((value: number) => value, { + domain: () => [0, 10] as [number, number], + }); + + const series = [ + { id: 'buy', data: [2], stackId: 'orders' }, + { id: 'sell', data: [4], stackId: 'orders' }, + ]; + + const seriesData: Record = { + buy: [[0, 2]], + sell: [[2, 6]], + }; + + const getBarsResult = (barMinSize?: number, stackMinSize?: number) => + getBars({ + series: series as any, + seriesData, + categoryIndex: 0, + categoryValue: 0, + indexPos: 0, + thickness: 8, + valueScale: valueScale as any, + seriesGradients: [], + roundBaseline: false, + layout: 'horizontal', + baseline: 0, + baselinePx: 0, + stackGap: 0, + barMinSize, + stackMinSize, + defaultFill: '#000', + borderRadius: 0, + defaultFillOpacity: 1, + defaultStroke: undefined, + defaultStrokeWidth: undefined, + defaultBarComponent: undefined, + }); + + it('distributes stackMinSize proportionally to segment entrance min sizes', () => { + const bars = getBarsResult(undefined, 12); + expect(bars.map((bar) => bar.minSize)).toEqual([4, 8]); + }); + + it('uses max of barMinSize and stackMinSize-derived min size', () => { + const bars = getBarsResult(6, 12); + expect(bars.map((bar) => bar.minSize)).toEqual([6, 6]); + }); +}); + +describe('getNormalizedStagger', () => { + const drawingArea = { x: 10, y: 20, width: 200, height: 100 }; + + describe('vertical layout (stagger along x axis)', () => { + it('returns 0 at the left edge of the drawing area', () => { + expect(getNormalizedStagger('vertical', 10, 0, drawingArea)).toBe(0); + }); + + it('returns 1 at the right edge of the drawing area', () => { + expect(getNormalizedStagger('vertical', 210, 0, drawingArea)).toBe(1); + }); + + it('returns 0.5 at the midpoint of the drawing area', () => { + expect(getNormalizedStagger('vertical', 110, 0, drawingArea)).toBe(0.5); + }); + + it('returns 0 when drawing area width is 0', () => { + expect(getNormalizedStagger('vertical', 50, 0, { ...drawingArea, width: 0 })).toBe(0); + }); + }); + + describe('horizontal layout (stagger along y axis)', () => { + it('returns 0 at the top edge of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 20, drawingArea)).toBe(0); + }); + + it('returns 1 at the bottom edge of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 120, drawingArea)).toBe(1); + }); + + it('returns 0.5 at the midpoint of the drawing area', () => { + expect(getNormalizedStagger('horizontal', 0, 70, drawingArea)).toBe(0.5); + }); + + it('returns 0 when drawing area height is 0', () => { + expect(getNormalizedStagger('horizontal', 0, 50, { ...drawingArea, height: 0 })).toBe(0); + }); }); }); diff --git a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts index 8ff0147cb7..562ac9968d 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/chart.test.ts @@ -1,8 +1,11 @@ +import type { CartesianAxisConfigProps } from '../axis'; import { type AxisBounds, type ChartInset, defaultChartInset, + defaultHorizontalLayoutChartInset, defaultStackId, + defaultVerticalLayoutChartInset, getChartDomain, getChartInset, getChartRange, @@ -87,7 +90,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6] }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); expect(result.get('series1')).toEqual([ @@ -102,6 +105,77 @@ describe('getStackedSeriesData', () => { ]); }); + it('should apply axis baseline map to non-stacked numeric series', () => { + const series: Series[] = [ + { id: 'series1', data: [11, 12, 13], yAxisId: 'yA' }, + { id: 'series2', data: [4, 5, 6], yAxisId: 'yB' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'yA', baseline: 10 }, + { id: 'yB', baseline: 3 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([ + [10, 11], + [10, 12], + [10, 13], + ]); + expect(result.get('series2')).toEqual([ + [3, 4], + [3, 5], + [3, 6], + ]); + }); + + it('should not override tuple data when baseline map is provided', () => { + const series: Series[] = [ + { + id: 'series1', + data: [ + [8, 11], + [8, 12], + ], + }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], []); + + expect(result.get('series1')).toEqual([ + [8, 11], + [8, 12], + ]); + }); + + it('should stack numeric series around axis baseline values', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 30 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([[20, 30]]); + expect(result.get('series2')).toEqual([[30, 40]]); + expect(result.get('series3')).toEqual([[40, 70]]); + }); + + it('should apply axis baseline map to single-series stack groups', () => { + const series: Series[] = [{ id: 'series1', data: [1, 2], stackId: 'stack1' }]; + + const result = getStackedSeriesData(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 10 }, + ] as CartesianAxisConfigProps[]); + + expect(result.get('series1')).toEqual([ + [10, 1], + [10, 2], + ]); + }); + it('should handle series with tuple data', () => { const series: Series[] = [ { @@ -114,7 +188,7 @@ describe('getStackedSeriesData', () => { }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(1); expect(result.get('series1')).toEqual([ @@ -130,7 +204,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); // D3 stack will create cumulative values @@ -149,7 +223,7 @@ describe('getStackedSeriesData', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(2); // Should be treated as individual series since they have different y-axes @@ -165,23 +239,112 @@ describe('getStackedSeriesData', () => { ]); }); + it('should not stack series with different xAxisId', () => { + const series: Series[] = [ + { id: 'series1', data: [1, 2, 3], stackId: 'stack1', xAxisId: 'top' }, + { id: 'series2', data: [4, 5, 6], stackId: 'stack1', xAxisId: 'bottom' }, + ]; + + const result = getStackedSeriesData(series, 'vertical', [], []); + + expect(result.size).toBe(2); + expect(result.get('series1')).toEqual([ + [0, 1], + [0, 2], + [0, 3], + ]); + expect(result.get('series2')).toEqual([ + [0, 4], + [0, 5], + [0, 6], + ]); + }); + + it('should apply axis baseline map to non-stacked numeric series in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [11, 12, 13], xAxisId: 'xA' }, + { id: 'series2', data: [4, 5, 6], xAxisId: 'xB' }, + ]; + + const result = getStackedSeriesData( + series, + 'horizontal', + [ + { id: 'xA', baseline: 10 }, + { id: 'xB', baseline: 3 }, + ] as CartesianAxisConfigProps[], + [], + ); + + expect(result.get('series1')).toEqual([ + [10, 11], + [10, 12], + [10, 13], + ]); + expect(result.get('series2')).toEqual([ + [3, 4], + [3, 5], + [3, 6], + ]); + }); + + it('should stack numeric series around x-axis baseline values in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getStackedSeriesData( + series, + 'horizontal', + [{ id: 'DEFAULT_AXIS_ID', baseline: 30 }] as CartesianAxisConfigProps[], + [], + ); + + expect(result.get('series1')).toEqual([[20, 30]]); + expect(result.get('series2')).toEqual([[30, 40]]); + expect(result.get('series3')).toEqual([[40, 70]]); + }); + + it('should not stack series with different xAxisId in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [1, 2, 3], stackId: 'stack1', xAxisId: 'left' }, + { id: 'series2', data: [4, 5, 6], stackId: 'stack1', xAxisId: 'right' }, + ]; + + const result = getStackedSeriesData(series, 'horizontal', [], []); + + expect(result.size).toBe(2); + expect(result.get('series1')).toEqual([ + [0, 1], + [0, 2], + [0, 3], + ]); + expect(result.get('series2')).toEqual([ + [0, 4], + [0, 5], + [0, 6], + ]); + }); + it('should handle null values in data', () => { const series: Series[] = [{ id: 'series1', data: [1, null, 3] }]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.get('series1')).toEqual([[0, 1], null, [0, 3]]); }); it('should handle empty series array', () => { - const result = getStackedSeriesData([]); + const result = getStackedSeriesData([], 'vertical', [], []); expect(result.size).toBe(0); }); it('should handle series without data', () => { const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(0); }); @@ -192,7 +355,7 @@ describe('getStackedSeriesData', () => { { id: 'series3', data: [7, 8, 9] }, // No stackId ]; - const result = getStackedSeriesData(series); + const result = getStackedSeriesData(series, 'vertical', [], []); expect(result.size).toBe(3); expect(result.get('series3')).toEqual([ @@ -207,7 +370,7 @@ describe('getChartRange', () => { it('should return provided min and max when both are specified', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -10, 20); + const result = getChartRange(series, 'vertical', [], [], -10, 20); expect(result).toEqual({ min: -10, max: 20 }); }); @@ -217,7 +380,7 @@ describe('getChartRange', () => { { id: 'series2', data: [2, 4, 6] }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: 1, max: 6 }); }); @@ -240,7 +403,7 @@ describe('getChartRange', () => { }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: -1, max: 7 }); }); @@ -250,7 +413,7 @@ describe('getChartRange', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); // Stacked values should be cumulative expect(result.min).toBeDefined(); @@ -259,10 +422,41 @@ describe('getChartRange', () => { expect(result.max).toBeGreaterThanOrEqual(9); // 3 + 6 = 9 at minimum }); + it('should calculate range from baseline-centered stacked data', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getChartRange(series, 'vertical', [], [ + { id: 'DEFAULT_AXIS_ID', baseline: 30 }, + ] as CartesianAxisConfigProps[]); + + expect(result).toEqual({ min: 20, max: 70 }); + }); + + it('should calculate range from baseline-centered stacked data in horizontal layout', () => { + const series: Series[] = [ + { id: 'series1', data: [20], stackId: 'stack1' }, + { id: 'series2', data: [40], stackId: 'stack1' }, + { id: 'series3', data: [60], stackId: 'stack1' }, + ]; + + const result = getChartRange( + series, + 'horizontal', + [{ id: 'DEFAULT_AXIS_ID', baseline: 30 }] as CartesianAxisConfigProps[], + [], + ); + + expect(result).toEqual({ min: 20, max: 70 }); + }); + it('should handle negative values', () => { const series: Series[] = [{ id: 'series1', data: [-5, -2, 1, 3] }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: -5, max: 3 }); }); @@ -272,7 +466,7 @@ describe('getChartRange', () => { { id: 'series2', data: [-3, 4, -2], stackId: 'stack1' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result.min).toBeDefined(); expect(result.max).toBeDefined(); @@ -281,35 +475,35 @@ describe('getChartRange', () => { }); it('should handle empty series array', () => { - const result = getChartRange([]); + const result = getChartRange([], 'vertical', [], []); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle series with no data', () => { const series: Series[] = [{ id: 'series1' }, { id: 'series2', data: undefined }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: undefined, max: undefined }); }); it('should handle null values in data', () => { const series: Series[] = [{ id: 'series1', data: [1, null, 5, null, 3] }]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); expect(result).toEqual({ min: 1, max: 5 }); }); it('should use provided min with calculated max', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, -5); + const result = getChartRange(series, 'vertical', [], [], -5); expect(result).toEqual({ min: -5, max: 3 }); }); it('should use calculated min with provided max', () => { const series: Series[] = [{ id: 'series1', data: [1, 2, 3] }]; - const result = getChartRange(series, undefined, 10); + const result = getChartRange(series, 'vertical', [], [], undefined, 10); expect(result).toEqual({ min: 1, max: 10 }); }); @@ -319,7 +513,7 @@ describe('getChartRange', () => { { id: 'series2', data: [4, 5, 6], stackId: 'stack1', yAxisId: 'right' }, ]; - const result = getChartRange(series); + const result = getChartRange(series, 'vertical', [], []); // Should treat as individual series, not stacked expect(result).toEqual({ min: 0, max: 6 }); @@ -370,9 +564,9 @@ describe('isValidBounds', () => { }); }); -describe('defaultChartInset', () => { +describe('defaultVerticalLayoutChartInset', () => { it('should have correct default values', () => { - expect(defaultChartInset).toEqual({ + expect(defaultVerticalLayoutChartInset).toEqual({ top: 32, left: 16, bottom: 16, @@ -381,6 +575,23 @@ describe('defaultChartInset', () => { }); }); +describe('defaultHorizontalLayoutChartInset', () => { + it('should reserve additional right label room', () => { + expect(defaultHorizontalLayoutChartInset).toEqual({ + top: 16, + left: 16, + bottom: 16, + right: 48, + }); + }); +}); + +describe('deprecated chart inset aliases', () => { + it('maps defaultChartInset to defaultVerticalLayoutChartInset', () => { + expect(defaultChartInset).toEqual(defaultVerticalLayoutChartInset); + }); +}); + describe('getChartInset', () => { describe('with numeric inset', () => { it('should apply same value to all sides when given a number', () => { diff --git a/packages/web-visualization/src/chart/utils/__tests__/gradient.test.ts b/packages/web-visualization/src/chart/utils/__tests__/gradient.test.ts index ca44bc5fbd..098b9d7867 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/gradient.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/gradient.test.ts @@ -1,3 +1,4 @@ +import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; import { scaleLinear } from 'd3-scale'; import { evaluateGradientAtValue, getGradientConfig, type GradientDefinition } from '../gradient'; @@ -22,6 +23,24 @@ describe('gradient utilities', () => { expect(result?.[1]).toEqual({ offset: 1, color: '#00ff00', opacity: 1 }); }); + it('should use horizontal layout default (x axis) when gradient axis is omitted', () => { + const stopColorStart = defaultTheme.lightColor.fgNegative; + const stopColorEnd = defaultTheme.lightColor.fgPositive; + const localXScale: ChartScaleFunction = scaleLinear().domain([0, 4]).range([0, 400]); + const localYScale: ChartScaleFunction = scaleLinear().domain([0, 100]).range([400, 0]); + const gradient: GradientDefinition = { + stops: [ + { offset: 0, color: stopColorStart }, + { offset: 4, color: stopColorEnd }, + ], + }; + + const result = getGradientConfig(gradient, localXScale, localYScale, 'horizontal'); + expect(result).toHaveLength(2); + expect(result?.[0]).toEqual({ offset: 0, color: stopColorStart, opacity: 1 }); + expect(result?.[1]).toEqual({ offset: 1, color: stopColorEnd, opacity: 1 }); + }); + it('should handle CSS variables in gradient config', () => { const gradient: GradientDefinition = { stops: [ diff --git a/packages/web-visualization/src/chart/utils/__tests__/path.test.ts b/packages/web-visualization/src/chart/utils/__tests__/path.test.ts index b34ad2afdb..3e91b04b44 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/path.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/path.test.ts @@ -1,10 +1,5 @@ -import { - type ChartPathCurveType, - getAreaPath, - getBarPath, - getLinePath, - getPathCurveFunction, -} from '../path'; +import type { ChartPathCurveType } from '../path'; +import { getAreaPath, getBarPath, getLinePath, getPathCurveFunction } from '../path'; import { getCategoricalScale, getNumericScale } from '../scale'; describe('getPathCurveFunction', () => { @@ -45,6 +40,18 @@ describe('getPathCurveFunction', () => { expect(curveFunction).toBeDefined(); expect(typeof curveFunction).toBe('function'); }); + + it('should return layout-specific curve functions for monotone and bump', () => { + // Monotone + const monotoneHorizontal = getPathCurveFunction('monotone', 'horizontal'); + const monotoneVertical = getPathCurveFunction('monotone', 'vertical'); + expect(monotoneHorizontal).not.toBe(monotoneVertical); + + // Bump + const bumpHorizontal = getPathCurveFunction('bump', 'horizontal'); + const bumpVertical = getPathCurveFunction('bump', 'vertical'); + expect(bumpHorizontal).not.toBe(bumpVertical); + }); }); describe('getLinePath', () => { @@ -179,6 +186,23 @@ describe('getLinePath', () => { }); expect(result).toBe('M0,50Z'); }); + + it('should generate horizontal layout path correctly', () => { + const result = getLinePath({ + data: [1, 2, 3], + xScale, + yScale, + curve: 'linear', + layout: 'horizontal', + }); + // In horizontal layout (bars grow horizontally): + // x is value axis (xScale: 0->10 -> 0->100) + // y is index axis (yScale: 0->10 -> 100->0) + // Point 0: data[0]=1 -> value 1 -> x=xScale(1)=10, index 0 -> y=yScale(0)=100 + // Point 1: data[1]=2 -> value 2 -> x=xScale(2)=20, index 1 -> y=yScale(1)=90 + // Point 2: data[2]=3 -> value 3 -> x=xScale(3)=30, index 2 -> y=yScale(2)=80 + expect(result).toBe('M10,100L20,90L30,80'); + }); }); describe('getAreaPath', () => { @@ -317,6 +341,24 @@ describe('getAreaPath', () => { }); expect(result).toBe('M0,50L0,100Z'); }); + + it('should generate horizontal layout area path correctly', () => { + const result = getAreaPath({ + data: [1, 2], + curve: 'linear', + xScale, + yScale, + layout: 'horizontal', + }); + // In horizontal layout (areas grow horizontally): + // indexScale = yScale (0->10 -> 100->0) + // valueScale = xScale (0->10 -> 0->100) + // min = 0 + // Point 0: index 0 (y=100), low 0 (x=0), high 1 (x=10) + // Point 1: index 1 (y=90), low 0 (x=0), high 2 (x=20) + // Path: M10,100L20,90L0,90L0,100Z + expect(result).toBe('M10,100L20,90L0,90L0,100Z'); + }); }); describe('getBarPath', () => { @@ -374,4 +416,30 @@ describe('getBarPath', () => { expect(bottomRounding).not.toBe(bothRounding); expect(noRounding).not.toBe(bothRounding); }); + + it('should generate horizontal layout bar path correctly', () => { + // In horizontal layout (bars grow sideways): + // roundTop rounds the right face (max X) + // roundBottom rounds the left face (min X) + const x = 10, + y = 20, + width = 50, + height = 30, + radius = 5; + + const rightRounded = getBarPath(x, y, width, height, radius, true, false, 'horizontal'); + const leftRounded = getBarPath(x, y, width, height, radius, false, true, 'horizontal'); + + // Right face rounded: max X (x+width) corners + // Corners are: (x+width, y) and (x+width, y+height) + expect(rightRounded).toContain(`A ${radius} ${radius} 0 0 1 ${x + width} ${y + radius}`); + expect(rightRounded).toContain( + `A ${radius} ${radius} 0 0 1 ${x + width - radius} ${y + height}`, + ); + + // Left face rounded: min X (x) corners + // Corners are: (x, y) and (x, y+height) + expect(leftRounded).toContain(`A ${radius} ${radius} 0 0 1 ${x + radius} ${y}`); + expect(leftRounded).toContain(`A ${radius} ${radius} 0 0 1 ${x} ${y + height - radius}`); + }); }); diff --git a/packages/web-visualization/src/chart/utils/__tests__/point.test.ts b/packages/web-visualization/src/chart/utils/__tests__/point.test.ts index a11d405ce3..f70d37cb9d 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/point.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/point.test.ts @@ -313,6 +313,7 @@ describe('projectPoints', () => { }); it('should project numeric data array', () => { + // Default layout is now 'vertical': X is category (index), Y is value const result = projectPoints({ data: [1, 2, 3], xScale, diff --git a/packages/web-visualization/src/chart/utils/__tests__/scrubber.test.ts b/packages/web-visualization/src/chart/utils/__tests__/scrubber.test.ts index 97570597e5..ff11ae01cd 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/scrubber.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/scrubber.test.ts @@ -2,6 +2,27 @@ import type { Rect } from '@coinbase/cds-common/types'; import { calculateLabelYPositions, getLabelPosition } from '../scrubber'; +const calculateLabelStackedPositions = ( + dimensions: Array<{ + seriesId: string; + width: number; + height: number; + preferredX: number; + preferredY: number; + }>, + stackingStart: number, + stackingSize: number, + labelThickness: number, + minGap: number, +) => { + return calculateLabelYPositions( + dimensions, + { x: 0, y: stackingStart, width: 0, height: stackingSize }, + labelThickness, + minGap, + ); +}; + describe('getLabelPosition', () => { const drawingArea: Rect = { x: 0, @@ -85,7 +106,7 @@ describe('getLabelPosition', () => { }); }); -describe('calculateLabelYPositions', () => { +describe('calculateLabelStackedPositions', () => { const drawingArea: Rect = { x: 0, y: 0, @@ -97,7 +118,13 @@ describe('calculateLabelYPositions', () => { describe('with no labels', () => { it('should return empty map', () => { - const result = calculateLabelYPositions([], drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + [], + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); expect(result.size).toBe(0); }); }); @@ -107,7 +134,13 @@ describe('calculateLabelYPositions', () => { const dimensions = [ { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 150 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); expect(result.get('label1')).toBe(150); }); @@ -115,7 +148,13 @@ describe('calculateLabelYPositions', () => { const dimensions = [ { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 5 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // minY = 0 + 24/2 = 12 expect(result.get('label1')).toBe(12); }); @@ -124,7 +163,13 @@ describe('calculateLabelYPositions', () => { const dimensions = [ { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 295 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // maxY = 0 + 300 - 24/2 = 288 expect(result.get('label1')).toBe(288); }); @@ -137,7 +182,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 100 }, { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 150 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); expect(result.get('label1')).toBe(50); expect(result.get('label2')).toBe(100); expect(result.get('label3')).toBe(150); @@ -150,7 +201,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 150 }, { seriesId: 'label4', width: 50, height: 24, preferredX: 100, preferredY: 200 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // All labels should stay at their exact preferred positions expect(result.get('label1')).toBe(50); @@ -166,7 +223,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 50 }, { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // Labels form collision group and are centered around their average (50+60)/2 = 55 // With spacing of 28, they're positioned at 55-14=41 and 55+14=69 @@ -183,7 +246,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 55 }, { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // Labels form collision group and are centered around their average (50+55+60)/3 = 55 // Middle label at 55, others spaced 28 apart @@ -202,7 +271,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 50 }, { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 55 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // Despite different input order, results should be same as cascade test expect(result.get('label1')).toBe(27); @@ -218,7 +293,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 260 }, { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 270 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // label1 should stay at preferred position (not part of collision) expect(result.get('label1')).toBe(50); @@ -244,7 +325,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'Denver', width: 100, height: 24, preferredX: 100, preferredY: 238 }, { seriesId: 'Phoenix', width: 100, height: 24, preferredX: 100, preferredY: 242 }, ]; - const result = calculateLabelYPositions(dimensions, smallArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + smallArea.y, + smallArea.height, + labelHeight, + minGap, + ); // Boston should stay at preferred position (clamped to minY = 44) expect(result.get('Boston')).toBe(44); @@ -277,7 +364,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 260 }, { seriesId: 'label4', width: 50, height: 24, preferredX: 100, preferredY: 265 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // label1 and label2 should stay at preferred positions (not part of collision) expect(result.get('label1')).toBe(50); @@ -307,7 +400,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 70 }, { seriesId: 'label4', width: 50, height: 24, preferredX: 100, preferredY: 75 }, ]; - const result = calculateLabelYPositions(dimensions, smallDrawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + smallDrawingArea.y, + smallDrawingArea.height, + labelHeight, + minGap, + ); // All labels should fit within drawing area const positions = [ @@ -338,7 +437,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'group2', width: 50, height: 24, preferredX: 100, preferredY: 155 }, { seriesId: 'group3', width: 50, height: 24, preferredX: 100, preferredY: 160 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // Isolated label should stay at preferred position expect(result.get('isolated')).toBe(50); @@ -359,7 +464,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 0 }, { seriesId: 'label3', width: 50, height: 24, preferredX: 100, preferredY: 5 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); const label1Y = result.get('label1')!; const label2Y = result.get('label2')!; @@ -376,7 +487,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'wide', width: 100, height: 24, preferredX: 100, preferredY: 50 }, { seriesId: 'narrow', width: 30, height: 24, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + minGap, + ); // Labels form collision group, centered around (50+60)/2 = 55 expect(result.get('wide')).toBe(41); @@ -394,7 +511,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 50 }, { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, largeGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + largeGap, + ); // Centered around (50+60)/2 = 55, with spacing of 24+16=40 expect(result.get('label1')).toBe(35); @@ -410,7 +533,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label1', width: 50, height: 24, preferredX: 100, preferredY: 50 }, { seriesId: 'label2', width: 50, height: 24, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, labelHeight, smallGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + labelHeight, + smallGap, + ); // Centered around (50+60)/2 = 55, with spacing of 24+1=25 expect(result.get('label1')).toBe(42.5); @@ -428,7 +557,13 @@ describe('calculateLabelYPositions', () => { { seriesId: 'label1', width: 50, height: 32, preferredX: 100, preferredY: 50 }, { seriesId: 'label2', width: 50, height: 32, preferredX: 100, preferredY: 60 }, ]; - const result = calculateLabelYPositions(dimensions, drawingArea, largeLabelHeight, minGap); + const result = calculateLabelStackedPositions( + dimensions, + drawingArea.y, + drawingArea.height, + largeLabelHeight, + minGap, + ); // Centered around (50+60)/2 = 55, with spacing of 32+4=36 expect(result.get('label1')).toBe(37); diff --git a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts index 8ea8ac990a..4cf33e82d4 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts @@ -1,9 +1,13 @@ +import * as React from 'react'; import { renderHook } from '@testing-library/react-hooks'; +import type { MotionValue } from 'framer-motion'; -import { defaultTransition, usePathTransition } from '../transition'; +import { defaultTransition, getTransition, usePathTransition } from '../transition'; // Mock framer-motion jest.mock('framer-motion', () => { + const React = require('react'); + const mockMotionValue = (initial: any) => { let value = initial; const listeners: Array<(v: any) => void> = []; @@ -24,17 +28,18 @@ jest.mock('framer-motion', () => { }; return { - useMotionValue: jest.fn((initial) => mockMotionValue(initial)), - useTransform: jest.fn((source, transformer) => { - const result = mockMotionValue(transformer(source.get())); - source.onChange((v: any) => { - result.set(transformer(v)); - }); - return result; + useMotionValue: jest.fn((initial) => { + const motionValueRef = React.useRef(null as ReturnType | null); + if (motionValueRef.current === null) { + motionValueRef.current = mockMotionValue(initial); + } + return motionValueRef.current; }), - animate: jest.fn((value, target, config) => { - // Immediately set to target for testing - value.set(target); + animate: jest.fn((_from, _to, config) => { + // Simulate instant completion: call onUpdate with final value, then onComplete + if (config?.onUpdate) { + config.onUpdate(_to); + } if (config?.onComplete) { config.onComplete(); } @@ -86,6 +91,16 @@ describe('accessory transition constants', () => { }); }); +describe('getTransition', () => { + it('should return null when animate is false', () => { + expect(getTransition(defaultTransition, false, defaultTransition)).toBeNull(); + }); + + it('should return null when value is null', () => { + expect(getTransition(null, true, defaultTransition)).toBeNull(); + }); +}); + describe('usePathTransition', () => { beforeEach(() => { jest.clearAllMocks(); @@ -132,6 +147,83 @@ describe('usePathTransition', () => { expect(result.current.get()).toBeDefined(); }); + it('preserves motion value identity across rerenders with the same currentPath', () => { + const currentPath = 'M0,0L10,10'; + + const { result, rerender } = renderHook(() => + usePathTransition({ + currentPath, + }), + ); + + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); + + it('supports React Strict Mode when currentPath changes', () => { + const Strict = (props: { path: string; children?: React.ReactNode }) => + React.createElement(React.StrictMode, null, props.children); + + const { result, rerender } = renderHook<{ path: string }, MotionValue>( + ({ path }) => usePathTransition({ currentPath: path }), + { + wrapper: Strict, + initialProps: { path: 'M0,0L10,10' }, + }, + ); + + rerender({ path: 'M0,0L20,20' }); + + expect(result.current.get()).toBe('M0,0L20,20'); + }); + + it('starts the next interpolation from the current motion value when a transition is interrupted', () => { + const { animate } = require('framer-motion'); + const { interpolatePath } = require('d3-interpolate-path'); + + animate + .mockImplementationOnce( + (_from: unknown, _to: unknown, config: { onUpdate?: (t: number) => void }) => { + if (config?.onUpdate) { + config.onUpdate(0.49); + } + return { cancel: jest.fn(), stop: jest.fn() }; + }, + ) + .mockImplementationOnce( + ( + _from: unknown, + _to: unknown, + config: { onUpdate?: (t: number) => void; onComplete?: () => void }, + ) => { + if (config?.onUpdate) { + config.onUpdate(1); + } + if (config?.onComplete) { + config.onComplete(); + } + return { cancel: jest.fn(), stop: jest.fn() }; + }, + ); + + const path1 = 'M0,0L10,10'; + const path2 = 'M0,0L20,20'; + const path3 = 'M0,0L30,30'; + + const { rerender } = renderHook(({ path }) => usePathTransition({ currentPath: path }), { + initialProps: { path: path1 }, + }); + + interpolatePath.mockClear(); + rerender({ path: path2 }); + expect(interpolatePath).toHaveBeenCalledWith(path1, path2); + + interpolatePath.mockClear(); + rerender({ path: path3 }); + expect(interpolatePath).toHaveBeenCalledWith(path1, path3); + }); + it('should handle path updates', () => { const { useMotionValue, animate } = require('framer-motion'); const { result, rerender } = renderHook( @@ -167,7 +259,7 @@ describe('usePathTransition', () => { const { result } = renderHook(() => usePathTransition({ currentPath, - transition, + transitions: { update: transition }, }), ); @@ -178,7 +270,7 @@ describe('usePathTransition', () => { ({ path }) => usePathTransition({ currentPath: path, - transition, + transitions: { update: transition }, }), { initialProps: { path: currentPath }, @@ -236,12 +328,58 @@ describe('usePathTransition', () => { expect(interpolatePath).toHaveBeenCalled(); }); - it('should cancel ongoing animation when path changes', () => { + it('should short-circuit interpolation when update transition is null', () => { + const { interpolatePath } = require('d3-interpolate-path'); + const nextPath = 'M0,0L30,30'; + + const { result, rerender } = renderHook( + ({ path }) => + usePathTransition({ + currentPath: path, + transitions: { update: null }, + }), + { + initialProps: { path: 'M0,0L10,10' }, + }, + ); + + interpolatePath.mockClear(); + rerender({ path: nextPath }); + + expect(interpolatePath).not.toHaveBeenCalled(); + expect(result.current.get()).toBe(nextPath); + }); + + it('should short-circuit interpolation when enter transition is null', () => { + const { interpolatePath } = require('d3-interpolate-path'); + const initialPath = 'M0,0L10,10'; + const nextPath = 'M0,0L30,30'; + + const { result, rerender } = renderHook( + ({ path }) => + usePathTransition({ + currentPath: path, + initialPath, + transitions: { enter: null, update: defaultTransition }, + }), + { + initialProps: { path: initialPath }, + }, + ); + + interpolatePath.mockClear(); + rerender({ path: nextPath }); + + expect(interpolatePath).not.toHaveBeenCalled(); + expect(result.current.get()).toBe(nextPath); + }); + + it('should stop ongoing animation when path changes', () => { const { animate } = require('framer-motion'); - const cancelMock = jest.fn(); + const stopMock = jest.fn(); animate.mockReturnValue({ - cancel: cancelMock, - stop: jest.fn(), + cancel: jest.fn(), + stop: stopMock, }); const { rerender } = renderHook( @@ -257,10 +395,10 @@ describe('usePathTransition', () => { // Trigger first animation rerender({ path: 'M0,0L20,20' }); - // Trigger second animation (should cancel first) + // Trigger second animation (should stop first) rerender({ path: 'M0,0L30,30' }); - expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); }); it('should handle smooth interruption of ongoing animation', () => { @@ -291,12 +429,58 @@ describe('usePathTransition', () => { expect(animate.mock.calls.length).toBeGreaterThan(animateCallCount); }); + it('does not stop active animation when only transition object identity changes', () => { + const { animate } = require('framer-motion'); + const stopMock = jest.fn(); + animate.mockImplementation((_from: any, _to: any, config: any) => { + if (config?.onUpdate) { + config.onUpdate(0.5); + } + return { + cancel: jest.fn(), + stop: stopMock, + }; + }); + + const { rerender } = renderHook( + ({ path, transitionConfig }) => + usePathTransition({ + currentPath: path, + transitions: { + update: transitionConfig, + }, + }), + { + initialProps: { + path: 'M0,0L10,10', + transitionConfig: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }, + }, + ); + + // Start an animation to path2 + rerender({ + path: 'M0,0L20,20', + transitionConfig: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }); + const animateCallCount = animate.mock.calls.length; + + // Same path target, new transition object identity + rerender({ + path: 'M0,0L20,20', + transitionConfig: { type: 'spring' as const, stiffness: 300, damping: 30 }, + }); + + expect(animate.mock.calls.length).toBe(animateCallCount); + expect(stopMock).not.toHaveBeenCalled(); + }); + it('should cleanup animation on unmount', () => { const { animate } = require('framer-motion'); - const cancelMock = jest.fn(); + const stopMock = jest.fn(); animate.mockReturnValue({ - cancel: cancelMock, - stop: jest.fn(), + cancel: jest.fn(), + stop: stopMock, }); const { unmount, rerender } = renderHook( @@ -312,13 +496,13 @@ describe('usePathTransition', () => { // Trigger animation rerender({ path: 'M0,0L20,20' }); - // Unmount should cancel animation + // Unmount should stop animation unmount(); - expect(cancelMock).toHaveBeenCalled(); + expect(stopMock).toHaveBeenCalled(); }); - it('should maintain previous path reference across renders', () => { + it('supports multiple consecutive path updates', () => { const path1 = 'M0,0L10,10'; const path2 = 'M0,0L20,20'; const path3 = 'M0,0L30,30'; @@ -344,12 +528,14 @@ describe('usePathTransition', () => { expect(result.current).toBeDefined(); }); - it('should update previousPathRef onComplete', () => { + it('supports a new path transition after animation onComplete', () => { const { animate } = require('framer-motion'); let onCompleteCallback: (() => void) | undefined; - animate.mockImplementation((value: any, target: any, config: any) => { - value.set(target); + animate.mockImplementation((_from: any, _to: any, config: any) => { + if (config?.onUpdate) { + config.onUpdate(_to); + } onCompleteCallback = config?.onComplete; return { cancel: jest.fn(), @@ -375,7 +561,7 @@ describe('usePathTransition', () => { onCompleteCallback(); } - // Should be able to handle another path change + // Motion value already reflects the target path; another change should start a new transition rerender({ path: 'M0,0L30,30' }); expect(animate).toHaveBeenCalled(); diff --git a/packages/web-visualization/src/chart/utils/axis.ts b/packages/web-visualization/src/chart/utils/axis.ts index 73fac3a3d2..4832f5a39a 100644 --- a/packages/web-visualization/src/chart/utils/axis.ts +++ b/packages/web-visualization/src/chart/utils/axis.ts @@ -9,9 +9,9 @@ import { isValidBounds, type Series, } from './chart'; +import type { CartesianChartLayout } from './context'; import { getPointOnScale } from './point'; import { - type CategoricalScale, type ChartAxisScaleType, type ChartScaleFunction, getCategoricalScale, @@ -71,7 +71,8 @@ export type AxisConfig = { */ range: AxisBounds; /** - * Data for the axis + * Data for the axis. + * @note only used by the category axis. */ data?: string[] | number[]; /** @@ -88,10 +89,22 @@ export type AxisConfig = { domainLimit: 'nice' | 'strict'; }; +export type CartesianAxisConfig = AxisConfig & { + /** + * Baseline value used as the origin for numeric series on this axis. + * Only applies when this axis is the value axis for the current chart layout. + * - Non-stacked numeric series render from `[baseline, value]`. + * - Multi-series stacks are normalized around this baseline before stacking. + * + * @default 0 for value axes, undefined for category axes + */ + baseline?: number; +}; + /** * Axis configuration without computed bounds (used for input) */ -export type AxisConfigProps = Omit & { +export type CartesianAxisConfigProps = Omit & { /** * Unique identifier for this axis. */ @@ -120,6 +133,36 @@ export type AxisConfigProps = Omit & { range?: Partial | ((bounds: AxisBounds) => AxisBounds); }; +const includeBaselineInBounds = (bounds: AxisBounds, baseline: number): AxisBounds => { + if (baseline < bounds.min) return { ...bounds, min: baseline }; + if (baseline > bounds.max) return { ...bounds, max: baseline }; + return bounds; +}; + +export const withBaselineDomain = ( + domain: CartesianAxisConfigProps['domain'], + baseline: number = 0, +): CartesianAxisConfigProps['domain'] => { + if (typeof domain === 'function') return domain; + if (domain?.min !== undefined && domain?.max !== undefined) return domain; + + const hasExplicitMin = domain?.min !== undefined; + const hasExplicitMax = domain?.max !== undefined; + + return (bounds: AxisBounds): AxisBounds => { + const resolvedBounds: AxisBounds = { + min: hasExplicitMin ? (domain?.min as number) : bounds.min, + max: hasExplicitMax ? (domain?.max as number) : bounds.max, + }; + const baselineAdjustedBounds = includeBaselineInBounds(resolvedBounds, baseline); + + return { + min: hasExplicitMin ? resolvedBounds.min : baselineAdjustedBounds.min, + max: hasExplicitMax ? resolvedBounds.max : baselineAdjustedBounds.max, + }; + }; +}; + /** * Gets a D3 scale based on the axis configuration. * Handles both numeric (linear/log) and categorical (band) scales. @@ -127,27 +170,42 @@ export type AxisConfigProps = Omit & { * For numeric scales, the domain limit controls whether bounds are "nice" (human-friendly) * or "strict" (exact min/max). Range can be customized using function-based configuration. * + * Range inversion is determined by axis role (category vs value) and layout: + * - Vertical layout: Y axis (value) is inverted for SVG coordinate system + * - Horizontal layout: Y axis (category) is inverted (first category at top) + * * @param params - Scale parameters * @returns The D3 scale function * @throws An Error if bounds are invalid */ -export const getAxisScale = ({ +export const getCartesianAxisScale = ({ config, type, range, dataDomain, + layout = 'vertical', }: { - config?: AxisConfig; + config?: CartesianAxisConfig; type: 'x' | 'y'; range: AxisBounds; dataDomain: AxisBounds; + layout?: CartesianChartLayout; }): ChartScaleFunction => { const scaleType = config?.scaleType ?? 'linear'; let adjustedRange = range; - // Invert range for Y axis for SVG coordinate system - if (type === 'y') { + // Determine if this axis needs range inversion for SVG coordinate system. + // SVG Y coordinates increase downward, so we need to invert for value axes + // where we want higher values at the top. + // + // For vertical layout: Y axis is the value axis → invert (higher values at top) + // For horizontal layout: Y axis is the category axis → don't invert (first category at top is natural) + // X axis never needs inversion (left-to-right is natural for both layouts) + + const shouldInvertRange = type === 'y' && layout !== 'horizontal'; + + if (shouldInvertRange) { adjustedRange = { min: adjustedRange.max, max: adjustedRange.min }; } @@ -162,7 +220,7 @@ export const getAxisScale = ({ if (!isValidBounds(adjustedDomain)) throw new Error( - 'Invalid domain bounds. See https://cds.coinbase.com/http://localhost:3000/components/graphs/XAxis/#domain', + 'Invalid domain bounds. See https://cds.coinbase.com/components/charts/XAxis/#domain', ); if (scaleType === 'band') { @@ -197,11 +255,16 @@ export const getAxisScale = ({ */ export const getAxisConfig = ( type: 'x' | 'y', - axes: Partial | Partial[] | undefined, + axes: Partial | Partial[] | undefined, defaultId: string = defaultAxisId, defaultScaleType: ChartAxisScaleType = defaultAxisScaleType, -): AxisConfigProps[] => { +): CartesianAxisConfigProps[] => { const defaultDomainLimit = type === 'x' ? 'strict' : 'nice'; + const axisName = type === 'x' ? 'x-axis' : 'y-axis'; + const axisDocUrl = + type === 'x' + ? 'https://cds.coinbase.com/components/charts/XAxis' + : 'https://cds.coinbase.com/components/charts/YAxis'; if (!axes) { return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit }]; } @@ -211,21 +274,37 @@ export const getAxisConfig = ( // forces id to be defined on every input config when there are multiple axes if (axesLength > 1 && axes.some(({ id }) => id === undefined)) { throw new Error( - 'When defining multiple axes, each must have a unique id. See https://cds.coinbase.com/components/graphs/YAxis/#multiple-y-axes.', + `When defining multiple ${axisName}, each must have a unique id. See ${axisDocUrl}.`, ); } + if (axesLength > 1) { + const ids = axes.map(({ id }) => id).filter((id): id is string => id !== undefined); + if (new Set(ids).size !== ids.length) { + throw new Error( + `When defining multiple ${axisName}, each must have a unique id. See ${axisDocUrl}.`, + ); + } + } + return axes.map(({ id, ...axis }) => ({ // defaults the axis id if only a single axis is provided - id: axesLength > 1 ? (id ?? defaultAxisId) : (id as string), + id: axesLength > 1 ? (id ?? defaultAxisId) : (id ?? defaultId), scaleType: defaultScaleType, domainLimit: defaultDomainLimit, ...axis, - })); + })) as CartesianAxisConfigProps[]; } // Single axis config - return [{ id: defaultId, scaleType: defaultScaleType, domainLimit: defaultDomainLimit, ...axes }]; + return [ + { + id: defaultId, + scaleType: defaultScaleType, + domainLimit: defaultDomainLimit, + ...axes, + } as CartesianAxisConfigProps, + ]; }; /** @@ -235,12 +314,14 @@ export const getAxisConfig = ( * @param axisParam - The axis configuration * @param series - Array of series objects (for stacking support) * @param axisType - Whether this is an 'x' or 'y' axis + * @param layout - Chart layout ('horizontal' or 'vertical') * @returns The calculated axis bounds */ -export const getAxisDomain = ( - axisParam: AxisConfigProps, +export const getCartesianAxisDomain = ( + axisParam: CartesianAxisConfigProps, series: Series[], axisType: 'x' | 'y', + layout: CartesianChartLayout = 'vertical', ): AxisBounds => { let dataDomain: AxisBounds | null = null; if (axisParam.data && Array.isArray(axisParam.data) && axisParam.data.length > 0) { @@ -264,7 +345,18 @@ export const getAxisDomain = ( } // Calculate domain from series data - const seriesDomain = axisType === 'x' ? getChartDomain(series) : getChartRange(series); + // In vertical layout: X is category (index), Y is value (value) + // In horizontal layout: Y is category (index), X is value (value) + const isCategoryAxis = + (layout !== 'horizontal' && axisType === 'x') || (layout === 'horizontal' && axisType === 'y'); + const seriesDomain = isCategoryAxis + ? getChartDomain(series) + : getChartRange( + series, + layout, + axisType === 'x' ? [axisParam] : [], + axisType === 'y' ? [axisParam] : [], + ); // If data sets the domain, use that instead of the series domain const preferredDataDomain = dataDomain ?? seriesDomain; @@ -290,7 +382,6 @@ export const getAxisDomain = ( finalDomain = preferredDataDomain; } - // Ensure we always return valid bounds with no undefined values return { min: finalDomain.min ?? 0, max: finalDomain.max ?? 0, @@ -307,7 +398,7 @@ export const getAxisDomain = ( * @returns The calculated axis range bounds */ export const getAxisRange = ( - axisParam: AxisConfigProps, + axisParam: CartesianAxisConfigProps, chartRect: Rect, axisType: 'x' | 'y', ): AxisBounds => { diff --git a/packages/web-visualization/src/chart/utils/bar.ts b/packages/web-visualization/src/chart/utils/bar.ts index c30de9154e..e5ccb31939 100644 --- a/packages/web-visualization/src/chart/utils/bar.ts +++ b/packages/web-visualization/src/chart/utils/bar.ts @@ -1,3 +1,96 @@ +import type { Rect } from '@coinbase/cds-common/types'; +import type { Transition } from 'framer-motion'; + +import type { BarBaseProps, BarComponent } from '../bar/Bar'; +import type { BarSeries } from '../bar/BarStack'; + +import { defaultAxisId as fallbackAxisId } from './axis'; +import type { CartesianChartLayout } from './context'; +import type { GradientDefinition, GradientStop } from './gradient'; +import { evaluateGradientAtValue } from './gradient'; +import type { ChartScaleFunction } from './scale'; +import { defaultTransition } from './transition'; + +/** + * A bar-specific transition that extends Transition with stagger support. + * When `staggerDelay` is provided, bars will animate with increasing delays + * based on their position along the category axis (vertical: left-to-right, + * horizontal: top-to-bottom). + * + * @example + * // Bars stagger in from left to right over 0.25s, each animating for 0.75s + * { type: 'tween', duration: 0.75, staggerDelay: 0.25 } + */ +export type BarTransition = Transition & { + /** + * Maximum stagger delay (seconds) distributed across bars by x position. + * Leftmost bar starts immediately, rightmost starts after this delay. + */ + staggerDelay?: number; +}; + +/** + * Computes a bar's normalized [0, 1] position along the category axis, used for + * stagger-delay calculations. + * + * Vertical charts stagger left-to-right (x axis); horizontal charts stagger + * top-to-bottom (y axis). Returns 0 when the drawing area has no extent. + * + * @param layout - The layout of the chart + * @param x - Bar's left edge in pixels + * @param y - Bar's top edge in pixels + */ +export const getNormalizedStagger = ( + layout: CartesianChartLayout, + x: number, + y: number, + drawingArea: Rect, +): number => { + if (layout === 'horizontal') { + return drawingArea.height > 0 ? (y - drawingArea.y) / drawingArea.height : 0; + } + return drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0; +}; + +/** + * Strips `staggerDelay` from a transition and computes a positional delay. + * + * @param transition - The transition config (may include staggerDelay) + * @param normalizedPosition - The bar's normalized position along the category axis (0–1) + * @returns A standard Transition with computed delay + */ +export const withStaggerDelayTransition = ( + transition: BarTransition | null, + normalizedPosition: number, +): Transition | null => { + if (!transition) return null; + const { staggerDelay, ...baseTransition } = transition; + if (!staggerDelay) return transition; + return { + ...baseTransition, + delay: (baseTransition?.delay ?? 0) + normalizedPosition * staggerDelay, + }; +}; + +/** + * Default bar enter transition. Uses the default spring with a stagger delay + * so bars spring into place from left to right. + * `{ type: 'spring', stiffness: 900, damping: 120, mass: 4, staggerDelay: 0.25 }` + */ +export const defaultBarEnterTransition: BarTransition = { + ...defaultTransition, + staggerDelay: 0.25, +}; + +/** + * Default bar enter opacity transition. + * `{ type: 'tween', duration: 0.2 }` + */ +export const defaultBarEnterOpacityTransition: BarTransition = { + type: 'tween', + duration: 0.2, +}; + /** * Calculates the size adjustment needed for bars when accounting for gaps between them. * This function helps determine how much to reduce each bar's width to accommodate @@ -23,3 +116,1010 @@ export function getBarSizeAdjustment(barCount: number, gapSize: number): number return (gapSize * (barCount - 1)) / barCount; } + +type StackGroup = { + stackId: string; + series: BarSeries[]; + xAxisId?: string; + yAxisId?: string; +}; + +/** + * Groups bar series into stack groups scoped by stackId + axis IDs. + * + * Series with no `stackId` are treated as independent stacks keyed by series id. + * Axis IDs are included in the group key so series on different axes never stack together. + */ +export function getStackGroups( + series: BarSeries[], + defaultAxisId: string = fallbackAxisId, +): StackGroup[] { + const groups: Record = {}; + + series.forEach((entry) => { + const xAxisId = entry.xAxisId ?? defaultAxisId; + const yAxisId = entry.yAxisId ?? defaultAxisId; + const stackId = entry.stackId || `individual-${entry.id}`; + const stackKey = `${stackId}:${xAxisId}:${yAxisId}`; + + if (!groups[stackKey]) { + groups[stackKey] = { + stackId: stackKey, + series: [], + xAxisId: entry.xAxisId, + yAxisId: entry.yAxisId, + }; + } + + groups[stackKey].series.push(entry); + }); + + return Object.values(groups); +} + +/** + * A single positioned bar in a stack, used throughout all bar layout helpers. + */ +/** + * A single positioned bar — the source-of-truth data shape for the bar system. + * + * Layout fields (`valuePos`, `length`) are axis-agnostic and used by helper + * functions during computation. Rendering fields (`x`, `y`, `width`, `height`, + * `origin`, `dataX`, `dataY`) are derived at the end of `getBars` and can be + * passed directly to the `` component. + * + * `BarBaseProps` in `Bar.tsx` picks from this type. + */ +/** + * A fully computed bar ready to render — extends `BarBaseProps` with required + * identity fields and internal layout data used by helper functions. + * + * `getBars` returns `BarData[]` with every `BarBaseProps` field populated so + * the `` component can consume them directly. + */ +type BarData = BarBaseProps & { + /** The ID of the series this bar belongs to. */ + seriesId: string; + /** Coordinate of the baseline/origin for animations. */ + origin: number; + /** Position along the value axis in pixels (axis-agnostic, used by layout helpers). */ + valuePos: number; + /** Size along the value axis in pixels (axis-agnostic, used by layout helpers). */ + length: number; + /** The raw data value as [baseline, value], used by layout helpers for gap/rounding logic. */ + dataValue: [number, number]; + /** Whether gap distribution should be applied to this bar in a stack. */ + shouldApplyGap?: boolean; +}; + +/** + * Applies proportional gap distribution to a stack of bars, maintaining total stack length. + * Gaps are only inserted between bars that have `shouldApplyGap = true`. + * Positive (above-baseline) and negative (below-baseline) groups are gapped independently. + * + * @param bars - Array of bar items with current valuePos and length + * @param stackGap - Gap size in pixels between adjacent bars + * @param layout - The layout of the chart + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @returns New array of bars with adjusted valuePos and length + */ +function applyStackGap( + bars: BarData[], + stackGap: number, + layout: CartesianChartLayout, + baseline: number, + baselinePx: number, +): BarData[] { + if (!stackGap || bars.length <= 1) return bars; + + const result = [...bars]; + + const barsAboveBaseline = bars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return bottom >= baseline && top !== bottom && bar.shouldApplyGap; + }); + const barsBelowBaseline = bars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return top <= baseline && bottom !== top && bar.shouldApplyGap; + }); + + const applyGapGroup = (group: BarData[], growing: boolean) => { + if (group.length <= 1) return; + + const totalGapSpace = stackGap * (group.length - 1); + const totalDataLength = group.reduce((sum, bar) => sum + bar.length, 0); + const lengthReduction = totalGapSpace / totalDataLength; + + const sortedBars = growing + ? [...group].sort((a, b) => b.valuePos - a.valuePos) + : [...group].sort((a, b) => a.valuePos - b.valuePos); + + let currentEdge = baselinePx; + sortedBars.forEach((bar, index) => { + const newLength = bar.length * (1 - lengthReduction); + let newValuePos: number; + + if (growing) { + newValuePos = currentEdge - newLength; + currentEdge = newValuePos - (index < sortedBars.length - 1 ? stackGap : 0); + } else { + newValuePos = currentEdge; + currentEdge = newValuePos + newLength + (index < sortedBars.length - 1 ? stackGap : 0); + } + + const barIndex = result.findIndex((b) => b.seriesId === bar.seriesId); + if (barIndex !== -1) { + result[barIndex] = { ...result[barIndex], length: newLength, valuePos: newValuePos }; + } + }); + }; + + // Positive bars: grow up in vertical (decreasing Y), grow right in horizontal (increasing X) + applyGapGroup(barsAboveBaseline, layout === 'vertical'); + // Negative bars: grow down in vertical (increasing Y), grow left in horizontal (decreasing X) + applyGapGroup(barsBelowBaseline, layout !== 'vertical'); + + return result; +} + +/** + * Expands bars that are shorter than `barMinSize` to the minimum size. + * Non-expanded bars are scaled down proportionally to keep the total bar length constant, + * preventing stacked bars from overflowing the chart area. + * + * Bars are then repositioned from the baseline, preserving original gaps between them. + * + * @param bars - Array of bar items with current valuePos and length + * @param barMinSize - Minimum bar size in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @param layout - Chart layout + * @returns New array of bars with adjusted valuePos and length + */ +function applyBarMinSize( + bars: BarData[], + barMinSize: number, + baseline: number, + baselinePx: number, + layout: CartesianChartLayout, +): BarData[] { + if (!barMinSize || bars.length === 0) return bars; + + const originalTotalLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const needsExpansion = bars.map((bar) => bar.length < barMinSize); + const expandedTotalLength = bars.reduce( + (sum, bar, i) => sum + (needsExpansion[i] ? barMinSize : bar.length), + 0, + ); + + let finalLengths: number[]; + if (expandedTotalLength > originalTotalLength) { + // Scale down non-expanded bars to keep total bar length constant + const spaceForExpanded = needsExpansion.filter(Boolean).length * barMinSize; + const spaceForNonExpanded = Math.max(0, originalTotalLength - spaceForExpanded); + const nonExpandedOrigTotal = bars.reduce( + (sum, bar, i) => (!needsExpansion[i] ? sum + bar.length : sum), + 0, + ); + const scaleFactor = nonExpandedOrigTotal > 0 ? spaceForNonExpanded / nonExpandedOrigTotal : 0; + finalLengths = bars.map((bar, i) => + needsExpansion[i] ? barMinSize : bar.length * scaleFactor, + ); + } else { + finalLengths = bars.map((bar, i) => (needsExpansion[i] ? barMinSize : bar.length)); + } + + const expandedBars = bars.map((bar, i) => ({ + ...bar, + length: finalLengths[i], + })); + + const newPositions = new Map(); + + // Range bars (shouldApplyGap=false) float at data-defined coordinates independent of the + // baseline. Restacking them from the zero baseline would place them off-screen when the + // y-axis domain doesn't include 0 (e.g., a price chart with domain [28000, 37000]). + // Instead, expand them in-place, centered on their original midpoint. + for (let i = 0; i < bars.length; i++) { + if (bars[i].shouldApplyGap === false) { + const originalMid = bars[i].valuePos + bars[i].length / 2; + newPositions.set(bars[i].seriesId, { + valuePos: originalMid - expandedBars[i].length / 2, + length: expandedBars[i].length, + }); + } + } + + // Stacked bars (shouldApplyGap=true/undefined): classify by which side of the baseline + // they're on and restack from the baseline outward. + const stackedSortedBars = [...expandedBars] + .filter((bar) => bar.shouldApplyGap !== false) + .sort((a, b) => a.valuePos - b.valuePos); + + if (stackedSortedBars.length > 0) { + // Classify using dataValue to correctly identify which side of the baseline each bar is on, + // independent of the current valuePos (which hasn't been repositioned yet). + const barsAboveBaseline = stackedSortedBars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return bottom >= baseline && top !== bottom; + }); + const barsBelowBaseline = stackedSortedBars.filter((bar) => { + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + return top <= baseline && bottom !== top; + }); + + // Restack bars above baseline (positive data side). + // vertical → grow up (−Y from baseline); horizontal → grow right (+X from baseline). + if (layout === 'vertical') { + let currentAbove = baselinePx; + for (let i = barsAboveBaseline.length - 1; i >= 0; i--) { + const bar = barsAboveBaseline[i]; + const newValuePos = currentAbove - bar.length; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: bar.length }); + if (i > 0) { + const nextBar = barsAboveBaseline[i - 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalCurrent.valuePos - (originalNext.valuePos + originalNext.length); + currentAbove = newValuePos - originalGap; + } + } + } else { + let currentEdge = baselinePx; + for (let i = 0; i < barsAboveBaseline.length; i++) { + const bar = barsAboveBaseline[i]; + newPositions.set(bar.seriesId, { valuePos: currentEdge, length: bar.length }); + if (i < barsAboveBaseline.length - 1) { + const nextBar = barsAboveBaseline[i + 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length); + currentEdge = currentEdge + bar.length + originalGap; + } + } + } + + // Restack bars below baseline (negative data side). + // vertical → grow down (+Y); horizontal → grow left (−X). + if (layout === 'vertical') { + let currentBelow = baselinePx; + for (let i = 0; i < barsBelowBaseline.length; i++) { + const bar = barsBelowBaseline[i]; + newPositions.set(bar.seriesId, { valuePos: currentBelow, length: bar.length }); + if (i < barsBelowBaseline.length - 1) { + const nextBar = barsBelowBaseline[i + 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalNext.valuePos - (originalCurrent.valuePos + originalCurrent.length); + currentBelow = currentBelow + bar.length + originalGap; + } + } + } else { + const sortedBelow = [...barsBelowBaseline].sort((a, b) => b.valuePos - a.valuePos); + let currentEdge = baselinePx; + for (let i = sortedBelow.length - 1; i >= 0; i--) { + const bar = sortedBelow[i]; + const newValuePos = currentEdge - bar.length; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: bar.length }); + if (i > 0) { + const nextBar = sortedBelow[i - 1]; + const originalCurrent = bars.find((b) => b.seriesId === bar.seriesId)!; + const originalNext = bars.find((b) => b.seriesId === nextBar.seriesId)!; + const originalGap = + originalCurrent.valuePos - (originalNext.valuePos + originalNext.length); + currentEdge = newValuePos - originalGap; + } + } + } + } + + return expandedBars.map((bar) => { + const newPos = newPositions.get(bar.seriesId); + if (newPos) return { ...bar, valuePos: newPos.valuePos, length: newPos.length }; + return bar; + }); +} + +/** + * Computes per-bar initial animation origin positions for bar entrance animations. + * + * Bars are stacked from the baseline in their respective directions so they start at + * distinct, non-overlapping positions with the gap already applied: + * - Positive bars: stack rightward (horizontal) / upward (vertical) from the baseline. + * - Negative bars: stack leftward (horizontal) / downward (vertical) from the baseline. + * + * The bar closest to the baseline always gets index 0 and starts exactly at the baseline. + * + * @param bars - Array of bar items with final valuePos, length, and dataValue + * @param initialBarMinSizes - Per-bar initial sizes in pixels for entrance animation + * @param stackGap - Gap between adjacent bars in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @param layout - The layout of the chart + * @returns Array of origin positions (one per bar, parallel to input), all defaulting to baselinePx + */ +function getBarOrigins( + bars: BarData[], + initialBarMinSizes: number[], + stackGap: number, + baseline: number, + baselinePx: number, + layout: CartesianChartLayout, +): number[] { + const result = bars.map(() => baselinePx); + if (bars.length === 0 || initialBarMinSizes.every((size) => !size)) return result; + + const isPositive = (bar: BarData) => { + const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b); + return lo >= baseline && hi !== lo; + }; + + const isNegative = (bar: BarData) => { + const [lo, hi] = [...bar.dataValue].sort((a, b) => a - b); + return hi <= baseline && hi !== lo; + }; + + const positiveBars = bars + .map((bar, i) => ({ bar, i })) + .filter(({ bar }) => isPositive(bar)) + .sort( + (a, b) => + layout === 'vertical' + ? b.bar.valuePos - a.bar.valuePos // vertical: largest Y pixel = closest to bottom baseline + : a.bar.valuePos - b.bar.valuePos, // horizontal: smallest X pixel = closest to left baseline + ); + + if (layout === 'vertical') { + let currentPositive = baselinePx; + positiveBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + currentPositive -= initialSize; + result[i] = currentPositive; + if (idx < positiveBars.length - 1) { + currentPositive -= stackGap; + } + }); + } else { + let currentPositive = baselinePx; + positiveBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + result[i] = currentPositive; + currentPositive += initialSize; + if (idx < positiveBars.length - 1) { + currentPositive += stackGap; + } + }); + } + + const negativeBars = bars + .map((bar, i) => ({ bar, i })) + .filter(({ bar }) => isNegative(bar)) + .sort( + (a, b) => + layout === 'vertical' + ? a.bar.valuePos - b.bar.valuePos // vertical: smallest Y pixel = closest to top baseline + : b.bar.valuePos + b.bar.length - (a.bar.valuePos + a.bar.length), // horizontal: largest right edge = closest to baseline + ); + + if (layout === 'vertical') { + let currentNegative = baselinePx; + negativeBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + result[i] = currentNegative; + currentNegative += initialSize; + if (idx < negativeBars.length - 1) { + currentNegative += stackGap; + } + }); + } else { + let currentNegative = baselinePx; + negativeBars.forEach(({ i }, idx) => { + const initialSize = initialBarMinSizes[i] ?? 0; + currentNegative -= initialSize; + result[i] = currentNegative; + if (idx < negativeBars.length - 1) { + currentNegative -= stackGap; + } + }); + } + + return result; +} + +/** + * Computes stack clip origin [start, end] that covers the bounding box + * of all bars at their stacked starting positions (as computed by `getBarOrigins`). + * + * This is passed to `DefaultBarStack` so the clip animation starts in sync with the + * individual bar animations — no bars leak outside the clip on frame 0. + * + * @param barOrigins - Per-bar initial origins from `getBarOrigins` + * @param barMinSizes - Per-bar minimum sizes in pixels (or a uniform value) + * @returns [originStart, originEnd] or undefined when barMinSize is 0 / no bars + */ +export function getStackOrigin( + barOrigins: number[], + barMinSizes: number[] | number, +): [number, number] | undefined { + if (barOrigins.length === 0) return undefined; + const minSizes = Array.isArray(barMinSizes) ? barMinSizes : barOrigins.map(() => barMinSizes); + + let rangeStart = Number.POSITIVE_INFINITY; + let rangeEnd = Number.NEGATIVE_INFINITY; + + for (let i = 0; i < barOrigins.length; i++) { + const minSize = minSizes[i] ?? 0; + if (minSize <= 0) continue; + + const barStart = barOrigins[i]; + const barEnd = barStart + minSize; + rangeStart = Math.min(rangeStart, barStart, barEnd); + rangeEnd = Math.max(rangeEnd, barStart, barEnd); + } + + if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd)) return undefined; + return [rangeStart, rangeEnd]; +} + +function getInitialBarMinSizes( + bars: BarData[], + barMinSize: number | undefined, + stackMinSize: number | undefined, +): number[] { + const perBarMinFromBarMinSize = barMinSize ?? 0; + if (bars.length === 0) return []; + if (!stackMinSize) { + return bars.map(() => perBarMinFromBarMinSize); + } + + const totalBarLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const perBarMinFromStack = totalBarLength + ? bars.map((bar) => (stackMinSize * bar.length) / totalBarLength) + : bars.map(() => stackMinSize / bars.length); + + return perBarMinFromStack.map((stackMin) => Math.max(perBarMinFromBarMinSize, stackMin)); +} + +/** + * Computes the initial clip rect used for stack enter animations. + */ +export function getStackInitialClipRect( + stackRect: Rect, + layout: CartesianChartLayout, + origin?: number | [number, number], +): Rect { + const { x, y, width, height } = stackRect; + + if (Array.isArray(origin)) { + const [originStart, originEnd] = origin; + if (layout === 'vertical') { + return { x, y: originStart, width, height: originEnd - originStart }; + } + return { x: originStart, y, width: originEnd - originStart, height }; + } + + const initialSize = 1; + if (layout === 'vertical') { + const valueBaseline = origin ?? y + height; + return { x, y: valueBaseline, width, height: initialSize }; + } + + const valueBaseline = origin ?? x; + return { x: valueBaseline, y, width: initialSize, height }; +} + +/** + * Scales a stack of bars up so the total stack extent meets `stackMinSize`. + * For a single bar, the bar is expanded away from the baseline. + * For multiple bars, all bars are scaled proportionally, preserving relative gaps. + * + * @param bars - Array of bar items with current valuePos and length + * @param stackMinSize - Minimum stack size in pixels + * @param stackSize - Current total pixel extent of the stack + * @param stackBounds - Current bounding rect of the stack + * @param layout - The layout of the chart + * @param indexPos - Pixel position along the categorical (index) axis + * @param thickness - Bar thickness in pixels + * @param baseline - Value-axis baseline in data space + * @param baselinePx - Pixel position of the value-axis baseline on the value axis + * @returns Updated bars and stackBounds; unchanged if stackSize >= stackMinSize + */ +function applyStackMinSize( + bars: BarData[], + stackMinSize: number, + stackSize: number, + stackBounds: Rect, + layout: CartesianChartLayout, + indexPos: number, + thickness: number, + baseline: number, + baselinePx: number, +): { bars: BarData[]; stackBounds: Rect } { + if (!stackMinSize || stackSize >= stackMinSize) return { bars, stackBounds }; + if (bars.length === 0) return { bars, stackBounds }; + + let updatedBars = [...bars]; + let updatedBounds = { ...stackBounds }; + + if (bars.length === 1) { + const bar = bars[0]; + const sizeIncrease = stackMinSize - bar.length; + const [bottom, top] = [...bar.dataValue].sort((a, b) => a - b); + + let newValuePos: number; + const newLength = stackMinSize; + + if (bottom >= baseline && top !== bottom) { + // Bar is on the positive side: vertical→expands upward (↑), horizontal→expands rightward (→) + newValuePos = layout === 'vertical' ? bar.valuePos - sizeIncrease : bar.valuePos; + } else if (top <= baseline && top !== bottom) { + // Bar is on the negative side: vertical→expands downward (↓), horizontal→expands leftward (←) + newValuePos = layout === 'vertical' ? bar.valuePos : bar.valuePos - sizeIncrease; + } else { + // Bar spans baseline or is zero: expand equally in both directions + newValuePos = bar.valuePos - sizeIncrease / 2; + } + + updatedBars = [{ ...bar, valuePos: newValuePos, length: newLength }]; + updatedBounds = { + x: layout === 'vertical' ? indexPos : newValuePos, + y: layout === 'vertical' ? newValuePos : indexPos, + width: layout === 'vertical' ? thickness : newLength, + height: layout === 'vertical' ? newLength : thickness, + }; + } else { + const totalBarLength = bars.reduce((sum, bar) => sum + bar.length, 0); + const totalGapLength = stackSize - totalBarLength; + const requiredBarLength = stackMinSize - totalGapLength; + const barScaleFactor = requiredBarLength / totalBarLength; + + const sortedBars = [...bars].sort((a, b) => a.valuePos - b.valuePos); + + // For vertical: positive bars are above baseline (smaller Y), negative bars are below (larger Y) + // For horizontal: positive bars are right of baseline (larger X), negative bars are left (smaller X) + const barsOnPositiveSide = + layout === 'vertical' + ? sortedBars.filter((bar) => bar.valuePos + bar.length <= baselinePx) + : sortedBars.filter((bar) => bar.valuePos >= baselinePx); + const barsOnNegativeSide = + layout === 'vertical' + ? sortedBars.filter((bar) => bar.valuePos >= baselinePx) + : sortedBars.filter((bar) => bar.valuePos + bar.length <= baselinePx); + + const newPositions = new Map(); + + if (layout === 'vertical') { + // Stack from baseline upward (decreasing valuePos) for positive bars + let currentPos = baselinePx; + for (let i = barsOnPositiveSide.length - 1; i >= 0; i--) { + const bar = barsOnPositiveSide[i]; + const newLength = bar.length * barScaleFactor; + const newValuePos = currentPos - newLength; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: newLength }); + if (i > 0) { + const nextBar = barsOnPositiveSide[i - 1]; + const originalGap = bar.valuePos - (nextBar.valuePos + nextBar.length); + currentPos = newValuePos - originalGap; + } + } + // Stack from baseline downward (increasing valuePos) for negative bars + let currentPosBelow = baselinePx; + for (let i = 0; i < barsOnNegativeSide.length; i++) { + const bar = barsOnNegativeSide[i]; + const newLength = bar.length * barScaleFactor; + newPositions.set(bar.seriesId, { valuePos: currentPosBelow, length: newLength }); + if (i < barsOnNegativeSide.length - 1) { + const nextBar = barsOnNegativeSide[i + 1]; + const originalGap = nextBar.valuePos - (bar.valuePos + bar.length); + currentPosBelow = currentPosBelow + newLength + originalGap; + } + } + } else { + // Stack from baseline rightward (increasing valuePos) for positive bars + let currentPos = baselinePx; + for (let i = 0; i < barsOnPositiveSide.length; i++) { + const bar = barsOnPositiveSide[i]; + const newLength = bar.length * barScaleFactor; + newPositions.set(bar.seriesId, { valuePos: currentPos, length: newLength }); + if (i < barsOnPositiveSide.length - 1) { + const nextBar = barsOnPositiveSide[i + 1]; + const originalGap = nextBar.valuePos - (bar.valuePos + bar.length); + currentPos = currentPos + newLength + originalGap; + } + } + // Stack from baseline leftward (decreasing valuePos) for negative bars + let currentPosLeft = baselinePx; + for (let i = barsOnNegativeSide.length - 1; i >= 0; i--) { + const bar = barsOnNegativeSide[i]; + const newLength = bar.length * barScaleFactor; + const newValuePos = currentPosLeft - newLength; + newPositions.set(bar.seriesId, { valuePos: newValuePos, length: newLength }); + if (i > 0) { + const nextBar = barsOnNegativeSide[i - 1]; + const originalGap = bar.valuePos - (nextBar.valuePos + nextBar.length); + currentPosLeft = newValuePos - originalGap; + } + } + } + + updatedBars = bars.map((bar) => { + const newPos = newPositions.get(bar.seriesId); + if (!newPos) return bar; + return { ...bar, length: newPos.length, valuePos: newPos.valuePos }; + }); + + const newMinValuePos = Math.min(...updatedBars.map((bar) => bar.valuePos)); + const newMaxValuePos = Math.max(...updatedBars.map((bar) => bar.valuePos + bar.length)); + + updatedBounds = { + x: layout === 'vertical' ? indexPos : newMinValuePos, + y: layout === 'vertical' ? newMinValuePos : indexPos, + width: layout === 'vertical' ? thickness : newMaxValuePos - newMinValuePos, + height: layout === 'vertical' ? newMaxValuePos - newMinValuePos : thickness, + }; + } + + return { bars: updatedBars, stackBounds: updatedBounds }; +} + +/** + * Applies border-radius flags to a sorted stack of bars. + * + * Faces at the outer edges of the stack remain rounded; faces where two bars + * touch internally are squared. When `stackGap` is non-zero every face keeps + * its rounded corner because all bars are visually separated. + * + * @param bars - Bars with `roundTop`/`roundBottom` flags and position data + * @param layout - The layout of the chart + * @param stackGap - Pixel gap between adjacent bars (non-zero ⇒ all faces stay rounded) + * @returns New array of bars with corrected `roundTop`/`roundBottom` flags + */ +function applyBorderRadiusLogic( + bars: BarData[], + layout: CartesianChartLayout, + stackGap: number | undefined, +): BarData[] { + if (bars.length === 0) return bars; + + // Sort from "lower coordinate" face to "higher coordinate" face along the value axis: + // Vertical → descending valuePos (largest Y first = closest to baseline) + // Horizontal → ascending valuePos (smallest X first = closest to baseline) + const sortedBars = + layout === 'vertical' + ? [...bars].sort((a, b) => b.valuePos - a.valuePos) + : [...bars].sort((a, b) => a.valuePos - b.valuePos); + + return sortedBars.map((a, index) => { + const barBefore = index > 0 ? sortedBars[index - 1] : null; + const barAfter = index < sortedBars.length - 1 ? sortedBars[index + 1] : null; + + // shouldRoundLower: face with the smaller coordinate (top in vertical, left in horizontal) + const shouldRoundLower = + (layout === 'vertical' ? index === sortedBars.length - 1 : index === 0) || + Boolean(a.shouldApplyGap && stackGap) || + (!a.shouldApplyGap && + barAfter !== null && + barAfter.valuePos + barAfter.length !== a.valuePos); + + // shouldRoundHigher: face with the larger coordinate (bottom in vertical, right in horizontal) + const shouldRoundHigher = + (layout === 'vertical' ? index === 0 : index === sortedBars.length - 1) || + Boolean(a.shouldApplyGap && stackGap) || + (!a.shouldApplyGap && barBefore !== null && barBefore.valuePos !== a.valuePos + a.length); + + return { + ...a, + roundTop: Boolean( + a.roundTop && (layout === 'vertical' ? shouldRoundLower : shouldRoundHigher), + ), + roundBottom: Boolean( + a.roundBottom && (layout === 'vertical' ? shouldRoundHigher : shouldRoundLower), + ), + }; + }); +} + +/** + * Threshold for treating a position as touching the baseline. + * Positions within this distance are considered at the baseline for rounding purposes. + */ +export const EPSILON = 1e-4; + +/** + * Computes and clamps the value-axis baseline position in pixels. + * + * When `baseline` (data space) is omitted, the baseline is chosen heuristically from the scale domain: + * - If the full domain is positive, use domain min. + * - If the full domain is negative, use domain max. + * - If the domain crosses zero, use `0`. + * When `baseline` is set, that value is used as the data-space baseline instead. + * + * @param valueScale - Scale for the value axis + * @param stackRect - Bounding rect of the stack in pixels + * @param layout - Chart layout + * @param baseline - Optional value-axis baseline in data space + */ +export function getBaselinePx( + valueScale: ChartScaleFunction, + stackRect: Rect, + layout: CartesianChartLayout, + baseline?: number, +): number { + const [domainMin, domainMax] = valueScale.domain(); + const baselineInData = baseline ?? (domainMin >= 0 ? domainMin : domainMax <= 0 ? domainMax : 0); + const baselinePos = valueScale(baselineInData); + + if (layout === 'vertical') { + return Math.max( + stackRect.y, + Math.min(baselinePos ?? stackRect.y + stackRect.height, stackRect.y + stackRect.height), + ); + } + + return Math.max(stackRect.x, Math.min(baselinePos ?? stackRect.x, stackRect.x + stackRect.width)); +} + +type SeriesGradientEntry = { + seriesId: string; + gradient: GradientDefinition; + scale: ChartScaleFunction; + stops: GradientStop[]; +} | null; + +function getStackBoundsForLayout( + layout: CartesianChartLayout, + indexPos: number, + thickness: number, + minValuePos: number, + stackSize: number, +): Rect { + if (layout === 'vertical') { + return { x: indexPos, y: minValuePos, width: thickness, height: stackSize }; + } + return { x: minValuePos, y: indexPos, width: stackSize, height: thickness }; +} + +function getStackSizeForLayout(layout: CartesianChartLayout, stackRect: Rect): number { + return layout === 'vertical' ? stackRect.height : stackRect.width; +} + +/** + * Computes the positioned bar entries and bounding rect for a single stack at one category index. + * + * This is the pure computation extracted from `BarStack`'s `useMemo` so it can be tested + * independently and reused across contexts. + * + * @param params.series - Series configs for this stack + * @param params.seriesData - Stacked data for each series, keyed by series id + * @param params.categoryIndex - Index of the category being rendered + * @param params.indexPos - Pixel position along the categorical axis + * @param params.thickness - Bar thickness in pixels + * @param params.valueScale - Scale function for the value axis + * @param params.seriesGradients - Precomputed gradient configs per series (null entries are skipped) + * @param params.roundBaseline - Whether to round the face touching the baseline + * @param params.layout - The layout of the chart + * @param params.baseline - Value-axis baseline in data space + * @param params.baselinePx - Pixel position of the value-axis baseline on the value axis + * @param params.stackGap - Gap between adjacent bars in pixels + * @param params.barMinSize - Minimum individual bar size in pixels + * @param params.stackMinSize - Minimum total stack size in pixels + * @param params.defaultFill - Fallback fill color when a series has no color or gradient + * @returns Positioned bar entries and the stack's bounding rect + */ +export function getBars(params: { + series: BarSeries[]; + seriesData: Record; + categoryIndex: number; + categoryValue: number; + indexPos: number; + thickness: number; + valueScale: ChartScaleFunction; + seriesGradients: SeriesGradientEntry[]; + roundBaseline?: boolean; + layout: CartesianChartLayout; + baseline?: number; + baselinePx: number; + stackGap?: number; + barMinSize?: number; + stackMinSize?: number; + defaultFill: string; + borderRadius?: number; + defaultFillOpacity?: number; + defaultStroke?: string; + defaultStrokeWidth?: number; + defaultBarComponent?: BarComponent; +}) { + const { + series, + seriesData, + categoryIndex, + categoryValue, + indexPos, + thickness, + valueScale, + seriesGradients, + roundBaseline, + layout, + baseline: baselineParam, + baselinePx, + stackGap, + barMinSize, + stackMinSize, + defaultFill, + borderRadius, + defaultFillOpacity, + defaultStroke, + defaultStrokeWidth, + defaultBarComponent, + } = params; + + const baseline = baselineParam ?? 0; + + let allBars: BarData[] = []; + + series.forEach((s) => { + const data = seriesData[s.id]; + if (!data) return; + + const value = data[categoryIndex]; + if (value === null || value === undefined) return; + + const originalData = s.data; + const originalValue = originalData?.[categoryIndex]; + // Only apply gap logic if the original data wasn't tuple format + const shouldApplyGap = !Array.isArray(originalValue); + + // Sort to be in ascending order + const [bottom, top] = [...value].sort((a, b) => a - b); + + const edgeBottom = valueScale(bottom) ?? baselinePx; + const edgeTop = valueScale(top) ?? baselinePx; + + // In horizontal layout: roundTop is Right (edgeTop), roundBottom is Left (edgeBottom) + // getBarPath already handles the mapping of roundTop/roundBottom to coordinates. + // Use data-space baseline so faces at the axis baseline stay square when roundBaseline is off + // (pixel gaps after stackGap can otherwise trip the pixel-only epsilon check). + const roundTop = + roundBaseline || + (Math.abs(top - baseline) >= EPSILON && Math.abs(edgeTop - baselinePx) >= EPSILON); + const roundBottom = + roundBaseline || + (Math.abs(bottom - baseline) >= EPSILON && Math.abs(edgeBottom - baselinePx) >= EPSILON); + + // Calculate length (measured along the value axis) + const length = Math.abs(edgeBottom - edgeTop); + const valuePos = Math.min(edgeBottom, edgeTop); + + // Skip bars that would have zero or negative height + if (length <= 0) return; + + let barFill = s.color ?? defaultFill; + + // Evaluate gradient if provided (using precomputed stops) + const seriesGradientConfig = seriesGradients.find((g) => g?.seriesId === s.id); + if (seriesGradientConfig && originalValue !== null && originalValue !== undefined) { + const axis = seriesGradientConfig.gradient.axis ?? 'y'; + + let evalValue: number; + if (axis === 'x') { + // X-axis gradient: In vertical it's the index, in horizontal it's the value. + evalValue = + layout === 'vertical' + ? categoryIndex + : Array.isArray(originalValue) + ? originalValue[1] + : originalValue; + } else { + // Y-axis gradient: In vertical it's the value, in horizontal it's the index. + evalValue = + layout === 'vertical' + ? Array.isArray(originalValue) + ? originalValue[1] + : originalValue + : categoryIndex; + } + + const evaluatedColor = evaluateGradientAtValue( + seriesGradientConfig.stops, + evalValue, + seriesGradientConfig.scale, + ); + if (evaluatedColor) { + barFill = evaluatedColor; + } + } + + allBars.push({ + seriesId: s.id, + valuePos, + length, + dataValue: value, + fill: barFill, + roundTop, + roundBottom, + shouldApplyGap, + BarComponent: s.BarComponent, + x: 0, + y: 0, + width: 0, + height: 0, + origin: 0, + }); + }); + + // Apply proportional gap distribution to maintain total stack length + if (stackGap && allBars.length > 1) { + allBars = applyStackGap(allBars, stackGap, layout, baseline, baselinePx); + } + + // Apply barMinSize constraints + if (barMinSize) { + allBars = applyBarMinSize(allBars, barMinSize, baseline, baselinePx, layout); + } + + allBars = applyBorderRadiusLogic(allBars, layout, stackGap); + + // Apply stackMinSize constraints + if (stackMinSize && allBars.length > 0) { + const minValuePos = Math.min(...allBars.map((bar) => bar.valuePos)); + const maxValuePos = Math.max(...allBars.map((bar) => bar.valuePos + bar.length)); + const stackSize = maxValuePos - minValuePos; + const stackBounds = getStackBoundsForLayout( + layout, + indexPos, + thickness, + minValuePos, + stackSize, + ); + + const result = applyStackMinSize( + allBars, + stackMinSize, + stackSize, + stackBounds, + layout, + indexPos, + thickness, + baseline, + baselinePx, + ); + allBars = result.bars; + + // Reapply border radius logic only if we actually scaled + const newStackSize = getStackSizeForLayout(layout, result.stackBounds); + if (newStackSize < stackMinSize) { + allBars = applyBorderRadiusLogic(allBars, layout, stackGap); + } + } + + const initialBarMinSizes = getInitialBarMinSizes(allBars, barMinSize, stackMinSize); + const barOrigins = getBarOrigins( + allBars, + initialBarMinSizes, + stackGap ?? 0, + baseline, + baselinePx, + layout, + ); + + return allBars.map((bar, i) => ({ + ...bar, + x: layout === 'vertical' ? indexPos : bar.valuePos, + y: layout === 'vertical' ? bar.valuePos : indexPos, + width: layout === 'vertical' ? thickness : bar.length, + height: layout === 'vertical' ? bar.length : thickness, + dataX: layout === 'vertical' ? categoryValue : bar.dataValue, + dataY: layout === 'vertical' ? bar.dataValue : categoryValue, + origin: barOrigins[i], + borderRadius, + fillOpacity: defaultFillOpacity, + stroke: defaultStroke, + strokeWidth: defaultStrokeWidth, + minSize: initialBarMinSizes[i], + BarComponent: bar.BarComponent || defaultBarComponent, + })); +} diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts index b80dc5a621..70c89176ef 100644 --- a/packages/web-visualization/src/chart/utils/chart.ts +++ b/packages/web-visualization/src/chart/utils/chart.ts @@ -1,9 +1,26 @@ import { stack as d3Stack, stackOffsetDiverging, stackOrderNone } from 'd3-shape'; +import { type CartesianAxisConfigProps, defaultAxisId } from './axis'; +import type { CartesianChartLayout } from './context'; import type { GradientDefinition } from './gradient'; export const defaultStackId = 'DEFAULT_STACK_ID'; +/** + * Shape variants available for legend items. + */ +export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill'; + +/** + * Shape for legend items. Can be a preset variant or a custom ReactNode. + */ +export type LegendShape = LegendShapeVariant | React.ReactNode; + +/** + * Position of the legend relative to the chart. + */ +export type LegendPosition = 'top' | 'bottom' | 'left' | 'right'; + export type AxisBounds = { min: number; max: number; @@ -46,9 +63,16 @@ export type Series = { * Takes precedence over color except for scrubber beacon labels. */ gradient?: GradientDefinition; + /** + * Id of the x-axis this series uses. + * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'horizontal'. Vertical layout uses a single x-axis. + */ + xAxisId?: string; /** * Id of the y-axis this series uses. * Defaults to defaultAxisId if not specified. + * @note Only used for axis selection when layout is 'vertical'. Horizontal layout supports a single y-axis. */ yAxisId?: string; /** @@ -57,6 +81,12 @@ export type Series = { * If not specified, the series will not be stacked. */ stackId?: string; + /** + * Shape of the legend item for this series. + * Can be a preset shape variant or a custom ReactNode. + * @default 'circle' + */ + legendShape?: LegendShape; }; /** @@ -90,15 +120,34 @@ export const getChartDomain = ( }; /** - * Creates a composite stack key that includes both stack ID and y-axis ID. - * This ensures series with different y-scales don't get stacked together. + * Creates a composite stack key that includes stack ID and axis IDs. + * This ensures series with different scales don't get stacked together. */ const createStackKey = (series: Series): string | undefined => { if (series.stackId === undefined) return undefined; - // Include y-axis ID to prevent cross-scale stacking + // Include axis IDs to prevent cross-scale stacking + const xAxisId = series.xAxisId || 'default'; const yAxisId = series.yAxisId || 'default'; - return `${series.stackId}:${yAxisId}`; + return `${series.stackId}:${xAxisId}:${yAxisId}`; +}; + +/** + * Get the baseline for a series on the value axis for a series (stacking and plain numeric points). + * @returns The baseline for the series on the value axis, or `0` if none. + */ +const getValueAxisBaselineForSeries = ( + layout: CartesianChartLayout, + series: Series, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], +): number => { + if (layout === 'horizontal') { + const seriesAxisId = series.xAxisId ?? defaultAxisId; + return xAxisConfigs.find((a) => a.id === seriesAxisId)?.baseline ?? 0; + } + const seriesAxisId = series.yAxisId ?? defaultAxisId; + return yAxisConfigs.find((a) => a.id === seriesAxisId)?.baseline ?? 0; }; /** @@ -106,16 +155,38 @@ const createStackKey = (series: Series): string | undefined => { * Returns a map of series ID to transformed [baseline, value] tuples. * * @param series - Array of series with potential stack properties + * @param layout - When set with axis configs, value-axis baselines are resolved for stacking * @returns Map of series ID to stacked data arrays */ export const getStackedSeriesData = ( series: Series[], + layout: CartesianChartLayout, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], ): Map> => { const stackedDataMap = new Map>(); const numericStackGroups = new Map(); const individualSeries: typeof series = []; + const normalizeSeriesData = (seriesItem: Series): Array<[number, number] | null> | undefined => { + if (!seriesItem.data) return; + + const baseline = getValueAxisBaselineForSeries(layout, seriesItem, xAxisConfigs, yAxisConfigs); + + return seriesItem.data.map((val) => { + if (val === null) return null; + + if (Array.isArray(val)) { + return val as [number, number]; + } + + if (typeof val === 'number') return [baseline, val]; + + return null; + }); + }; + series.forEach((s) => { const stackKey = createStackKey(s); const hasTupleData = s.data?.some((val) => Array.isArray(val)); @@ -131,37 +202,37 @@ export const getStackedSeriesData = ( }); individualSeries.forEach((s) => { - if (!s.data) return; - - const normalizedData: Array<[number, number] | null> = s.data.map((val) => { - if (val === null) return null; - - if (Array.isArray(val)) { - return val as [number, number]; - } - - if (typeof val === 'number') { - return [0, val]; - } - - return null; - }); - + const normalizedData = normalizeSeriesData(s); + if (!normalizedData) return; stackedDataMap.set(s.id, normalizedData); }); - numericStackGroups.forEach((groupSeries, stackKey) => { + numericStackGroups.forEach((groupSeries) => { + // A lone series with stackId should still behave like a non-stacked series. + if (groupSeries.length < 2) { + groupSeries.forEach((singleSeries) => { + const normalizedData = normalizeSeriesData(singleSeries); + if (!normalizedData) return; + stackedDataMap.set(singleSeries.id, normalizedData); + }); + return; + } + const maxLength = Math.max(...groupSeries.map((s) => s.data?.length || 0)); if (maxLength === 0) return; + const first = groupSeries[0]; + const groupBaseline = getValueAxisBaselineForSeries(layout, first, xAxisConfigs, yAxisConfigs); + const dataset: Array> = new Array(maxLength) .fill(undefined) .map((_, i) => { const row: Record = {}; for (const s of groupSeries) { const val = s.data?.[i]; - const num = typeof val === 'number' ? val : 0; + // Stack around baseline by translating values into baseline-relative deltas. + const num = typeof val === 'number' ? val - groupBaseline : 0; row[s.id] = num; } return row; @@ -176,8 +247,8 @@ export const getStackedSeriesData = ( stackedSeries.forEach((layer, layerIndex) => { const seriesId = keys[layerIndex]; const stackedData: Array<[number, number] | null> = layer.map(([bottom, top]) => [ - bottom, - top, + bottom + groupBaseline, + top + groupBaseline, ]); stackedDataMap.set(seriesId, stackedData); }); @@ -204,7 +275,7 @@ export const getLineData = ( if (Array.isArray(firstNonNull)) { return data.map((d) => { if (d === null) return null; - if (Array.isArray(d)) return d.at(-1) ?? null; + if (Array.isArray(d)) return d[d.length - 1] ?? null; return d as number; }); } @@ -220,6 +291,9 @@ export const getLineData = ( */ export const getChartRange = ( series: Series[], + layout: CartesianChartLayout, + xAxisConfigs: CartesianAxisConfigProps[], + yAxisConfigs: CartesianAxisConfigProps[], min?: number, max?: number, ): Partial => { @@ -251,11 +325,11 @@ export const getChartRange = ( if (hasStacks) { // Get stacked data using the shared function - const stackedDataMap = getStackedSeriesData(series); + const stackedDataMap = getStackedSeriesData(series, layout, xAxisConfigs, yAxisConfigs); // Find the extreme values from the stacked data - let stackedMax = 0; - let stackedMin = 0; + let stackedMax = -Infinity; + let stackedMin = Infinity; stackedDataMap.forEach((stackedData) => { stackedData.forEach((point) => { @@ -268,8 +342,8 @@ export const getChartRange = ( }); // Don't add padding - let D3's nice() function handle axis padding - if (range.min === undefined) range.min = Math.min(0, stackedMin); - if (range.max === undefined) range.max = Math.max(0, stackedMax); + if (range.min === undefined) range.min = stackedMin === Infinity ? 0 : stackedMin; + if (range.max === undefined) range.max = stackedMax === -Infinity ? 0 : stackedMax; } else { // No stacking, calculate range from raw values const allValues: number[] = []; @@ -306,13 +380,27 @@ export type ChartInset = { right: number; }; -export const defaultChartInset: ChartInset = { +export const defaultVerticalLayoutChartInset: ChartInset = { top: 32, left: 16, bottom: 16, right: 16, }; +export const defaultHorizontalLayoutChartInset: ChartInset = { + top: 16, + left: 16, + bottom: 16, + right: 48, +}; + +/** + * @deprecated Use `defaultVerticalLayoutChartInset` for vertical layout charts or. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 + * `defaultHorizontalLayoutChartInset` for horizontal layout charts. + */ +export const defaultChartInset: ChartInset = defaultVerticalLayoutChartInset; + /** * Normalize padding to include all sides with a value. * @param padding - The padding to get. diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts index c976ac02b8..5044251d7f 100644 --- a/packages/web-visualization/src/chart/utils/context.ts +++ b/packages/web-visualization/src/chart/utils/context.ts @@ -1,15 +1,30 @@ import { createContext, useContext } from 'react'; import type { Rect } from '@coinbase/cds-common/types'; -import type { AxisConfig } from './axis'; +import type { CartesianAxisConfig } from './axis'; import type { Series } from './chart'; import type { ChartScaleFunction } from './scale'; +/** + * Chart layout for Cartesian charts. + * Describes the direction bars/areas grow. + * - 'vertical': Bars grow vertically (up/down). X is category axis, Y is value axis. + * - 'horizontal': Bars grow horizontally (left/right). Y is category axis, X is value axis. + */ +export type CartesianChartLayout = 'horizontal' | 'vertical'; + /** * Context value for Cartesian (X/Y) coordinate charts. * Contains axis-specific methods and properties for rectangular coordinate systems. */ export type CartesianChartContextValue = { + /** + * Chart layout - describes the direction bars/areas grow. + * @default 'vertical' + * - 'vertical': Bars grow vertically (up/down). X is category axis, Y is value axis. + * - 'horizontal': Bars grow horizontally (left/right). Y is category axis, X is value axis. + */ + layout: CartesianChartLayout; /** * The series data for the chart. */ @@ -38,18 +53,20 @@ export type CartesianChartContextValue = { */ height: number; /** - * Get x-axis configuration. + * Get x-axis configuration by ID. + * @param id - The axis ID. Defaults to defaultAxisId. */ - getXAxis: () => AxisConfig | undefined; + getXAxis: (id?: string) => CartesianAxisConfig | undefined; /** * Get y-axis configuration by ID. * @param id - The axis ID. Defaults to defaultAxisId. */ - getYAxis: (id?: string) => AxisConfig | undefined; + getYAxis: (id?: string) => CartesianAxisConfig | undefined; /** - * Get x-axis scale function. + * Get x-axis scale function by ID. + * @param id - The axis ID. Defaults to defaultAxisId. */ - getXScale: () => ChartScaleFunction | undefined; + getXScale: (id?: string) => ChartScaleFunction | undefined; /** * Get y-axis scale function by ID. * @param id - The axis ID. Defaults to defaultAxisId. diff --git a/packages/web-visualization/src/chart/utils/gradient.ts b/packages/web-visualization/src/chart/utils/gradient.ts index 96cc946772..ac1ca034d1 100644 --- a/packages/web-visualization/src/chart/utils/gradient.ts +++ b/packages/web-visualization/src/chart/utils/gradient.ts @@ -1,4 +1,5 @@ import type { AxisBounds } from './chart'; +import type { CartesianChartLayout } from './context'; import { type ChartScaleFunction, isCategoricalScale } from './scale'; /** @@ -22,7 +23,7 @@ export type GradientStop = { export type GradientDefinition = { /** * Axis that the gradient maps to. - * @default 'y' + * @default 'y' for vertical layout, 'x' for horizontal layout */ axis?: 'x' | 'y'; /** @@ -32,6 +33,16 @@ export type GradientDefinition = { stops: GradientStop[] | ((domain: AxisBounds) => GradientStop[]); }; +/** + * Resolves the axis used for gradient processing. + */ +export const getGradientAxis = ( + gradient: Pick, + layout: CartesianChartLayout, +): 'x' | 'y' => { + return gradient.axis ?? (layout === 'horizontal' ? 'x' : 'y'); +}; + /** * Resolves gradient stops, handling both static arrays and function forms. * When stops is a function, calls it with the domain bounds. @@ -175,9 +186,10 @@ export const evaluateGradientAtValue = ( * Processes a GradientDefinition into a renderable GradientConfig. * Supports both numeric scales (linear, log) and categorical scales (band). * - * @param gradient - GradientDefinition configuration (required) - * @param xScale - X-axis scale (required) - * @param yScale - Y-axis scale (required) + * @param gradient - GradientDefinition configuration + * @param xScale - X-axis scale + * @param yScale - Y-axis scale + * @param layout - Chart layout * @returns GradientConfig or null if gradient processing fails * * @example @@ -202,11 +214,13 @@ export const getGradientConfig = ( gradient: GradientDefinition, xScale: ChartScaleFunction, yScale: ChartScaleFunction, + layout: CartesianChartLayout = 'vertical', ): GradientStop[] | undefined => { if (!gradient) return; // Get the scale based on axis - const scale = gradient.axis === 'x' ? xScale : yScale; + const axis = getGradientAxis(gradient, layout); + const scale = axis === 'x' ? xScale : yScale; if (!scale) return; // Extract domain from scale @@ -258,7 +272,8 @@ export const getBaseline = (axisBounds: AxisBounds, baseline: number = 0): numbe * @param fill - The color to use for the gradient * @param peakOpacity - Opacity at the peak of the gradient * @param baselineOpacity - Opacity at the baseline - * @returns A gradient definition with y-axis stops in ascending order + * @param axis - The axis the gradient maps to ('y' for vertical, 'x' for horizontal layout) + * @returns A gradient definition with stops in ascending order */ export const createGradient = ( axisBounds: AxisBounds, @@ -266,6 +281,7 @@ export const createGradient = ( fill: string, peakOpacity: number, baselineOpacity: number, + axis: 'x' | 'y' = 'y', ): GradientDefinition => { const { min, max } = axisBounds; @@ -274,7 +290,7 @@ export const createGradient = ( if (lowerBound < baselineValue && baselineValue < upperBound) { return { - axis: 'y', + axis, stops: [ { offset: lowerBound, color: fill, opacity: peakOpacity }, { offset: baselineValue, color: fill, opacity: baselineOpacity }, @@ -286,7 +302,7 @@ export const createGradient = ( const peakValue = Math.abs(min - baselineValue) > Math.abs(max - baselineValue) ? min : max; return { - axis: 'y', + axis, stops: [ { offset: peakValue, color: fill, opacity: peakOpacity }, { offset: baselineValue, color: fill, opacity: baselineOpacity }, diff --git a/packages/web-visualization/src/chart/utils/path.ts b/packages/web-visualization/src/chart/utils/path.ts index 9932df4d9e..6822022a84 100644 --- a/packages/web-visualization/src/chart/utils/path.ts +++ b/packages/web-visualization/src/chart/utils/path.ts @@ -1,20 +1,33 @@ import { area as d3Area, curveBumpX, + curveBumpY, curveCatmullRom, curveLinear, curveLinearClosed, curveMonotoneX, + curveMonotoneY, curveNatural, curveStep, curveStepAfter, curveStepBefore, line as d3Line, } from 'd3-shape'; +import type { Transition } from 'framer-motion'; -import { projectPoint, projectPoints } from './point'; +import type { CartesianChartLayout } from './context'; +import { getPointOnScale, projectPoint, projectPoints } from './point'; import { type ChartScaleFunction, isCategoricalScale } from './scale'; +/** + * Default enter transition for path-based components (Line, Area). + * `{ type: 'tween', duration: 0.5 }` + */ +export const defaultPathEnterTransition: Transition = { + type: 'tween', + duration: 0.5, +}; + export type ChartPathCurveType = | 'bump' | 'catmullRom' @@ -30,14 +43,20 @@ export type ChartPathCurveType = * Get the d3 curve function for a path. * See https://d3js.org/d3-shape/curve * @param curve - The curve type. Defaults to 'linear'. + * @param layout - The chart layout. Defaults to 'vertical'. * @returns The d3 curve function. */ -export const getPathCurveFunction = (curve: ChartPathCurveType = 'linear') => { +export const getPathCurveFunction = ( + curve: ChartPathCurveType = 'linear', + layout: CartesianChartLayout = 'vertical', +) => { switch (curve) { case 'catmullRom': return curveCatmullRom; - case 'monotone': // When we support layout="vertical" this should dynamically switch to curveMonotoneY - return curveMonotoneX; + case 'monotone': + // For vertical layout, X is the independent axis (category/index), so use MonotoneX + // For horizontal layout, Y is the independent axis (category/index), so use MonotoneY + return layout !== 'horizontal' ? curveMonotoneX : curveMonotoneY; case 'natural': return curveNatural; case 'step': @@ -46,8 +65,10 @@ export const getPathCurveFunction = (curve: ChartPathCurveType = 'linear') => { return curveStepBefore; case 'stepAfter': return curveStepAfter; - case 'bump': // When we support layout="vertical" this should dynamically switch to curveBumpY - return curveBumpX; + case 'bump': + // For vertical layout, X is the independent axis (category/index), so use BumpX + // For horizontal layout, Y is the independent axis (category/index), so use BumpY + return layout !== 'horizontal' ? curveBumpX : curveBumpY; case 'linearClosed': return curveLinearClosed; case 'linear': @@ -71,26 +92,34 @@ export const getLinePath = ({ xScale, yScale, xData, + yData, connectNulls, + layout = 'vertical', }: { data: (number | null | { x: number; y: number })[]; curve?: ChartPathCurveType; xScale: ChartScaleFunction; yScale: ChartScaleFunction; xData?: number[]; + yData?: number[]; /** * When true, null values are skipped and the line connects across gaps. * By default, null values create gaps in the line. */ connectNulls?: boolean; + /** + * Chart layout. + * @default 'horizontal' + */ + layout?: CartesianChartLayout; }): string => { if (data.length === 0) { return ''; } - const curveFunction = getPathCurveFunction(curve); + const curveFunction = getPathCurveFunction(curve, layout); - const dataPoints = projectPoints({ data, xScale, yScale, xData }); + const dataPoints = projectPoints({ data, xScale, yScale, xData, yData, layout }); // When connectNulls is true, filter out null values before rendering // When false, use defined() to create gaps in the line @@ -133,29 +162,40 @@ export const getAreaPath = ({ xScale, yScale, xData, + yData, connectNulls, + layout = 'vertical', }: { data: (number | null)[] | Array<[number, number] | null>; xScale: ChartScaleFunction; yScale: ChartScaleFunction; curve: ChartPathCurveType; xData?: number[]; + yData?: number[]; /** * When true, null values are skipped and the area connects across gaps. * By default null values create gaps in the area. */ connectNulls?: boolean; + /** + * Chart layout. + * @default 'horizontal' + */ + layout?: CartesianChartLayout; }): string => { if (data.length === 0) { return ''; } - const curveFunction = getPathCurveFunction(curve); + const curveFunction = getPathCurveFunction(curve, layout); + const categoryAxisIsX = layout !== 'horizontal'; - const yDomain = yScale.domain(); - const yMin = Math.min(...yDomain); + // Determine baseline from the value scale + const valueScale = categoryAxisIsX ? yScale : xScale; + const domain = valueScale.domain(); + const min = Math.min(...domain); - const normalizedData: Array<[number, number] | null> = data.map((item, index) => { + const normalizedData: Array<[number, number] | null> = data.map((item) => { if (item === null) { return null; } @@ -168,7 +208,7 @@ export const getAreaPath = ({ } if (typeof item === 'number') { - return [yMin, item]; + return [min, item]; } return null; @@ -178,35 +218,31 @@ export const getAreaPath = ({ if (range === null) { return { x: 0, + y: 0, low: null, high: null, isValid: false, }; } - let xValue: number = index; - if (!isCategoricalScale(xScale) && xData && xData[index] !== undefined) { - xValue = xData[index]; + // Determine the position along the independent (index) axis + let indexValue: number = index; + const indexScale = categoryAxisIsX ? xScale : yScale; + const indexData = categoryAxisIsX ? xData : yData; + + if (!isCategoricalScale(indexScale) && indexData && indexData[index] !== undefined) { + indexValue = indexData[index]; } - const xPoint = projectPoint({ x: xValue, y: 0, xScale, yScale }); - const lowPoint = projectPoint({ - x: xValue, - y: range[0], - xScale, - yScale, - }); - const highPoint = projectPoint({ - x: xValue, - y: range[1], - xScale, - yScale, - }); + const pos = getPointOnScale(indexValue, indexScale); + const low = getPointOnScale(range[0], valueScale); + const high = getPointOnScale(range[1], valueScale); return { - x: xPoint.x, - low: lowPoint.y, - high: highPoint.y, + x: categoryAxisIsX ? pos : 0, + y: !categoryAxisIsX ? pos : 0, + low, + high, isValid: true, }; }); @@ -217,15 +253,27 @@ export const getAreaPath = ({ const areaGenerator = d3Area<{ x: number; + y: number; low: number | null; high: number | null; isValid: boolean; - }>() - .x((d) => d.x) - .y0((d) => d.low ?? 0) // Bottom boundary (low values), fallback to 0 - .y1((d) => d.high ?? 0) // Top boundary (high values), fallback to 0 + }>(); + + if (categoryAxisIsX) { + areaGenerator + .x((d) => d.x) + .y0((d) => d.low ?? 0) + .y1((d) => d.high ?? 0); + } else { + areaGenerator + .y((d) => d.y) + .x0((d) => d.low ?? 0) + .x1((d) => d.high ?? 0); + } + + areaGenerator .curve(curveFunction) - .defined((d) => connectNulls || (d.isValid && d.low != null && d.high != null)); // Only draw where both values exist + .defined((d) => connectNulls || (d.isValid && d.low != null && d.high != null)); const result = areaGenerator(filteredPoints); return result ?? ''; @@ -266,29 +314,30 @@ export const getBarPath = ( radius: number, roundTop: boolean, roundBottom: boolean, + layout: CartesianChartLayout = 'vertical', ): string => { + const isVerticalLayout = layout === 'vertical'; const roundBothSides = roundTop && roundBottom; const r = Math.min(radius, width / 2, roundBothSides ? height / 2 : height); - const topR = roundTop ? r : 0; - const bottomR = roundBottom ? r : 0; - - // Build path with selective rounding - let path = `M ${x + (roundTop ? r : 0)} ${y}`; - path += ` L ${x + width - topR} ${y}`; - - path += ` A ${topR} ${topR} 0 0 1 ${x + width} ${y + topR}`; - path += ` L ${x + width} ${y + height - bottomR}`; + const rTL = isVerticalLayout ? (roundTop ? r : 0) : roundBottom ? r : 0; + const rTR = isVerticalLayout ? (roundTop ? r : 0) : roundTop ? r : 0; + const rBR = isVerticalLayout ? (roundBottom ? r : 0) : roundTop ? r : 0; + const rBL = isVerticalLayout ? (roundBottom ? r : 0) : roundBottom ? r : 0; - path += ` A ${bottomR} ${bottomR} 0 0 1 ${x + width - bottomR} ${y + height}`; - - path += ` L ${x + bottomR} ${y + height}`; + // Build path with selective rounding + let path = `M ${x + rTL} ${y}`; + path += ` L ${x + width - rTR} ${y}`; + path += ` A ${rTR} ${rTR} 0 0 1 ${x + width} ${y + rTR}`; - path += ` A ${bottomR} ${bottomR} 0 0 1 ${x} ${y + height - bottomR}`; + path += ` L ${x + width} ${y + height - rBR}`; + path += ` A ${rBR} ${rBR} 0 0 1 ${x + width - rBR} ${y + height}`; - path += ` L ${x} ${y + topR}`; + path += ` L ${x + rBL} ${y + height}`; + path += ` A ${rBL} ${rBL} 0 0 1 ${x} ${y + height - rBL}`; - path += ` A ${topR} ${topR} 0 0 1 ${x + topR} ${y}`; + path += ` L ${x} ${y + rTL}`; + path += ` A ${rTL} ${rTL} 0 0 1 ${x + rTL} ${y}`; path += ' Z'; return path; diff --git a/packages/web-visualization/src/chart/utils/point.ts b/packages/web-visualization/src/chart/utils/point.ts index d64c6e9f05..c0b3431dc1 100644 --- a/packages/web-visualization/src/chart/utils/point.ts +++ b/packages/web-visualization/src/chart/utils/point.ts @@ -1,11 +1,11 @@ import type { TextHorizontalAlignment, TextVerticalAlignment } from '../text'; +import type { CartesianChartLayout } from './context'; import { type CategoricalScale, type ChartScaleFunction, isCategoricalScale, isLogScale, - isNumericScale, type PointAnchor, } from './scale'; @@ -32,7 +32,7 @@ export const getPointOnScale = ( anchor: PointAnchor = 'middle', ): number => { if (isCategoricalScale(scale)) { - const bandScale = scale; + const bandScale = scale as CategoricalScale; const bandStart = bandScale(dataValue); if (bandStart === undefined) return 0; @@ -46,13 +46,12 @@ export const getPointOnScale = ( return stepStart; case 'bandStart': return bandStart; + case 'middle': + return bandStart + bandwidth / 2; case 'bandEnd': return bandStart + bandwidth; case 'stepEnd': return stepStart + step; - case 'middle': - default: - return bandStart + bandwidth / 2; } } @@ -112,12 +111,18 @@ export const projectPoints = ({ yScale, xData, yData, + layout = 'vertical', }: { data: (number | null | { x: number; y: number })[]; xData?: number[]; yData?: number[]; xScale: ChartScaleFunction; yScale: ChartScaleFunction; + /** + * Chart layout. + * @default 'vertical' + */ + layout?: CartesianChartLayout; }): Array<{ x: number; y: number } | null> => { if (data.length === 0) { return []; @@ -137,39 +142,30 @@ export const projectPoints = ({ }); } - // For scales with axis data, determine the correct x value - let xValue: number = index; - - // For band scales, always use the index - if (!isCategoricalScale(xScale)) { - // For numeric scales with axis data, use the axis data values instead of indices - if (xData && Array.isArray(xData) && xData.length > 0) { - // Check if it's numeric data - if (typeof xData[0] === 'number') { - const numericXData = xData as number[]; - xValue = numericXData[index] ?? index; + // Determine values/scales based on role (index vs value) and layout. + const categoryAxisIsX = layout !== 'horizontal'; + const indexScale = categoryAxisIsX ? xScale : yScale; + const indexData = categoryAxisIsX ? xData : yData; + + // 1. Calculate position along the index axis (categorical or numeric domain). + let indexValue: number = index; + if (!isCategoricalScale(indexScale)) { + if (indexData && Array.isArray(indexData) && indexData.length > 0) { + if (typeof indexData[0] === 'number') { + indexValue = (indexData as number[])[index] ?? index; } } } - let yValue: number = value as number; - if ( - isNumericScale(yScale) && - yData && - Array.isArray(yData) && - yData.length > 0 && - typeof yData[0] === 'number' && - typeof value === 'number' - ) { - yValue = value as number; + // 2. Calculate position along the value axis (measured magnitude). + const valueAsNumber = value as number; + + // 3. Project final coordinates based on layout. + if (categoryAxisIsX) { + return projectPoint({ x: indexValue, y: valueAsNumber, xScale, yScale }); } - return projectPoint({ - x: xValue, - y: yValue, - xScale, - yScale, - }); + return projectPoint({ x: valueAsNumber, y: indexValue, xScale, yScale }); }); }; diff --git a/packages/web-visualization/src/chart/utils/scrubber.ts b/packages/web-visualization/src/chart/utils/scrubber.ts index df416cda52..32049d9a74 100644 --- a/packages/web-visualization/src/chart/utils/scrubber.ts +++ b/packages/web-visualization/src/chart/utils/scrubber.ts @@ -15,22 +15,28 @@ export type LabelDimensions = { /** * Determines which side (left/right) to place scrubber labels based on available space. - * Prefers right side, switches to left when labels would overflow. + * Honors the preferred side when there's enough space, otherwise switches to the opposite side. */ export const getLabelPosition = ( beaconX: number, maxLabelWidth: number, drawingArea: Rect, xOffset: number = 16, + preferredSide: ScrubberLabelPosition = 'right', ): ScrubberLabelPosition => { if (drawingArea.width <= 0 || drawingArea.height <= 0) { - return 'right'; + return preferredSide; } - const availableRightSpace = drawingArea.x + drawingArea.width - beaconX; const requiredSpace = maxLabelWidth + xOffset; - return requiredSpace <= availableRightSpace ? 'right' : 'left'; + if (preferredSide === 'right') { + const availableSpace = drawingArea.x + drawingArea.width - beaconX; + return requiredSpace <= availableSpace ? 'right' : 'left'; + } + + const availableSpace = beaconX - drawingArea.x; + return requiredSpace <= availableSpace ? 'left' : 'right'; }; type LabelWithPosition = { diff --git a/packages/web-visualization/src/chart/utils/transition.ts b/packages/web-visualization/src/chart/utils/transition.ts index f4fa560082..084e4fab80 100644 --- a/packages/web-visualization/src/chart/utils/transition.ts +++ b/packages/web-visualization/src/chart/utils/transition.ts @@ -6,12 +6,12 @@ import { type MotionValue, type Transition, useMotionValue, - useTransform, type ValueAnimationTransition, } from 'framer-motion'; /** - * Default transition configuration used across all chart components. + * Default update transition used across all chart components. + * `{ type: 'spring', stiffness: 900, damping: 120, mass: 4 }` */ export const defaultTransition: Transition = { type: 'spring', @@ -20,6 +20,15 @@ export const defaultTransition: Transition = { mass: 4, }; +/** + * Instant transition that completes immediately with no animation. + * Used when a transition is set to `null`. + */ +export const instantTransition: Transition = { + type: 'tween', + duration: 0, +}; + /** * Duration in seconds for accessory elements to fade in. */ @@ -30,40 +39,62 @@ export const accessoryFadeTransitionDuration = 0.15; */ export const accessoryFadeTransitionDelay = 0.35; +/** + * Default enter transition for accessory elements (Point, Scrubber beacons). + * `{ type: 'tween', duration: 0.15, delay: 0.35 }` + */ +export const defaultAccessoryEnterTransition: Transition = { + type: 'tween', + duration: accessoryFadeTransitionDuration, + delay: accessoryFadeTransitionDelay, +}; + +/** + * Resolves a transition value based on the animation state and a default. + * @note Passing in null will disable an animation. + * @note Passing in undefined will use the provided default. + */ +export const getTransition = ( + value: Transition | null | undefined, + animate: boolean, + defaultValue: Transition, +): Transition | null => { + if (!animate || value === null) return null; + return value ?? defaultValue; +}; + /** * Hook for path animation state and transitions. * * @param currentPath - Current target path to animate to * @param initialPath - Initial path for enter animation. When provided, the first animation will go from initialPath to currentPath. - * @param transition - Transition configuration + * @param transitions - Transition configuration for enter and update animations * @returns MotionValue containing the current interpolated path string * * @example * // Simple path transition * const animatedPath = usePathTransition({ * currentPath: d ?? '', - * transition: { - * type: 'spring', - * stiffness: 300, - * damping: 20 - * } + * transitions: { + * update: { type: 'spring', stiffness: 300, damping: 20 }, + * }, * }); * * @example - * // Time based animation + * // Enter animation with different initial config (like DefaultBar) * const animatedPath = usePathTransition({ * currentPath: targetPath, * initialPath: baselinePath, - * transition: { - * type: 'tween', - * duration: 0.3, - * ease: 'easeInOut' - * } + * transitions: { + * enter: { type: 'tween', duration: 0.5 }, + * update: { type: 'spring', stiffness: 900, damping: 120, mass: 4 }, + * }, * }); */ export const usePathTransition = ({ currentPath, initialPath, + transitions, transition = defaultTransition, }: { /** @@ -77,63 +108,71 @@ export const usePathTransition = ({ */ initialPath?: string; /** - * Transition configuration + * Transition configuration for enter and update animations. + */ + transitions?: { + /** + * Transition for the initial enter animation (initialPath → currentPath). + * Only used when `initialPath` is provided. + * If not provided, falls back to `update`. + */ + enter?: Transition | null; + /** + * Transition for subsequent data update animations. + * @default defaultTransition + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. */ transition?: Transition; }): MotionValue => { - const isInitialRender = useRef(true); - const previousPathRef = useRef(initialPath ?? currentPath); - const targetPathRef = useRef(currentPath); - const animationRef = useRef(null); - const progress = useMotionValue(0); - - // Derive the interpolated path from progress using useTransform - const interpolatedPath = useTransform(progress, (latest) => { - const pathInterpolator = interpolatePath(previousPathRef.current, targetPathRef.current); - return pathInterpolator(latest); + const transitionRef = useRef<{ + enter?: Transition | null; + update: Transition | null; + }>({ + enter: transitions?.enter, + update: transitions?.update !== undefined ? transitions.update : transition, }); + const isFirstAnimation = useRef(!!initialPath); - useEffect(() => { - // Only proceed if the target path has actually changed - if (targetPathRef.current !== currentPath) { - // Cancel any ongoing animation before starting a new one - const wasAnimating = !!animationRef.current; - if (animationRef.current) { - animationRef.current.cancel(); - animationRef.current = null; - } - - const currentInterpolatedPath = interpolatedPath.get(); + const animatedPath = useMotionValue(initialPath ?? currentPath); + transitionRef.current.enter = transitions?.enter; + transitionRef.current.update = + transitions?.update !== undefined ? transitions.update : transition; - // If we were animating and the interpolated path is different from both start and end, - // use it as the starting point for the next animation (smooth interruption) - const isInterpolatedPosition = - currentInterpolatedPath !== previousPathRef.current && - currentInterpolatedPath !== currentPath; - - if (wasAnimating && isInterpolatedPosition) { - previousPathRef.current = currentInterpolatedPath; - } - - targetPathRef.current = currentPath; + useEffect(() => { + const fromPath = animatedPath.get(); + if (fromPath === currentPath) { + return; + } - progress.set(0); - animationRef.current = animate(progress, 1, { - ...(transition as ValueAnimationTransition), - onComplete: () => { - previousPathRef.current = currentPath; - }, - }); + const { enter, update } = transitionRef.current; + const activeTransition = isFirstAnimation.current && enter !== undefined ? enter : update; + isFirstAnimation.current = false; - isInitialRender.current = false; + if (activeTransition === null) { + animatedPath.set(currentPath); + return; } + const pathInterpolator = interpolatePath(fromPath, currentPath); + const playback: AnimationPlaybackControls = animate(0, 1, { + ...(activeTransition as ValueAnimationTransition), + onUpdate: (latest) => { + animatedPath.set(pathInterpolator(latest)); + }, + onComplete: () => { + animatedPath.set(currentPath); + }, + }); + return () => { - if (animationRef.current) { - animationRef.current.cancel(); - } + playback?.stop(); }; - }, [currentPath, transition, progress, interpolatedPath]); + }, [currentPath, animatedPath]); - return interpolatedPath; + return animatedPath; }; diff --git a/packages/web-visualization/src/sparkline/Sparkline.tsx b/packages/web-visualization/src/sparkline/Sparkline.tsx index 4dd69222ae..e70de260b0 100644 --- a/packages/web-visualization/src/sparkline/Sparkline.tsx +++ b/packages/web-visualization/src/sparkline/Sparkline.tsx @@ -46,7 +46,8 @@ export type SparklineBaseProps = SharedProps & { export type SparklineProps = SparklineBaseProps; /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const Sparkline = memo( forwardRef( diff --git a/packages/web-visualization/src/sparkline/SparklineArea.tsx b/packages/web-visualization/src/sparkline/SparklineArea.tsx index 52d3c5849f..634c3c36ba 100644 --- a/packages/web-visualization/src/sparkline/SparklineArea.tsx +++ b/packages/web-visualization/src/sparkline/SparklineArea.tsx @@ -7,7 +7,8 @@ export type SparklineAreaBaseProps = { }; /** - * @deprecated Use AreaChart instead. + * @deprecated Use AreaChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineArea = memo( forwardRef( diff --git a/packages/web-visualization/src/sparkline/SparklineGradient.tsx b/packages/web-visualization/src/sparkline/SparklineGradient.tsx index 8607a46639..bba817abb4 100644 --- a/packages/web-visualization/src/sparkline/SparklineGradient.tsx +++ b/packages/web-visualization/src/sparkline/SparklineGradient.tsx @@ -5,7 +5,8 @@ import { Sparkline } from './Sparkline'; import type { SparklinePathRef } from './SparklinePath'; /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineGradient = memo( forwardRef( diff --git a/packages/web-visualization/src/sparkline/__figma__/Sparkline.figma.tsx b/packages/web-visualization/src/sparkline/__figma__/Sparkline.figma.tsx index 10f586446a..5eb3660f1d 100644 --- a/packages/web-visualization/src/sparkline/__figma__/Sparkline.figma.tsx +++ b/packages/web-visualization/src/sparkline/__figma__/Sparkline.figma.tsx @@ -10,8 +10,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=320-15040&m=dev', { imports: [ - "import { Sparkline } from '@coinbase/cds-web-visualization';", - "import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath';", + "import { Sparkline } from '@coinbase/cds-web-visualization'", + "import { useSparklinePath } from '@coinbase/cds-common/visualizations/useSparklinePath'", ], example: () => { const data = [20, 30, 5, 45, 0]; diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx index 7cd6a5655e..76482c7a5e 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive-header/__figma__/SparklineInteractiveHeader.figma.tsx +++ b/packages/web-visualization/src/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-web-visualization';", - "import { SparklineInteractive } from '@coinbase/cds-web-visualization';", + "import { SparklineInteractiveHeader } from '@coinbase/cds-web-visualization'", + "import { SparklineInteractive } from '@coinbase/cds-web-visualization'", ], props: { compact: figma.boolean('compact'), diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx index 0ff3689620..fb8a111d0d 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive-header/__stories__/SparklineInteractiveHeader.stories.tsx @@ -15,6 +15,9 @@ import { export default { component: SparklineInteractiveHeader, title: 'Components/SparklineInteractiveHeader', + parameters: { + a11y: { test: 'off' }, + }, }; type SparklinePeriod = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'; diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx index ffd0e8471b..2f3e3b72c4 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/SparklineInteractive.tsx @@ -478,7 +478,8 @@ function SparklineInteractiveWithGeneric({ } /** - * @deprecated Use LineChart instead. + * @deprecated Use LineChart instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v4 */ export const SparklineInteractive = memo( SparklineInteractiveWithGeneric, diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx index 2e98c45ebc..bea24790c1 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/__figma__/SparklineInteractive.figma.tsx @@ -7,7 +7,7 @@ figma.connect( SparklineInteractive, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=320-14858&m=dev', { - imports: ["import { SparklineInteractive } from '@coinbase/cds-web-visualization';"], + imports: ["import { SparklineInteractive } from '@coinbase/cds-web-visualization'"], props: { compact: figma.boolean('compact'), disableScrubbing: figma.boolean('scrubbing', { diff --git a/packages/web-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx b/packages/web-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx index e53a6d6008..e1c3d5bf36 100644 --- a/packages/web-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx +++ b/packages/web-visualization/src/sparkline/sparkline-interactive/__stories__/SparklineInteractive.stories.tsx @@ -17,6 +17,9 @@ import { SparklineInteractive } from '../SparklineInteractive'; export default { component: SparklineInteractive, title: 'Components/SparklineInteractive', + parameters: { + a11y: { test: 'off' }, + }, }; type SparklinePeriod = 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'; diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 140bf4dfc4..e744ea0685 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,7 +8,407 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 8.66.0 (4/16/2026 PST) + +#### 🚀 Updates + +- Deprecate Dropdown and add PopoverPanel component. [[#566](https://github.com/coinbase/cds/pull/566)] + +## 8.65.0 (4/16/2026 PST) + +#### 🚀 Updates + +- Add customization to text for ModalHeader. [[#613](https://github.com/coinbase/cds/pull/613)] + +## 8.64.5 (4/16/2026 PST) + +#### 🐞 Fixes + +- Fix: remove extra padding on combobox input. [[#617](https://github.com/coinbase/cds/pull/617)] + +## 8.64.4 (4/10/2026 PST) + +#### 🐞 Fixes + +- Fix Toast enter animation in React 19 StrictMode. [[#607](https://github.com/coinbase/cds/pull/607)] + +## 8.64.3 (4/8/2026 PST) + +#### 🐞 Fixes + +- Fix: Stepper animation with react-spring ^10.0.1. [[#603](https://github.com/coinbase/cds/pull/603)] + +## 8.64.2 ((4/8/2026, 11:26 AM PST)) + +This is an artificial version bump with no new change. + +## 8.64.1 (4/7/2026 PST) + +#### 🐞 Fixes + +- Chore: Add styles and classNames APIs to Tour and TourStep components. [[#592](https://github.com/coinbase/cds/pull/592)] + +## 8.64.0 (4/2/2026 PST) + +#### 🚀 Updates + +- Added DefaultTab and DefaultTabActiveIndicator and deprecate types used by TabNavigation. [[#558](https://github.com/coinbase/cds/pull/558)] + +## 8.63.0 (4/1/2026 PST) + +#### 🚀 Updates + +- Add type focus to Select. [[#571](https://github.com/coinbase/cds/pull/571)] + +## 8.62.1 (4/1/2026 PST) + +#### 🐞 Fixes + +- Remove usage of Array.prototype.at(). [[#575](https://github.com/coinbase/cds/pull/575)] + +## 8.62.0 (3/30/2026 PST) + +#### 🚀 Updates + +- Add ComponentConfigProvider. [[#507](https://github.com/coinbase/cds/pull/507)] + +## 8.61.0 (3/30/2026 PST) + +#### 🚀 Updates + +- Feat: support SearchInput height customization. [[#565](https://github.com/coinbase/cds/pull/565)] + +#### 📘 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 + +- Suppoer controlSize on Checkbox and Radio. [[#546](https://github.com/coinbase/cds/pull/546)] + +## 8.58.0 (3/25/2026 PST) + +#### 🚀 Updates + +- Feat: support font prop on inputs. [[#545](https://github.com/coinbase/cds/pull/545)] +- Feat: support borderRadius on SearchInput. [[#545](https://github.com/coinbase/cds/pull/545)] + +## 8.57.1 (3/24/2026 PST) + +#### 🐞 Fixes + +- Use aria-describedby for all tooltip's triggers. [[#541](https://github.com/coinbase/cds/pull/541)] + +## 8.57.0 (3/24/2026 PST) + +#### 🚀 Updates + +- Feat: support focusedBorderWidth on TextInput. [[#537](https://github.com/coinbase/cds/pull/537)] + +## 8.56.1 (3/24/2026 PST) + +#### 🐞 Fixes + +- Fixed issue when typing space in combobox input closes the popover by mistake. [[#523](https://github.com/coinbase/cds/pull/523)] + +## 8.56.0 (3/23/2026 PST) + +#### 🚀 Updates + +- Support modal subcomponent props. [[#534](https://github.com/coinbase/cds/pull/534)] + +#### 📘 Misc + +- Chore: Updated numerous deprecation annotation messages. + +## 8.55.1 (3/22/2026 PST) + +#### 🐞 Fixes + +- Fix icon inconsistent rendering. [[#527](https://github.com/coinbase/cds/pull/527)] + +## 8.55.0 ((3/19/2026, 01:41 PM PST)) + +This is an artificial version bump with no new change. + +## 8.54.0 (3/18/2026 PST) + +#### 🚀 Updates + +- Add component styling, improve a11y for Calendar and DatePicker. [[#139](https://github.com/coinbase/cds/pull/139)] + +## 8.53.1 (3/17/2026 PST) + +#### 🐞 Fixes + +- Fix: update RemoteImageGroup excess bg color. [[#512](https://github.com/coinbase/cds/pull/512)] + +## 8.53.0 (3/16/2026 PST) + +#### 🚀 Updates + +- Feat: update Checkbox borderRadius to match design. [[#509](https://github.com/coinbase/cds/pull/509)] + +#### 📘 Misc + +- Deprecate SegmentedControl. [[#493](https://github.com/coinbase/cds/pull/493)] + +## 8.52.2 (3/11/2026 PST) + +#### 🐞 Fixes + +- Configure control borderWidth and controlColor. [[#457](https://github.com/coinbase/cds/pull/457)] + +## 8.52.1 (3/11/2026 PST) + +#### 🐞 Fixes + +- Add keyboard scroll support to FocusTrap, Tray, and Modal. [[#481](https://github.com/coinbase/cds/pull/481)] + +## 8.52.0 (3/10/2026 PST) + +#### 🚀 Updates + +- A11y improvements to Fallback, Spinner, and LottieStatusAnimation. [[#388](https://github.com/coinbase/cds/pull/388)] +- Simplify the ProgressBar component implementation. [[#388](https://github.com/coinbase/cds/pull/388)] +- Use shapeBorderRadius tokens in RemoteImage/RemoteImageGroup. [[#388](https://github.com/coinbase/cds/pull/388)] +- Removed useFallbackShape implementation from web and reuse the same hook defined in common. [[#388](https://github.com/coinbase/cds/pull/388)] + +## 8.51.0 (3/9/2026 PST) + +#### 🚀 Updates + +- Added hasInteractiveContent prop to Tooltip to correctly handle keyboard navigation when content includes interactive elements. [[#469](https://github.com/coinbase/cds/pull/469)] [DX-5097] + +#### 🐞 Fixes + +- Fixed issue when tooltip does not announce its content when content is a React Node instead of a string. [[#469](https://github.com/coinbase/cds/pull/469)] + +## 8.50.0 (3/6/2026 PST) + +#### 🚀 Updates + +- Feat: iconSize customization for IconButton. [[#474](https://github.com/coinbase/cds/pull/474)] + +## 8.49.2 (3/6/2026 PST) + +#### 🐞 Fixes + +- Feat: improve deprecation notice in ListCell. [[#411](https://github.com/coinbase/cds/pull/411)] + +## 8.49.1 (3/5/2026 PST) + +#### 🐞 Fixes + +- Fix: spread tabs props at end for Tabs. [[#472](https://github.com/coinbase/cds/pull/472)] + +## 8.49.0 (2/26/2026 PST) + +#### 🚀 Updates + +- Add styles and classnames props to Tab components. [[#438](https://github.com/coinbase/cds/pull/438)] + +## 8.48.3 (2/25/2026 PST) + +#### 🐞 Fixes + +- Fix: allow arrow up/down keys within focus trapped text area. [[#417](https://github.com/coinbase/cds/pull/417)] + +## 8.48.2 ((2/25/2026, 04:21 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.1 (2/25/2026 PST) + +#### 🐞 Fixes + +- Truncate text mid-word in multi-select chips. [[#412](https://github.com/coinbase/cds/pull/412)] + +## 8.48.0 (2/24/2026 PST) + +#### 🚀 Updates + +- Add start/end icon/node support to Tag. [[#421](https://github.com/coinbase/cds/pull/421)] + +## 8.47.4 ((2/23/2026, 03:04 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.3 (2/20/2026 PST) + +#### 🐞 Fixes + +- Remove behavior of scrolling inside TextInput updating numeric values. [[#413](https://github.com/coinbase/cds/pull/413)] + +## 8.47.2 ((2/19/2026, 03:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.1 ((2/19/2026, 01:18 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.0 (2/19/2026 PST) + +#### 🚀 Updates + +- Feat: enable Button text customization via font props. [[#408](https://github.com/coinbase/cds/pull/408)] + +## 8.46.1 (2/12/2026 PST) + +#### 🐞 Fixes + +- Fix: (DX-5052) use previous active step value for calculating remaining steps to animate to for a completed stepper. [[#397](https://github.com/coinbase/cds/pull/397)] [DX-5052] + +## 8.46.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add open/close visibility delays to Tooltip. [[#234](https://github.com/coinbase/cds/pull/234)] + +## 8.45.0 (2/12/2026 PST) + +#### 🚀 Updates + +- Add reduce motion support for Tray. [[#386](https://github.com/coinbase/cds/pull/386)] + +## 8.44.2 (2/10/2026 PST) + +#### 🐞 Fixes + +- Fix Tray drag elastic. [[#385](https://github.com/coinbase/cds/pull/385)] + +## 8.44.1 (2/10/2026 PST) + +#### 🐞 Fixes + +- Enabled customer to override the width prop in Banner so they can explicitly pass in a width for any bleed effect. [[#383](https://github.com/coinbase/cds/pull/383)] + +#### 📘 Misc + +- Update jsdocs for styles props. [[#384](https://github.com/coinbase/cds/pull/384)] + +## 8.44.0 (2/9/2026 PST) + +#### 🚀 Updates + +- Add new tray design. [[#349](https://github.com/coinbase/cds/pull/349)] + +## 8.43.2 ((2/9/2026, 09:05 AM PST)) + +This is an artificial version bump with no new change. + +## 8.43.1 (2/6/2026 PST) + +#### 🐞 Fixes + +- Update chpi prop export. [[#328](https://github.com/coinbase/cds/pull/328)] +- Add NavigationBar classNames. [[#328](https://github.com/coinbase/cds/pull/328)] + +## 8.43.0 (2/6/2026 PST) + +#### 🚀 Updates + +- Carousel autoplay. [[#361](https://github.com/coinbase/cds/pull/361)] + +## 8.42.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Added MediaCard, MessagingCard, and alpha DataCard. [[#329](https://github.com/coinbase/cds/pull/329)] +- Updated ContentCard. [[#329](https://github.com/coinbase/cds/pull/329)] + +## 8.41.0 (2/4/2026 PST) + +#### 🚀 Updates + +- Add align prop to Select and Combobox. [[#348](https://github.com/coinbase/cds/pull/348)] + +## 8.40.2 ((2/2/2026, 11:25 AM PST)) + +This is an artificial version bump with no new change. + +## 8.40.1 ((1/30/2026, 04:58 PM PST)) + +This is an artificial version bump with no new change. + +#### 📘 Misc + +- Add descriptive names for generic types. [[#341](https://github.com/coinbase/cds/pull/341)] [DX-5037] + +## 8.40.0 ((1/28/2026, 11:12 AM PST)) + +This is an artificial version bump with no new change. + +## 8.39.1 (1/27/2026 PST) + +#### 🐞 Fixes + +- Fix padding on Tab components. [[#330](https://github.com/coinbase/cds/pull/330)] + +## 8.39.0 (1/27/2026 PST) + +#### 🚀 Updates + +- Support Carousel looping. [[#327](https://github.com/coinbase/cds/pull/327)] + +## 8.38.7 (1/26/2026 PST) + +#### 🐞 Fixes + +- Fix Switch rendering with an unintended drop shadow. Add optional `elevation` prop to Control components (Switch, Checkbox, Radio). [[#325](https://github.com/coinbase/cds/pull/325)] + +## 8.38.6 (1/23/2026 PST) + +#### 🐞 Fixes + +- Fix(RadioCell): Adjusted Pressable to have a tabindex="-1" instead of 0. [CDS-1170] + +## 8.38.5 (1/23/2026 PST) + +#### 🐞 Fixes + +- Improve keyboard navigation and ARIA labels on Select and Combobox. [[#250](https://github.com/coinbase/cds/pull/250)] + +## 8.38.4 (1/22/2026 PST) + +#### 🐞 Fixes + +- Fixed spacing props not working on web Button. + +## 8.38.3 (1/22/2026 PST) + +#### 🐞 Fixes + +- Fix: destructure unused props from default horizontal stepper components to prevent dev mode React warnings. [[#324](https://github.com/coinbase/cds/pull/324)] + +## 8.38.2 (1/22/2026 PST) + +#### 🐞 Fixes + +- FocusTrap supports single focusable child and updates to its tests. [[#306](https://github.com/coinbase/cds/pull/306)] + +## 8.38.1 (1/15/2026 PST) + +#### 🐞 Fixes + +- Support TextInput labelNode on compact and inside labelVariant. [[#293](https://github.com/coinbase/cds/pull/293)] + +#### 📘 Misc + +- Internal: code connect file lint fixes. [[#311](https://github.com/coinbase/cds/pull/311)] #### 📘 Misc @@ -229,7 +629,7 @@ This is an artificial version bump with no new change. #### 🐞 Fixes -- Improve keyboard navigation for Tabs components and upadate ARIA roles. [[#96](https://github.com/coinbase/cds/pull/96)] +- Improve keyboard navigation for Tabs components and update ARIA roles. [[#96](https://github.com/coinbase/cds/pull/96)] ## 8.25.0 (12/1/2025 PST) @@ -311,7 +711,7 @@ This is an artificial version bump with no new change. - Fixed select alpha dropdown zIndex. [[#161](https://github.com/coinbase/cds/pull/161)] - Corrected ListCell spacingVariant jsdoc. [[#161](https://github.com/coinbase/cds/pull/161)] -- Updated docs of FullscreenMoal and FullscreenModalLayout to show a more precise 3-column layout example. [[#161](https://github.com/coinbase/cds/pull/161)] +- Updated docs of FullscreenModal and FullscreenModalLayout to show a more precise 3-column layout example. [[#161](https://github.com/coinbase/cds/pull/161)] ## 8.21.0 (11/12/2025 PST) diff --git a/packages/web/jest/setup.js b/packages/web/jest/setup.js index b99d21bd76..d50f8d3b7c 100644 --- a/packages/web/jest/setup.js +++ b/packages/web/jest/setup.js @@ -1,3 +1,33 @@ +/* -------------------------------------------------------------------------- */ +/* @floating-ui/react-dom */ +/* -------------------------------------------------------------------------- */ +jest.mock('@floating-ui/react-dom', () => { + const floatingRef = { current: null }; + return { + useFloating: () => ({ + refs: { + setReference: jest.fn(), + setFloating: jest.fn((el) => { + floatingRef.current = el; + }), + reference: { current: null }, + floating: floatingRef, + }, + floatingStyles: {}, + placement: 'bottom', + middlewareData: { arrow: {} }, + }), + // Middleware stubs — these are called as functions and must return a value + arrow: () => ({}), + autoPlacement: () => ({}), + autoUpdate: jest.fn(), + flip: () => ({}), + limitShift: () => ({}), + offset: () => ({}), + shift: () => ({}), + }; +}); + jest.mock('framer-motion', () => ({ ...jest.requireActual('framer-motion'), m: jest.requireActual('framer-motion')?.motion, diff --git a/packages/web/package.json b/packages/web/package.json index ad4b451251..9b2fa290e6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.38.0", + "version": "8.66.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", @@ -32,6 +32,10 @@ "types": "./dts/alpha/combobox/index.d.ts", "default": "./esm/alpha/combobox/index.js" }, + "./alpha/data-card": { + "types": "./dts/alpha/data-card/index.d.ts", + "default": "./esm/alpha/data-card/index.js" + }, "./alpha/select": { "types": "./dts/alpha/select/index.d.ts", "default": "./esm/alpha/select/index.js" @@ -218,7 +222,8 @@ "lodash": "^4.17.21", "lottie-web": "^5.13.0", "react-popper": "^2.2.4", - "react-use-measure": "^2" + "react-use-measure": "^2", + "zustand": "^5.0.12" }, "devDependencies": { "@babel/core": "^7.28.0", diff --git a/packages/web/src/__stories__/AccessibilityViolations.stories.tsx b/packages/web/src/__stories__/AccessibilityViolations.stories.tsx new file mode 100644 index 0000000000..eaa2c144cf --- /dev/null +++ b/packages/web/src/__stories__/AccessibilityViolations.stories.tsx @@ -0,0 +1,46 @@ +import { IconButton } from '../buttons'; +import { Box, VStack } from '../layout'; +import { Text } from '../typography'; + +const AccessibilityViolations = () => { + return ( + + + + Missing accessibilityLabel + + + + Correct usage + + + + + + Incorrect color contrast + + + + This text does not contrast with the background + + + + Correct color contrast + + + This text contrasts with the background + + + + ); +}; + +export const Default = () => ; + +export default { + component: AccessibilityViolations, + title: 'Accessibility/AccessibilityViolations', + parameters: { + a11y: { test: 'todo' }, + }, +}; diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/BodyText.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/BodyText.tsx new file mode 100644 index 0000000000..57ded22048 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/BodyText.tsx @@ -0,0 +1,13 @@ +import { memo } from 'react'; +import { Text, type TextDefaultElement, type TextProps } from '@coinbase/cds-web/typography/Text'; + +export const BodyText = memo(({ style, ...props }: TextProps) => ( + +)); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/Container.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/Container.tsx new file mode 100644 index 0000000000..6813efca90 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/Container.tsx @@ -0,0 +1,55 @@ +import { memo } from 'react'; +import { type BoxDefaultElement, type BoxProps } from '@coinbase/cds-web/layout/Box'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Text } from '@coinbase/cds-web/typography/Text'; + +type ContainerProps = Omit, 'title'> & { + title?: string; +}; + +export const Container = memo( + ({ + background = 'bg', + alignSelf = 'stretch', + alignItems = 'center', + flexDirection = 'row', + justifyContent = 'center', + flexWrap = 'wrap', + flexGrow = 0, + flexShrink = 0, + width = '100%', + borderRadius = 200, + position = 'relative', + padding = 2, + gap = 2, + children, + title, + ...props + }: ContainerProps) => { + return ( + + {title && ( + + {title} + + )} + {children} + + ); + }, +); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/StickerSheet.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/StickerSheet.tsx new file mode 100644 index 0000000000..6bec812a96 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/StickerSheet.tsx @@ -0,0 +1,410 @@ +import { memo } from 'react'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { Accordion } from '@coinbase/cds-web/accordion/Accordion'; +import { AccordionItem } from '@coinbase/cds-web/accordion/AccordionItem'; +import { Banner } from '@coinbase/cds-web/banner/Banner'; +import { Button } from '@coinbase/cds-web/buttons/Button'; +import { IconButton } from '@coinbase/cds-web/buttons/IconButton'; +import { MessagingCard } from '@coinbase/cds-web/cards/MessagingCard'; +import { ListCell } from '@coinbase/cds-web/cells/ListCell'; +import { Chip } from '@coinbase/cds-web/chips/Chip'; +import { InputChip } from '@coinbase/cds-web/chips/InputChip'; +import { MediaChip } from '@coinbase/cds-web/chips/MediaChip'; +import { Coachmark } from '@coinbase/cds-web/coachmark/Coachmark'; +import { DotCount } from '@coinbase/cds-web/dots/DotCount'; +import { Icon } from '@coinbase/cds-web/icons/Icon'; +import { Pictogram } from '@coinbase/cds-web/illustrations/Pictogram'; +import { HStack } from '@coinbase/cds-web/layout/HStack'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Avatar } from '@coinbase/cds-web/media/Avatar'; +import { RemoteImage } from '@coinbase/cds-web/media/RemoteImage'; +import { Tag } from '@coinbase/cds-web/tag/Tag'; +import { Link } from '@coinbase/cds-web/typography/Link'; +import { Text } from '@coinbase/cds-web/typography/Text'; + +import { AlertExample } from './examples/AlertExample'; +import { ControlsExample } from './examples/Controls'; +import { DatePickerExample } from './examples/DatePicker'; +import { DropdownExample } from './examples/DropdownExample'; +import { ModalExample } from './examples/ModalExample'; +import { PaginationExample } from './examples/Pagination'; +import { RollingNumberExample } from './examples/RollingNumber'; +import { SearchExample } from './examples/Search'; +import { SegmentedTabsExample } from './examples/SegmentedTabs'; +import { SelectExample } from './examples/Select'; +import { SelectChipExample } from './examples/SelectChip'; +import { StepperHorizontalBasicExample } from './examples/StepperHorizontal'; +import { StepperVerticalCustomExample } from './examples/StepperVertical'; +import { TableExample } from './examples/TableExample'; +import { TextInputExample } from './examples/TextInput'; +import { ToastExample } from './examples/ToastExample'; +import { Container } from './Container'; +import { bannerVariants, buttonVariants, tagColorSchemes } from './themeVars'; + +const SHOW_DEBUG_BG_COLORS = false; + +const leftColumnWidth = 420; +const rightColumnWidth = 600; + +export const StickerSheet = memo(() => { + return ( + + + + + + + + + + + + + + + + + + + + + + undefined} + start={} + value="ETH" + /> + + + + + + + + + + + + + + + + + + + } + subtitle="This is an example subtitle" + title="Accordion item" + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + } + subtitle="This is an example subtitle" + title="Accordion item" + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + + + + + } + mediaPlacement="end" + onActionButtonClick={() => {}} + onDismissButtonClick={() => {}} + title="Earn more crypto" + type="nudge" + /> + + + + + } + mediaPlacement="end" + onActionButtonClick={() => {}} + onDismissButtonClick={() => {}} + title="Upgrade to Coinbase One" + type="upsell" + /> + + + + + + + + + + + + + + + + Got it + + } + closeButtonAccessibilityLabel="Close coachmark" + content="You can now trade directly from your portfolio page." + onClose={() => {}} + title="New feature" + /> + + + + + + + primary + primary + + {tagColorSchemes.map((colorScheme) => ( + + + {colorScheme} + + + {colorScheme} + + + ))} + + + + + + + + + + + + + + + + + + + + + {buttonVariants.map((variant) => ( + + + + + ))} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {buttonVariants.map((variant) => ( + + + + + ))} + + + + + + + + + + + + + + + + + + + {}}> + Chip + + }>User + {}} + start={} + value="BTC" + /> + + + + + + + } + onClick={() => {}} + subtitle="BTC" + title="Bitcoin" + /> + + } + onClick={() => {}} + subtitle="ETH" + title="Ethereum" + /> + + } + onClick={() => {}} + subtitle="XRP" + title="XRP" + /> + + + + + {bannerVariants.map((variant, index) => ( + Primary} + secondaryAction={Secondary} + startIcon="info" + styleVariant="global" + title="Global banner" + variant={variant} + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + + ))} + + + + + + + + + + + + + + + + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx new file mode 100644 index 0000000000..d08162f13b --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/customComponentConfig.tsx @@ -0,0 +1,131 @@ +import { Text } from '@coinbase/cds-web/typography/Text'; + +import type { ComponentConfig } from '../../core/componentConfig'; + +export const customComponentConfig: ComponentConfig = { + Banner: { + borderRadius: 0, + }, + + Button: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + font: props.compact ? 'label1' : 'headline', + progressCircleSize: props.compact ? 16 : 24, + }), + + IconButton: (props) => { + const isCompact = props.compact ?? true; + return { + borderRadius: 200, + height: isCompact ? 24 : 32, + width: isCompact ? 24 : 32, + ...(props.variant === 'tertiary' + ? { + background: 'bgAlternate', + color: 'fg', + borderColor: 'bgAlternate', + } + : {}), + }; + }, + + TextInput: ({ label, labelNode, ...props }) => ({ + labelNode: + (labelNode ?? label) ? ( + + {label} + + ) : undefined, + bordered: false, + inputBackground: 'bgAlternate', + font: props.compact ? 'label2' : 'body', + variant: 'foregroundMuted', + focusedBorderWidth: 100, + }), + + Switch: (props) => ({ + background: props.checked ? 'bgPrimary' : undefined, + controlColor: props.checked ? 'bgAlternate' : 'fg', + }), + + Tooltip: { + invertColorScheme: false, + }, + + Radio: (props) => ({ + background: 'bg', + borderWidth: props.checked ? 200 : 100, + borderColor: props.checked ? 'bgPrimary' : 'bgLinePrimarySubtle', + controlColor: 'bgPrimary', + dotSize: 20 / 3, + }), + + /** + * Advanced parity gap: we use 4px border radius instead of 2px border radius, could be fixed by adding borderRadius of 50 + */ + Checkbox: (props) => ({ + borderWidth: 200, + controlColor: 'fg', + background: props.checked ? 'bgSecondary' : undefined, + borderColor: props.checked ? 'bgSecondary' : 'bgLinePrimarySubtle', + }), + + ModalHeader: { + paddingX: 4, + paddingY: 3, + }, + + ModalFooter: { + paddingX: 4, + paddingY: 4, + }, + + ModalBody: { + paddingX: 4, + }, + + Table: { + variant: 'default', + }, + + SegmentedTabs: { + activeBackground: 'bgSecondary', + background: 'bgAlternate', + borderRadius: 300, + }, + + SegmentedTab: { + activeColor: 'fg', + borderRadius: 200, + font: 'headline', + }, + + Chip: { + borderRadius: 200, + }, + + Link: { + underline: true, + }, + + ControlGroup: { + gap: 1, + }, + + SearchInput: (props) => ({ + borderRadius: 200, + height: props.compact ? 24 : 32, + }), + + Select: (props) => ({ + bordered: false, + variant: 'foregroundMuted', + inputBackground: 'bgAlternate', + focusedBorderWidth: 100, + height: props.compact ? 24 : props.labelVariant === 'inside' ? 40 : 32, + font: props.compact ? 'label2' : 'body', + labelColor: 'fgMuted', + labelFont: props.compact ? (props.align === 'end' ? 'label1' : 'label2') : 'body', + }), +}; diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/customTheme.ts b/packages/web/src/__stories__/componentConfigStickerSheet/customTheme.ts new file mode 100644 index 0000000000..c1d068fe93 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/customTheme.ts @@ -0,0 +1,529 @@ +import type { ThemeConfig } from '@coinbase/cds-web/core/theme'; +import { defaultTheme } from '@coinbase/cds-web/themes/defaultTheme'; + +export const customThemeId = 'custom-theme'; + +const lightSpectrum = { + blue0: '245,248,255', + blue5: '211,225,255', + blue10: '176,202,255', + blue15: '146,182,255', + blue20: '115,162,255', + blue30: '70,132,255', + blue40: '38,110,255', + blue50: '16,94,255', + blue60: '0,82,255', + blue70: '0,75,235', + blue80: '0,62,193', + blue90: '0,41,130', + blue100: '0,24,77', + green0: '245,255,251', + green5: '203,245,227', + green10: '171,230,206', + green15: '131,224,186', + green20: '101,214,167', + green30: '60,194,138', + green40: '34,173,115', + green50: '18,153,97', + green60: '9,133,81', + green70: '4,112,67', + green80: '2,83,50', + green90: '0,57,35', + green100: '0,31,18', + orange0: '255,250,245', + orange5: '254,232,210', + orange10: '253,213,176', + orange15: '251,194,147', + orange20: '249,174,118', + orange30: '244,140,76', + orange40: '237,112,47', + orange50: '225,89,27', + orange60: '207,71,14', + orange70: '181,54,6', + orange80: '145,39,2', + orange90: '100,26,0', + orange100: '51,13,0', + gray0: '255,255,255', + gray5: '247,248,249', + gray10: '238,240,243', + gray15: '222,225,231', + gray20: '206,210,219', + gray30: '177,183,195', + gray40: '137,144,158', + gray50: '113,120,134', + gray60: '91,97,110', + gray70: '70,75,85', + gray80: '50,53,61', + gray90: '30,32,37', + gray100: '10,11,13', + indigo0: '246,247,255', + indigo5: '230,232,255', + indigo10: '214,218,254', + indigo15: '198,204,253', + indigo20: '181,189,253', + indigo30: '148,161,251', + indigo40: '116,135,247', + indigo50: '89,111,242', + indigo60: '66,91,233', + indigo70: '47,74,215', + indigo80: '31,54,173', + indigo90: '17,32,107', + indigo100: '8,15,51', + pink0: '255,245,255', + pink5: '253,228,253', + pink10: '251,212,250', + pink15: '248,195,245', + pink20: '244,178,240', + pink30: '235,143,227', + pink40: '221,110,209', + pink50: '203,81,187', + pink60: '179,58,162', + pink70: '149,39,133', + pink80: '116,26,102', + pink90: '83,17,72', + pink100: '51,10,44', + purple0: '251,247,255', + purple5: '244,232,255', + purple10: '237,217,255', + purple15: '230,201,255', + purple20: '222,184,255', + purple30: '205,153,253', + purple40: '188,123,251', + purple50: '157,107,242', + purple60: '138,85,233', + purple70: '119,67,215', + purple80: '90,48,173', + purple90: '54,27,107', + purple100: '25,13,51', + red0: '255,245,246', + red5: '254,225,228', + red10: '253,206,210', + red15: '251,186,191', + red20: '249,166,173', + red30: '244,127,136', + red40: '237,89,102', + red50: '225,57,71', + red60: '207,32,47', + red70: '181,15,29', + red80: '145,5,16', + red90: '100,1,9', + red100: '51,0,4', + teal0: '240,254,255', + teal5: '188,246,253', + teal10: '136,237,251', + teal15: '93,226,248', + teal20: '51,213,244', + teal30: '0,188,235', + teal40: '0,169,221', + teal50: '0,147,203', + teal60: '0,123,179', + teal70: '0,97,149', + teal80: '0,71,116', + teal90: '0,47,83', + teal100: '0,27,51', + yellow0: '255,252,241', + yellow5: '255,244,192', + yellow10: '255,240,145', + yellow15: '255,234,100', + yellow20: '255,228,54', + yellow30: '247,210,26', + yellow40: '235,186,0', + yellow50: '207,151,0', + yellow60: '174,113,0', + yellow70: '136,76,0', + yellow80: '96,48,0', + yellow90: '58,20,0', + yellow100: '27,6,0', + chartreuse0: '245,255,250', + chartreuse5: '221,251,232', + chartreuse10: '198,247,209', + chartreuse15: '176,242,182', + chartreuse20: '159,238,155', + chartreuse30: '137,223,117', + chartreuse40: '127,208,87', + chartreuse50: '86,179,64', + chartreuse60: '53,151,48', + chartreuse70: '35,122,43', + chartreuse80: '25,93,41', + chartreuse90: '17,64,35', + chartreuse100: '7,26,17', +}; + +const darkSpectrum = { + blue0: '0,16,51', + blue5: '1,29,91', + blue10: '1,42,130', + blue15: '3,51,154', + blue20: '5,59,177', + blue30: '10,72,206', + blue40: '19,84,225', + blue50: '33,98,238', + blue60: '55,115,245', + blue70: '87,139,250', + blue80: '132,170,253', + blue90: '185,207,255', + blue100: '245,248,255', + green0: '0,31,18', + green5: '0,56,36', + green10: '1,70,42', + green15: '2,82,48', + green20: '2,92,55', + green30: '6,112,68', + green40: '11,133,82', + green50: '21,153,98', + green60: '39,173,117', + green70: '68,194,141', + green80: '111,214,171', + green90: '171,235,208', + green100: '245,255,251', + orange0: '51,13,0', + orange5: '79,20,0', + orange10: '107,28,1', + orange15: '131,36,2', + orange20: '155,44,4', + orange30: '189,59,9', + orange40: '213,76,18', + orange50: '230,96,32', + orange60: '240,120,54', + orange70: '248,150,86', + orange80: '252,185,131', + orange90: '254,219,185', + orange100: '255,250,245', + gray0: '10,11,13', + gray5: '20,21,25', + gray10: '30,32,37', + gray15: '40,43,49', + gray20: '50,53,61', + gray30: '70,75,85', + gray40: '91,97,110', + gray50: '114,120,134', + gray60: '138,145,158', + gray70: '165,170,182', + gray80: '193,198,207', + gray90: '224,226,231', + gray100: '255,255,255', + indigo0: '8,15,51', + indigo5: '14,27,91', + indigo10: '21,39,130', + indigo15: '27,47,154', + indigo20: '33,56,177', + indigo30: '48,73,206', + indigo40: '68,92,225', + indigo50: '92,113,238', + indigo60: '121,138,245', + indigo70: '153,165,250', + indigo80: '187,194,253', + indigo90: '219,223,255', + indigo100: '246,247,255', + pink0: '51,10,44', + pink5: '70,14,61', + pink10: '89,19,78', + pink15: '108,24,94', + pink20: '126,30,111', + pink30: '159,44,142', + pink40: '187,64,170', + pink50: '208,88,193', + pink60: '225,117,214', + pink70: '237,149,230', + pink80: '246,184,243', + pink90: '252,217,251', + pink100: '255,245,255', + purple0: '25,13,51', + purple5: '43,22,89', + purple10: '73,30,137', + purple15: '97,37,175', + purple20: '123,45,211', + purple30: '142,51,234', + purple40: '164,84,244', + purple50: '188,123,251', + purple60: '205,153,253', + purple70: '217,176,254', + purple80: '230,201,255', + purple90: '237,217,255', + purple100: '251,247,255', + red0: '51,0,4', + red5: '80,17,22', + red10: '107,1,10', + red15: '131,4,14', + red20: '155,7,19', + red30: '189,19,33', + red40: '213,38,52', + red50: '230,64,78', + red60: '240,97,109', + red70: '248,134,144', + red80: '252,174,181', + red90: '254,213,216', + red100: '255,245,246', + teal0: '0,20,38', + teal5: '0,32,59', + teal10: '0,45,79', + teal15: '0,58,99', + teal20: '0,72,118', + teal30: '0,99,153', + teal40: '0,125,182', + teal50: '0,149,205', + teal60: '0,170,223', + teal70: '6,190,236', + teal80: '69,217,245', + teal90: '149,239,251', + teal100: '240,254,255', + yellow0: '27,6,0', + yellow5: '49,17,0', + yellow10: '81,40,0', + yellow15: '96,48,0', + yellow20: '115,64,0', + yellow30: '147,96,0', + yellow40: '175,128,0', + yellow50: '199,158,0', + yellow60: '222,189,23', + yellow70: '229,205,48', + yellow80: '242,222,94', + yellow90: '255,240,145', + yellow100: '255,252,241', + chartreuse0: '5,22,14', + chartreuse5: '14,54,29', + chartreuse10: '21,79,34', + chartreuse15: '29,103,36', + chartreuse20: '45,128,40', + chartreuse30: '73,152,54', + chartreuse40: '107,176,73', + chartreuse50: '123,200,105', + chartreuse60: '140,209,136', + chartreuse70: '158,217,163', + chartreuse80: '178,222,188', + chartreuse90: '209,238,220', + chartreuse100: '245,255,250', +}; + +export const customTheme = { + ...defaultTheme, + id: customThemeId, + lightSpectrum, + darkSpectrum, + lightColor: { + // Foreground + fg: `rgb(${lightSpectrum.gray100})`, + fgMuted: `rgb(${lightSpectrum.gray60})`, + fgInverse: `rgb(${lightSpectrum.gray0})`, + fgPrimary: `rgb(${lightSpectrum.gray100})`, + fgWarning: `rgb(${lightSpectrum.orange60})`, + fgPositive: `rgb(${lightSpectrum.green60})`, + fgNegative: `rgb(${lightSpectrum.red60})`, + // Background + bg: `rgb(${lightSpectrum.gray0})`, + bgAlternate: `rgb(${lightSpectrum.gray5})`, + bgInverse: `rgb(${lightSpectrum.gray100})`, + bgOverlay: `rgba(${lightSpectrum.gray80},0.33)`, + bgPrimary: `rgb(${lightSpectrum.gray100})`, + bgPrimaryWash: `rgb(${lightSpectrum.gray5})`, + bgSecondary: `rgb(${lightSpectrum.gray10})`, + bgTertiary: `rgb(${lightSpectrum.gray20})`, + bgSecondaryWash: `rgb(${lightSpectrum.gray15})`, + bgNegative: `rgb(${lightSpectrum.red60})`, + bgNegativeWash: `rgb(${lightSpectrum.red5})`, + bgPositive: `rgb(${lightSpectrum.green60})`, + bgPositiveWash: `rgb(${lightSpectrum.green10})`, + bgWarning: `rgb(${lightSpectrum.orange40})`, + bgWarningWash: `rgb(${lightSpectrum.orange0})`, + currentColor: 'currentColor', + // Line + bgLine: `rgba(${lightSpectrum.gray60},0.2)`, + bgLineHeavy: `rgba(${lightSpectrum.gray60},0.66)`, + bgLineInverse: `rgb(${lightSpectrum.gray0})`, + bgLinePrimary: `rgb(${lightSpectrum.gray100})`, + bgLinePrimarySubtle: `rgb(${lightSpectrum.gray20})`, + // Elevation + bgElevation1: `rgb(${lightSpectrum.gray0})`, + bgElevation2: `rgb(${lightSpectrum.gray0})`, + // Accent + accentSubtleGreen: `rgb(${lightSpectrum.green0})`, + accentBoldGreen: `rgb(${lightSpectrum.green60})`, + accentSubtleBlue: `rgb(${lightSpectrum.blue0})`, + accentBoldBlue: `rgb(${lightSpectrum.blue60})`, + accentSubtlePurple: `rgb(${lightSpectrum.purple0})`, + accentBoldPurple: `rgb(${lightSpectrum.purple80})`, + accentSubtleYellow: `rgb(${lightSpectrum.yellow0})`, + accentBoldYellow: `rgb(${lightSpectrum.yellow30})`, + accentSubtleRed: `rgb(${lightSpectrum.red0})`, + accentBoldRed: `rgb(${lightSpectrum.red60})`, + accentSubtleGray: `rgb(${lightSpectrum.gray10})`, + accentBoldGray: `rgb(${lightSpectrum.gray80})`, + // Transparent + transparent: `rgba(${lightSpectrum.gray100},0)`, + }, + darkColor: { + // Foreground + fg: `rgb(${darkSpectrum.gray100})`, + fgInverse: `rgb(${darkSpectrum.gray0})`, + fgMuted: `rgb(${darkSpectrum.gray60})`, + fgPrimary: `rgb(${darkSpectrum.gray100})`, + fgPositive: `rgb(${darkSpectrum.green60})`, + fgNegative: `rgb(${darkSpectrum.red60})`, + fgWarning: `rgb(${darkSpectrum.orange60})`, + // Background + bg: `rgb(${darkSpectrum.gray0})`, + bgAlternate: `rgb(${darkSpectrum.gray5})`, + bgInverse: `rgb(${darkSpectrum.gray100})`, + bgOverlay: `rgba(${darkSpectrum.gray0},0.66)`, + bgPrimary: `rgb(${darkSpectrum.gray100})`, + bgPrimaryWash: `rgb(${darkSpectrum.gray10})`, + bgSecondary: `rgb(${darkSpectrum.gray15})`, + bgTertiary: `rgb(${darkSpectrum.gray30})`, + bgSecondaryWash: `rgb(${darkSpectrum.gray20})`, + bgNegative: `rgb(${darkSpectrum.red60})`, + bgNegativeWash: `rgb(${darkSpectrum.red5})`, + bgPositive: `rgb(${darkSpectrum.green60})`, + bgPositiveWash: `rgb(${darkSpectrum.green5})`, + bgWarning: `rgb(${darkSpectrum.orange40})`, + bgWarningWash: `rgb(${darkSpectrum.orange0})`, + currentColor: 'currentColor', + // Line + bgLine: `rgba(${darkSpectrum.gray60},0.2)`, + bgLineInverse: `rgb(${darkSpectrum.gray0})`, + bgLineHeavy: `rgba(${darkSpectrum.gray60},0.66)`, + bgLinePrimary: `rgb(${darkSpectrum.gray100})`, + bgLinePrimarySubtle: `rgb(${darkSpectrum.gray20})`, + // Elevation + bgElevation1: `rgb(${darkSpectrum.gray0})`, + bgElevation2: `rgb(${darkSpectrum.gray0})`, + // Accent + accentSubtleGreen: `rgb(${darkSpectrum.green0})`, + accentBoldGreen: `rgb(${darkSpectrum.green60})`, + accentSubtleBlue: `rgb(${darkSpectrum.blue0})`, + accentBoldBlue: `rgb(${darkSpectrum.blue60})`, + accentSubtlePurple: `rgb(${darkSpectrum.purple0})`, + accentBoldPurple: `rgb(${darkSpectrum.purple80})`, + accentSubtleYellow: `rgb(${darkSpectrum.yellow0})`, + accentBoldYellow: `rgb(${darkSpectrum.yellow30})`, + accentSubtleRed: `rgb(${darkSpectrum.red0})`, + accentBoldRed: `rgb(${darkSpectrum.red60})`, + accentSubtleGray: `rgb(${darkSpectrum.gray10})`, + accentBoldGray: `rgb(${darkSpectrum.gray80})`, + // Transparent + transparent: `rgba(${darkSpectrum.gray100},0)`, + }, + space: { + '0': 0, + '0.25': 1, + '0.5': 2, + '0.75': 3, + '1': 4, + '1.5': 6, + '2': 8, + '3': 12, + '4': 16, + '5': 20, + '6': 24, + '7': 28, + '8': 32, + '9': 36, + '10': 40, + }, + iconSize: { + xs: 8, + s: 12, + m: 16, + l: 20, + }, + avatarSize: { + s: 12, + m: 16, + l: 20, + xl: 32, + xxl: 36, + xxxl: 48, + }, + controlSize: { + checkboxSize: 16, + radioSize: 16, + switchWidth: 42, + switchHeight: 24, + switchThumbSize: 22, + tileSize: 64, + }, + borderRadius: { + '0': 0, + '100': 4, + '200': 6, + '300': 8, + '400': 12, + '500': 16, + '600': 24, + '700': 32, + '800': 40, + '900': 48, + '1000': 100000, + }, + borderWidth: { + '0': 0, + '100': 1, + '200': 2, + '300': 4, + '400': 6, + '500': 8, + }, + fontFamily: { + display1: 'var(--defaultFont-sans)', + display2: 'var(--defaultFont-sans)', + display3: 'var(--defaultFont-sans)', + title1: 'var(--defaultFont-sans)', + title2: 'var(--defaultFont-sans)', + title3: 'var(--defaultFont-sans)', + title4: 'var(--defaultFont-sans)', + headline: 'var(--defaultFont-sans)', + body: 'var(--defaultFont-sans)', + label1: 'var(--defaultFont-sans)', + label2: 'var(--defaultFont-sans)', + caption: 'var(--defaultFont-sans)', + legal: 'var(--defaultFont-sans)', + }, + fontSize: { + display1: '49px', + display2: '35px', + display3: '31px', + title1: '20px', + title2: '20px', + title3: '14px', + title4: '14px', + headline: '12px', + body: '12px', + label1: '10px', + label2: '10px', + caption: '9px', + legal: '9px', + }, + fontWeight: { + display1: '400', + display2: '400', + display3: '400', + title1: '600', + title2: '400', + title3: '600', + title4: '400', + headline: '600', + body: '400', + label1: '600', + label2: '400', + caption: '600', + legal: '400', + }, + lineHeight: { + display1: '56px', + display2: '40px', + display3: '36px', + title1: '24px', + title2: '24px', + title3: '20px', + title4: '20px', + headline: '16px', + body: '16px', + label1: '12px', + label2: '16px', + caption: '12px', + legal: '12px', + }, + shadow: { + elevation1: '0px 8px 12px rgba(0, 0, 0, 0.12)', + elevation2: '0px 8px 24px rgba(0, 0, 0, 0.12)', + }, +} as const satisfies ThemeConfig; diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/AlertExample.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/AlertExample.tsx new file mode 100644 index 0000000000..6913fe9ab1 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/AlertExample.tsx @@ -0,0 +1,25 @@ +import { memo, useState } from 'react'; +import { Button } from '@coinbase/cds-web/buttons/Button'; +import { Alert } from '@coinbase/cds-web/overlays/Alert'; + +export const AlertExample = memo(() => { + const [visible, setVisible] = useState(false); + return ( + <> + + setVisible(false)} + onPreferredActionPress={() => setVisible(false)} + onRequestClose={() => setVisible(false)} + preferredActionLabel="Remove" + preferredActionVariant="negative" + title="Remove asset?" + visible={visible} + /> + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Controls.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Controls.tsx new file mode 100644 index 0000000000..818944a0fe --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Controls.tsx @@ -0,0 +1,43 @@ +import { memo, useState } from 'react'; +import { Checkbox } from '@coinbase/cds-web/controls/Checkbox'; +import { Radio } from '@coinbase/cds-web/controls/Radio'; +import { Switch } from '@coinbase/cds-web/controls/Switch'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; + +export const ControlsExample = memo(() => { + const [checkboxValue, setCheckboxValue] = useState(true); + const [radioValue, setRadioValue] = useState('option1'); + const [switchValue, setSwitchValue] = useState(true); + return ( + <> + + setSwitchValue((s) => !s)}> + Switch + + + + + setCheckboxValue((s) => !s)}> + Checkbox + + + + + setRadioValue('option1')} + value="option1" + > + Option 1 + + setRadioValue('option2')} + value="option2" + > + Option 2 + + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx new file mode 100644 index 0000000000..d5b4d0883a --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/DatePicker.tsx @@ -0,0 +1,48 @@ +import { memo, useState } from 'react'; +import { type DateInputValidationError } from '@coinbase/cds-common/dates/DateInputValidationError'; +import { DatePicker } from '@coinbase/cds-web/dates/DatePicker'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; + +export const DatePickerExample = memo(() => { + const [date, setDate] = useState(new Date(2012, 5, 17)); + const [dateError, setDateError] = useState(null); + return ( + + + + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/DropdownExample.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/DropdownExample.tsx new file mode 100644 index 0000000000..77e8740d28 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/DropdownExample.tsx @@ -0,0 +1,34 @@ +import { memo, useState } from 'react'; +import { Button } from '@coinbase/cds-web/buttons/Button'; +import { Dropdown } from '@coinbase/cds-web/dropdown/Dropdown'; +import { MenuItem } from '@coinbase/cds-web/dropdown/MenuItem'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; + +export const DropdownExample = memo(() => { + const [value, setValue] = useState(); + const controlledElementAccessibilityProps = { + id: 'component-config-dropdown-menu', + accessibilityLabel: 'Navigation menu', + }; + + return ( + + Account + Settings + Support + + } + controlledElementAccessibilityProps={{ + id: 'component-config-dropdown-menu', + accessibilityLabel: 'Navigation menu', + }} + onChange={setValue} + value={value} + > + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/ModalExample.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/ModalExample.tsx new file mode 100644 index 0000000000..00a55da9b6 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/ModalExample.tsx @@ -0,0 +1,25 @@ +import { memo, useState } from 'react'; +import { Button } from '@coinbase/cds-web/buttons/Button'; +import { VStack } from '@coinbase/cds-web/layout/VStack'; +import { Modal, ModalBody, ModalFooter, ModalHeader } from '@coinbase/cds-web/overlays'; +import { Text } from '@coinbase/cds-web/typography/Text'; + +export const ModalExample = memo(() => { + const [visible, setVisible] = useState(false); + return ( + <> + + setVisible(false)} visible={visible}> + + + + Are you sure you want to send 0.5 ETH? + + + setVisible(false)}>Confirm} /> + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Pagination.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Pagination.tsx new file mode 100644 index 0000000000..fac99a8915 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Pagination.tsx @@ -0,0 +1,14 @@ +import { memo, useState } from 'react'; +import { Pagination } from '@coinbase/cds-web/pagination/Pagination'; + +export const PaginationExample = memo(() => { + const [activePage, setActivePage] = useState(1); + return ( + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/RollingNumber.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/RollingNumber.tsx new file mode 100644 index 0000000000..86061008dc --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/RollingNumber.tsx @@ -0,0 +1,58 @@ +import { memo, useCallback, useEffect, useState } from 'react'; +import { Icon } from '@coinbase/cds-web/icons/Icon'; +import { RollingNumber } from '@coinbase/cds-web/numbers/RollingNumber'; + +export const RollingNumberExample = memo(() => { + const [{ price, difference }, setPriceState] = useState({ + price: 12345.67, + difference: 0, + }); + const onNext = useCallback( + () => + setPriceState((p) => { + const delta = (Math.random() - 0.5) * 200; // +/- 100 + const next = Math.max(0, p.price + delta); + const price = Math.round(next * 100) / 100; + return { price, difference: price - p.price }; + }), + [], + ); + + useEffect(() => { + onNext(); + const interval = setInterval(() => { + onNext(); + }, 3000); + return () => clearInterval(interval); + }, [onNext]); + + const trendColor = difference >= 0 ? 'fgPositive' : 'fgNegative'; + + return ( + 0 ? 'up ' : difference < 0 ? 'down ' : ''} + color={trendColor} + font="body" + format={{ + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }} + prefix={ + difference >= 0 ? ( + + ) : ( + + ) + } + styles={{ + prefix: { + paddingRight: 'var(--space-1)', + }, + }} + suffix={`(${((Math.abs(difference) / price) * 100).toFixed(2)}%)`} + value={Math.abs(difference)} + /> + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Search.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Search.tsx new file mode 100644 index 0000000000..dd5b6d7cd7 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Search.tsx @@ -0,0 +1,25 @@ +import { memo, useState } from 'react'; +import { SearchInput } from '@coinbase/cds-web/controls/SearchInput'; + +export const SearchExample = memo(() => { + const [searchValue, setSearchValue] = useState(''); + return ( + <> + + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx new file mode 100644 index 0000000000..9b037d7b8d --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/SegmentedTabs.tsx @@ -0,0 +1,28 @@ +import { memo, useState } from 'react'; +import type { TabValue } from '@coinbase/cds-common/tabs/useTabs'; +import { SegmentedTabs } from '@coinbase/cds-web/tabs/SegmentedTabs'; + +import { VStack } from '../../../layout'; + +const tabs = [ + { id: 'buy', label: 'Buy' }, + { id: 'sell', label: 'Sell' }, + { id: 'convert', label: 'Convert' }, +]; + +export const SegmentedTabsExample = memo(() => { + const [activeTab, setActiveTab] = useState(tabs[0]); + + // SegmentedTabs stories disable color-contrast checks in custom/story contexts + + return ( + + + + ); +}); diff --git a/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx new file mode 100644 index 0000000000..c5514d87c9 --- /dev/null +++ b/packages/web/src/__stories__/componentConfigStickerSheet/examples/Select.tsx @@ -0,0 +1,51 @@ +import { memo, useState } from 'react'; +import { Select } from '@coinbase/cds-web/alpha/select'; + +import { VStack } from '../../../layout'; + +const selectOptions = [ + { value: 'option1', label: 'Option 1', description: 'Description' }, + { value: 'option2', label: 'Option 2', description: 'Description' }, + { value: 'option3', label: 'Option 3', description: 'Description' }, + { value: 'option4', label: 'Option 4', description: 'Description' }, + { value: 'option5', label: 'Option 5', description: 'Description' }, + { value: 'option6', label: 'Option 6', description: 'Description' }, +]; + +export const SelectExample = memo(() => { + const [selectValue, setSelectValue] = useState(null); + + // Select stories run with a11y test off due to a known nested-interactive issue + + return ( + + + {}} options={selectOptions} type={type} value={value} /> + ), + }, +); diff --git a/packages/web/src/alpha/combobox/Combobox.tsx b/packages/web/src/alpha/combobox/Combobox.tsx index 51ca6e22cf..6afcdc7a90 100644 --- a/packages/web/src/alpha/combobox/Combobox.tsx +++ b/packages/web/src/alpha/combobox/Combobox.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import Fuse from 'fuse.js'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import type { SelectOptionList } from '../select'; import { DefaultSelectControl } from '../select/DefaultSelectControl'; import type { @@ -60,7 +61,7 @@ export type ComboboxControlProps< Type extends SelectType = 'single', SelectOptionValue extends string = string, > = SelectControlProps & - Pick, 'hideSearchInput'> & { + Pick, 'hideSearchInput' | 'font'> & { /** Search text value */ searchText: string; /** Search text change handler */ @@ -155,15 +156,20 @@ const ComboboxControlContextAdapter = memo( const ComboboxBase = memo( forwardRef( ( - { + _props: ComboboxProps, + ref: React.Ref, + ) => { + const mergedProps = useComponentConfig('Combobox', _props); + const { type = 'single' as Type, value, onChange, options, open: openProp, setOpen: setOpenProp, - placeholder, - accessibilityLabel = 'Combobox control', + label, + accessibilityLabel = typeof label === 'string' ? label : 'Combobox dropdown', + controlAccessibilityLabel = typeof label === 'string' ? label : 'Combobox control', defaultOpen, searchText: searchTextProp, onSearch: onSearchProp, @@ -172,10 +178,9 @@ const ComboboxBase = memo( SelectControlComponent = DefaultSelectControl, ComboboxControlComponent = DefaultComboboxControl, hideSearchInput, + font, ...props - }: ComboboxProps, - ref: React.Ref, - ) => { + } = mergedProps; const [searchTextInternal, setSearchTextInternal] = useState(defaultSearchText); const searchText = searchTextProp ?? searchTextInternal; const setSearchText = onSearchProp ?? setSearchTextInternal; @@ -234,9 +239,10 @@ const ComboboxBase = memo( ComboboxControlComponent={ComboboxControlComponent} SelectControlComponent={SelectControlComponent} controlRef={controlRef} + font={font} /> ), - [SelectControlComponent, ComboboxControlComponent], + [SelectControlComponent, ComboboxControlComponent, font], ); return ( @@ -252,11 +258,12 @@ const ComboboxBase = memo( ref={controlRef} SelectControlComponent={ComboboxControl} accessibilityLabel={accessibilityLabel} + controlAccessibilityLabel={controlAccessibilityLabel} defaultOpen={defaultOpen} + label={label} onChange={handleChange} open={open} options={filteredOptions} - placeholder={placeholder} setOpen={setOpen} type={type} value={value} diff --git a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx index a68310e1a9..7e8449c017 100644 --- a/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx +++ b/packages/web/src/alpha/combobox/DefaultComboboxControl.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { NativeInput } from '../../controls/NativeInput'; import { HStack } from '../../layout'; @@ -25,8 +25,11 @@ export const DefaultComboboxControl = memo( open, setOpen, compact, + align, searchText, onSearch, + font = 'body', + accessibilityLabel, ...props }: ComboboxControlProps) => { const searchInputRef = useRef(null); @@ -54,12 +57,24 @@ export const DefaultComboboxControl = memo( [setOpen], ); + const computedAccessibilityLabel = useMemo(() => { + let label = accessibilityLabel; + if (!hasValue && typeof placeholder === 'string') { + label = `${label}, ${placeholder}`; + } + return label; + }, [hasValue, accessibilityLabel, placeholder]); + return ( { @@ -83,15 +99,15 @@ export const DefaultComboboxControl = memo( }} placeholder={typeof placeholder === 'string' ? placeholder : undefined} style={{ - paddingLeft: 0, - paddingRight: 0, - height: hasValue ? 24 : compact ? 40 : 48, + padding: 0, + height: !hasValue ? (compact ? 40 : 48) : undefined, minWidth: 0, flexGrow: 1, width: '100%', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', + textAlign: align, }} tabIndex={0} value={searchText} @@ -104,9 +120,10 @@ export const DefaultComboboxControl = memo( as="p" color="fgMuted" display="block" - font="body" + font={font} overflow="truncate" paddingY={0} + textAlign={align} > {placeholder} @@ -114,7 +131,6 @@ export const DefaultComboboxControl = memo( ) } - placeholder={null} styles={{ ...props.styles, controlEndNode: { diff --git a/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx b/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx index 9c913bc012..12a1a17675 100644 --- a/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx +++ b/packages/web/src/alpha/combobox/__stories__/Combobox.stories.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useMultiSelect } from '@coinbase/cds-common/select/useMultiSelect'; import { css } from '@linaria/core'; @@ -9,6 +9,7 @@ import { VStack } from '../../../layout/VStack'; import { Text } from '../../../typography/Text'; import type { SelectOptionList } from '../../select'; import type { SelectOption } from '../../select/Select'; +import type { ComboboxProps } from '../Combobox'; import { Combobox, type ComboboxControlComponent, @@ -19,6 +20,10 @@ import { export default { title: 'Components/Alpha/Combobox', component: Combobox, + parameters: { + // Due to the InputChips rendered inside the Select control, there's an a11y violation. + a11y: { test: 'off' }, + }, }; const fruitOptions: SelectOption[] = [ @@ -266,6 +271,122 @@ export const LongPlaceholder = () => { ); }; +export const Alignments = () => { + const [singleValue, setSingleValue] = useState('apple'); + const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ + initialValue: ['apple', 'banana'], + }); + + return ( + + + + + + + + + + + + + + + ); +}; + export const ControlledSearch = () => { const { value, onChange } = useMultiSelect({ initialValue: [] }); const [searchText, setSearchText] = useState(''); @@ -666,7 +787,7 @@ export const RemoveOptionLabel = () => { label="Custom remove label" onChange={onChange} options={fruitOptions} - placeholder="Custom accessibility..." + placeholder="Custom remove label" removeSelectedOptionAccessibilityLabel="Delete" type="multi" value={value} @@ -681,8 +802,9 @@ export const AccessibilityLabel = () => { return ( { ); }; +function getFlagEmoji(cc: string): string { + return cc + .toUpperCase() + .split('') + .map((c) => String.fromCodePoint(0x1f1e6 - 65 + c.charCodeAt(0))) + .join(''); +} + +const countrySelectionOptions: SelectOptionList<'multi'> = [ + { + label: 'North America', + options: [ + { value: 'us', label: `${getFlagEmoji('us')} United States` }, + { value: 'ca', label: `${getFlagEmoji('ca')} Canada` }, + { value: 'mx', label: `${getFlagEmoji('mx')} Mexico` }, + ], + }, + { + label: 'Europe', + options: [ + { value: 'uk', label: `${getFlagEmoji('gb')} United Kingdom` }, + { value: 'fr', label: `${getFlagEmoji('fr')} France` }, + { value: 'de', label: `${getFlagEmoji('de')} Germany` }, + ], + }, + { + label: 'Asia', + options: [ + { value: 'jp', label: `${getFlagEmoji('jp')} Japan` }, + { value: 'cn', label: `${getFlagEmoji('cn')} China` }, + { value: 'in', label: `${getFlagEmoji('in')} India` }, + ], + }, +]; + +export const CountrySelectionExample = () => { + const { value, onChange } = useMultiSelect({ initialValue: [] }); + + return ( + + ); +}; + +const CREATE_OPTION_PREFIX = '__create__'; + +type FreeSoloComboboxProps< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +> = Omit< + React.ComponentProps, + 'options' | 'searchText' | 'onSearch' | 'onChange' +> & { + freeSolo?: boolean; + options: SelectOption[]; + value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null; + onChange: (value: Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null) => void; +}; + +function FreeSoloCombobox< + Type extends 'single' | 'multi' = 'multi', + SelectOptionValue extends string = string, +>({ + freeSolo = false, + options: initialOptions, + value, + onChange, + placeholder = 'Search or type to add...', + ...comboboxProps +}: FreeSoloComboboxProps) { + const [searchText, setSearchText] = useState(''); + const [options, setOptions] = useState(initialOptions); + + useEffect(() => { + if (!freeSolo) return; + const initialSet = new Set(initialOptions.map((o) => o.value)); + const valueSet = new Set(Array.isArray(value) ? value : value != null ? [value] : []); + setOptions((prev) => { + const addedStillSelected = prev.filter( + (o) => !initialSet.has(o.value) && valueSet.has(o.value as string), + ); + return [...initialOptions, ...addedStillSelected]; + }); + }, [value, freeSolo, initialOptions]); + + const optionsWithCreate = useMemo(() => { + if (!freeSolo) return options; + const trimmed = searchText.trim(); + if (!trimmed) return options; + const alreadyExists = options.some( + (o) => typeof o.label === 'string' && o.label.toLowerCase() === trimmed.toLowerCase(), + ); + if (alreadyExists) return options; + return [...options, { value: `${CREATE_OPTION_PREFIX}${trimmed}`, label: `Add "${trimmed}"` }]; + }, [options, searchText, freeSolo]); + + const handleChange = useCallback( + (newValue: string | string[] | null) => { + if (!freeSolo) { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + return; + } + + const values = Array.isArray(newValue) ? newValue : newValue ? [newValue] : []; + const createValue = values.find((v) => String(v).startsWith(CREATE_OPTION_PREFIX)); + + if (createValue) { + const newLabel = String(createValue).slice(CREATE_OPTION_PREFIX.length); + const newOption: SelectOption = { value: newLabel.toLowerCase(), label: newLabel }; + setOptions((prev) => [...prev, newOption]); + const updatedValues = values + .filter((v) => !String(v).startsWith(CREATE_OPTION_PREFIX)) + .concat(newOption.value as string); + + if (comboboxProps.type === 'multi') { + onChange(updatedValues as Type extends 'multi' ? SelectOptionValue[] : never); + } else { + onChange( + newOption.value as SelectOptionValue as Type extends 'multi' + ? never + : SelectOptionValue | null, + ); + } + setSearchText(''); + } else { + onChange(newValue as Type extends 'multi' ? SelectOptionValue[] : SelectOptionValue | null); + } + }, + [onChange, freeSolo, comboboxProps.type], + ); + + const effectiveOptions = freeSolo ? optionsWithCreate : initialOptions; + const effectiveSearchProps = freeSolo ? { searchText, onSearch: setSearchText } : {}; + + return ( + + ); +} + +export const FreeSoloComboboxExample = () => { + const [standardSingleValue, setStandardSingle] = useState(null); + const [freeSoloSingleValue, setFreeSoloSingle] = useState(null); + const standardMulti = useMultiSelect({ initialValue: [] }); + const freeSoloMulti = useMultiSelect({ initialValue: [] }); + + const baseOptions = fruitOptions.slice(0, 6); + + return ( + + + freeSolo={false} + label="Standard single" + onChange={setStandardSingle} + options={baseOptions} + placeholder="Search fruits..." + type="single" + value={standardSingleValue} + /> + + freeSolo + label="FreeSolo single" + onChange={setFreeSoloSingle} + options={baseOptions} + placeholder="Search or type to add..." + type="single" + value={freeSoloSingleValue} + /> + + + + ); +}; + const CustomComponent: ComboboxControlComponent = (props) => { return ; }; diff --git a/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx b/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx index a037763b03..3788d80c6a 100644 --- a/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx +++ b/packages/web/src/alpha/combobox/__tests__/Combobox.test.tsx @@ -23,20 +23,6 @@ const defaultProps: ComboboxProps<'single' | 'multi'> = { label: 'Test Combobox', }; -// Mock floating-ui to simplify testing -jest.mock('@floating-ui/react-dom', () => ({ - useFloating: () => ({ - refs: { - setReference: jest.fn(), - setFloating: jest.fn(), - reference: { current: null }, - floating: { current: null }, - }, - floatingStyles: {}, - }), - flip: () => ({}), -})); - jest.mock('../../../overlays/Portal', () => ({ Portal: ({ children }: { children: React.ReactNode }) => (
    {children}
    @@ -56,7 +42,7 @@ describe('Combobox', () => { , ); - expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); }); it('displays placeholder in search input when no value is selected', () => { @@ -67,8 +53,8 @@ describe('Combobox', () => { ); // The placeholder is shown in the input, not as text - const button = screen.getByRole('button'); - fireEvent.click(button); + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); const input = screen.getByRole('textbox'); expect(input).toHaveAttribute('placeholder', 'Search and select...'); @@ -96,9 +82,9 @@ describe('Combobox', () => { , ); - expect(screen.getByLabelText('Custom combobox')).toBeTruthy(); - const button = screen.getByRole('button'); - fireEvent.click(button); + expect(screen.getByLabelText('Custom combobox, Search and select...')).toBeTruthy(); + const combobox = screen.getByRole('combobox'); + fireEvent.click(combobox); expect(screen.getByLabelText('Combobox menu')).toBeTruthy(); }); }); @@ -115,6 +101,26 @@ describe('Combobox', () => { expect(input).toBeInTheDocument(); }); + it('passes font to the search input', () => { + render( + + + , + ); + + expect(screen.getByRole('textbox')).toHaveStyle('font-size: var(--fontSize-label1);'); + }); + + it('zeros NativeInput padding on the combobox search field', () => { + render( + + + , + ); + + expect(screen.getByRole('textbox')).toHaveStyle({ padding: '0px' }); + }); + it('filters options based on search text', async () => { render( @@ -243,8 +249,8 @@ describe('Combobox', () => { , ); - const button = screen.getByRole('button'); - await userEvent.click(button); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); await waitFor(() => { expect(screen.getByRole('listbox')).toBeInTheDocument(); @@ -258,8 +264,8 @@ describe('Combobox', () => { , ); - const button = screen.getByRole('button'); - await userEvent.click(button); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); const input = screen.getByRole('textbox'); @@ -278,8 +284,8 @@ describe('Combobox', () => { , ); - const button = screen.getByRole('button'); - await userEvent.click(button); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); const input = screen.getByRole('textbox'); fireEvent.keyDown(input, { key: 'Enter' }); @@ -297,8 +303,8 @@ describe('Combobox', () => { , ); - const button = screen.getByRole('button'); - await userEvent.click(button); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); expect(setOpenMock).toHaveBeenCalled(); }); @@ -436,8 +442,8 @@ describe('Combobox', () => { expect(ref.current?.open).toBe(false); - const button = screen.getByRole('button'); - await userEvent.click(button); + const combobox = screen.getByRole('combobox'); + await userEvent.click(combobox); await waitFor(() => { expect(ref.current?.open).toBe(true); @@ -468,8 +474,8 @@ describe('Combobox', () => { ); // Verify basic accessibility - has a button that can be focused - const button = screen.getByRole('button'); - expect(button).toBeInTheDocument(); + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeInTheDocument(); }); it('has appropriate ARIA attributes', () => { @@ -499,8 +505,8 @@ describe('Combobox', () => { , ); - const button = screen.getByRole('button'); - expect(button).toBeEnabled(); + const combobox = screen.getByRole('combobox'); + expect(combobox).toBeEnabled(); }); it('uses custom SelectControlComponent when provided', () => { diff --git a/packages/web/src/alpha/data-card/DataCard.tsx b/packages/web/src/alpha/data-card/DataCard.tsx new file mode 100644 index 0000000000..f12a033f11 --- /dev/null +++ b/packages/web/src/alpha/data-card/DataCard.tsx @@ -0,0 +1,84 @@ +import { forwardRef, memo } from 'react'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import { CardRoot, type CardRootBaseProps } from '../../cards/CardRoot'; +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; + +import { DataCardLayout, type DataCardLayoutProps } from './DataCardLayout'; + +export type DataCardBaseProps = Polymorphic.ExtendableProps< + Omit, + DataCardLayoutProps & { + classNames?: { + /** Root element */ + root?: string; + }; + styles?: { + /** Root element */ + root?: React.CSSProperties; + }; + } +>; + +export type DataCardProps = Polymorphic.Props< + AsComponent, + DataCardBaseProps +>; + +type DataCardComponent = (( + props: DataCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +const dataCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + flexDirection: 'row' as const, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +export const DataCard: DataCardComponent = memo( + forwardRef, DataCardBaseProps>( + ( + { + title, + subtitle, + titleAccessory, + thumbnail, + visualization, + layout, + slotProps, + as, + children, + className, + style, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + styles: { root: rootStyle, ...layoutStyles } = {}, + ...props + }: DataCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + {children} + + + ), + ), +); diff --git a/packages/web/src/alpha/data-card/DataCardLayout.tsx b/packages/web/src/alpha/data-card/DataCardLayout.tsx new file mode 100644 index 0000000000..6c6e516311 --- /dev/null +++ b/packages/web/src/alpha/data-card/DataCardLayout.tsx @@ -0,0 +1,129 @@ +import React, { memo, useMemo } from 'react'; + +import { Box, HStack, VStack } from '../../layout'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography'; + +export type DataCardLayoutBaseProps = { + /** Text or React node to display as the card title. Use a Text component to override default color and font. */ + title: React.ReactNode; + /** Text or React node to display as the card subtitle. Use a Text component to override default color and font. */ + subtitle?: React.ReactNode; + /** React node to display as a title accessory. */ + titleAccessory?: React.ReactNode; + /** React node to display as a thumbnail in the header area. */ + thumbnail?: React.ReactNode; + /** Layout orientation of the card. Horizontal places header and visualization side by side, vertical stacks them. + * @default 'vertical' + */ + layout: 'horizontal' | 'vertical'; + /** Child node to display as the visualization (e.g., ProgressBar or ProgressCircle). */ + children?: React.ReactNode; +}; + +export type DataCardLayoutProps = DataCardLayoutBaseProps & { + classNames?: { + /** Layout container element */ + layoutContainer?: string; + /** Header container element */ + headerContainer?: string; + /** Text container element */ + textContainer?: string; + /** Title container element */ + titleContainer?: string; + }; + styles?: { + /** Layout container element */ + layoutContainer?: React.CSSProperties; + /** Header container element */ + headerContainer?: React.CSSProperties; + /** Text container element */ + textContainer?: React.CSSProperties; + /** Title container element */ + titleContainer?: React.CSSProperties; + }; +}; + +export const DataCardLayout = memo( + ({ + title, + subtitle, + titleAccessory, + thumbnail, + layout = 'vertical', + classNames = {}, + styles = {}, + children, + }: DataCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo(() => { + if (typeof subtitle === 'string') { + return ( + + {subtitle} + + ); + } + return subtitle; + }, [subtitle]); + + const layoutContainerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? 'row' : 'column', + gap: layout === 'horizontal' ? 2 : 1, + padding: 2, + } as const; + }, [layout]); + + const headerSpacingProps = useMemo(() => { + return { + flexDirection: layout === 'horizontal' ? 'column' : 'row', + gap: layout === 'horizontal' ? 2 : 1.5, + alignItems: layout === 'horizontal' ? 'flex-start' : 'center', + justifyContent: layout === 'horizontal' ? 'space-between' : 'flex-start', + } as const; + }, [layout]); + + return ( + + + {thumbnail} + + {subtitleNode} + + {titleNode} + {titleAccessory} + + + + {children} + + ); + }, +); diff --git a/packages/web/src/alpha/data-card/__figma__/DataCard.figma.tsx b/packages/web/src/alpha/data-card/__figma__/DataCard.figma.tsx new file mode 100644 index 0000000000..059d82e8cd --- /dev/null +++ b/packages/web/src/alpha/data-card/__figma__/DataCard.figma.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { figma } from '@figma/code-connect'; + +import { Avatar } from '../../../media'; +import { DataCard } from '../DataCard'; + +figma.connect( + DataCard, + 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-17832&m=dev', + { + imports: [ + "import { DataCard } from '@coinbase/cds-web/alpha/data-card'", + "import { Avatar } from '@coinbase/cds-web/media/Avatar'", + ], + props: { + subtitle: figma.boolean('show subtitle', { + true: figma.string('Subtitle'), + false: undefined, + }), + thumbnail: figma.boolean('show media', { + true: figma.instance('↳ media'), + false: undefined, + }), + }, + example: ({ thumbnail, subtitle }) => ( + + {/* visualization */} + + ), + }, +); diff --git a/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx new file mode 100644 index 0000000000..7242dac7ed --- /dev/null +++ b/packages/web/src/alpha/data-card/__stories__/DataCard.stories.tsx @@ -0,0 +1,339 @@ +import React, { useRef } from 'react'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; +import { + ProgressBar, + ProgressBarWithFixedLabels, + ProgressCircle, +} from '@coinbase/cds-web/visualizations'; + +import { Box } from '../../../layout/Box'; +import { VStack } from '../../../layout/VStack'; +import { RemoteImage } from '../../../media'; +import { Text } from '../../../typography'; +import { DataCard } from '../DataCard'; + +const exampleThumbnail = ( + +); + +const renderProgressLabel = (num: number) => ( + + {num}% + +); + +// Basic Examples +export const BasicExamples = (): JSX.Element => { + return ( + + + ↗ 25.25% + + } + > + + + + + + + + ↘ 3.12% + + } + > + + + + + + ↘ 1.8% + + } + > + + + + + + ); +}; + +// Features +export const Features = (): JSX.Element => { + return ( + + + ↗ 25.25% + + } + > + + + + + + + + ↘ 5.2% + + } + > + + + + + + + + + + + ); +}; + +// Interactive +export const Interactive = (): JSX.Element => { + const ref1 = useRef(null); + const ref2 = useRef(null); + return ( + + alert('Progress bar card clicked!')} + subtitle="Clickable progress card" + thumbnail={exampleThumbnail} + title="Progress Bar with Button" + titleAccessory={ + + ↗ 8.5% + + } + > + + + + + + + + ↗ 8.5% + + } + > + + + + + + ); +}; + +// Style Overrides +export const StyleOverrides = (): JSX.Element => { + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +// Multiple Cards +export const MultipleCards = (): JSX.Element => { + return ( + + + + + + + + + + ↗ 25.25% + + } + > + + + + + + ); +}; + +export default { + title: 'Components/Alpha/DataCard', + component: DataCard, +}; diff --git a/packages/web/src/alpha/data-card/__tests__/DataCard.test.tsx b/packages/web/src/alpha/data-card/__tests__/DataCard.test.tsx new file mode 100644 index 0000000000..bc19ec896a --- /dev/null +++ b/packages/web/src/alpha/data-card/__tests__/DataCard.test.tsx @@ -0,0 +1,135 @@ +import { renderA11y } from '@coinbase/cds-web-utils/jest'; +import { render, screen } from '@testing-library/react'; + +import { Avatar } from '../../../media/Avatar'; +import { Tag } from '../../../tag/Tag'; +import { DefaultThemeProvider } from '../../../utils/test'; +import { ProgressBar } from '../../../visualizations/ProgressBar'; +import { DataCard } from '../DataCard'; + +const exampleProps = { + title: 'Test Title', + layout: 'vertical' as const, +}; + +describe('DataCard', () => { + it('passes accessibility for vertical layout', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('passes accessibility for horizontal layout', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('passes accessibility with all props', async () => { + expect( + await renderA11y( + + } + titleAccessory={New} + > + + + , + ), + ).toHaveNoViolations(); + }); + + it('renders the card with the correct title', () => { + render( + + + , + ); + expect(screen.getByText(exampleProps.title)).toBeInTheDocument(); + }); + + it('renders the card with the correct subtitle', () => { + render( + + + , + ); + expect(screen.getByText('Test Subtitle')).toBeInTheDocument(); + }); + + it('renders thumbnail content', () => { + render( + + Thumb
    } /> + , + ); + expect(screen.getByTestId('test-thumbnail')).toBeInTheDocument(); + }); + + it('renders titleAccessory content', () => { + render( + + Accessory} + /> + , + ); + expect(screen.getByTestId('test-accessory')).toBeInTheDocument(); + }); + + it('renders children (visualization)', () => { + render( + + +
    Visualization
    +
    +
    , + ); + expect(screen.getByTestId('test-visualization')).toBeInTheDocument(); + }); + + it('renders custom title node', () => { + render( + + Custom Title} /> + , + ); + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + }); + + it('renders custom subtitle node', () => { + render( + + Custom Subtitle} + /> + , + ); + expect(screen.getByTestId('custom-subtitle')).toBeInTheDocument(); + }); + + it('renders with horizontal layout', () => { + render( + + +
    Visualization
    +
    +
    , + ); + expect(screen.getByText(exampleProps.title)).toBeInTheDocument(); + expect(screen.getByTestId('test-visualization')).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/alpha/data-card/index.ts b/packages/web/src/alpha/data-card/index.ts new file mode 100644 index 0000000000..50c49a1911 --- /dev/null +++ b/packages/web/src/alpha/data-card/index.ts @@ -0,0 +1,2 @@ +export type { DataCardBaseProps, DataCardProps } from './DataCard'; +export { DataCard } from './DataCard'; diff --git a/packages/web/src/alpha/index.ts b/packages/web/src/alpha/index.ts index 5c733d31c1..5f86562948 100644 --- a/packages/web/src/alpha/index.ts +++ b/packages/web/src/alpha/index.ts @@ -1,3 +1,4 @@ export * from './combobox'; +export * from './data-card'; export * from './select'; export * from './select-chip'; diff --git a/packages/web/src/alpha/select-chip/SelectChip.tsx b/packages/web/src/alpha/select-chip/SelectChip.tsx index 436847691b..af6fa3eed2 100644 --- a/packages/web/src/alpha/select-chip/SelectChip.tsx +++ b/packages/web/src/alpha/select-chip/SelectChip.tsx @@ -1,6 +1,7 @@ import React, { forwardRef, memo, useMemo } from 'react'; import type { ChipBaseProps } from '../../chips'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import type { PressableBaseProps } from '../../system/Pressable'; import { Select, type SelectRef } from '../select/Select'; import type { SelectControlProps, SelectProps, SelectType } from '../select/types'; @@ -72,15 +73,11 @@ function createSelectChipControlWrapper< const SelectChipComponent = memo( forwardRef( ( - { - invertColorScheme, - numberOfLines, - maxWidth, - displayValue, - ...props - }: SelectChipProps, + _props: SelectChipProps, ref: React.Ref, ) => { + const mergedProps = useComponentConfig('SelectChip', _props); + const { invertColorScheme, numberOfLines, maxWidth, displayValue, ...props } = mergedProps; const WrappedSelectChipControl = useMemo( () => createSelectChipControlWrapper({ diff --git a/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx b/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx index 335f24a7c1..cf90e43a67 100644 --- a/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx +++ b/packages/web/src/alpha/select-chip/__tests__/SelectChip.test.tsx @@ -8,19 +8,6 @@ import type { SelectOption } from '../../select/types'; import type { SelectChipProps } from '../SelectChip'; import { SelectChip } from '../SelectChip'; -jest.mock('@floating-ui/react-dom', () => ({ - useFloating: () => ({ - refs: { - setReference: jest.fn(), - setFloating: jest.fn(), - reference: { current: null }, - floating: { current: null }, - }, - floatingStyles: {}, - }), - flip: () => ({}), -})); - jest.mock('../../../overlays/Portal', () => ({ Portal: ({ children, containerId }: { children: React.ReactNode; containerId?: string }) => (
    {children}
    diff --git a/packages/web/src/alpha/select/DefaultSelectControl.tsx b/packages/web/src/alpha/select/DefaultSelectControl.tsx index 17e12f5cf5..2c93074c3f 100644 --- a/packages/web/src/alpha/select/DefaultSelectControl.tsx +++ b/packages/web/src/alpha/select/DefaultSelectControl.tsx @@ -35,6 +35,15 @@ const noFocusOutlineCss = css` } `; +const selectedOptionChipContentCss = css` + min-width: 0; + + & > :not(:last-child) { + min-width: 0; + max-width: 100%; + } +`; + const variantColor: Record = { foreground: 'fg', positive: 'fgPositive', @@ -48,13 +57,16 @@ type DefaultSelectControlBase = < Type extends SelectType, SelectOptionValue extends string = string, >( - props: SelectControlProps & { ref?: React.Ref }, + props: SelectControlProps & { + ref?: React.Ref; + }, ) => React.ReactElement; const DefaultSelectControlComponent = memo( forwardRef( ( { + role = 'button', type, options, value, @@ -72,6 +84,8 @@ const DefaultSelectControlComponent = memo( endNode: customEndNode, compact, blendStyles, + align = 'start', + font = 'body', bordered = true, borderWidth = bordered ? 100 : 0, focusedBorderWidth = bordered ? undefined : 200, @@ -81,6 +95,7 @@ const DefaultSelectControlComponent = memo( accessibilityLabel, ariaHaspopup, tabIndex = 0, + onKeyDown, styles, classNames, ...props @@ -93,7 +108,6 @@ const DefaultSelectControlComponent = memo( const isMultiSelect = type === 'multi'; const shouldShowCompactLabel = compact && label && !isMultiSelect; const hasValue = value !== null && !(Array.isArray(value) && value.length === 0); - // Map of options to their values // If multiple options share the same value, the first occurrence wins (matches native HTML select behavior) const optionsMap = useMemo(() => { @@ -140,6 +154,37 @@ const DefaultSelectControlComponent = memo( return map; }, [options]); + const singleValueContent = useMemo(() => { + const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined; + const label = option?.label ?? option?.description ?? option?.value ?? placeholder; + return hasValue ? label : placeholder; + }, [hasValue, isMultiSelect, optionsMap, placeholder, value]); + + const computedControlAccessibilityLabel = useMemo(() => { + // For multi-select, set the label to the content of each selected value and the hidden selected options label + if (isMultiSelect) { + const selectedValues = (value as SelectOptionValue[]) + .map((v) => { + const option = optionsMap.get(v); + return option?.label ?? option?.description ?? option?.value ?? v; + }) + .slice(0, maxSelectedOptionsToShow) + .join(', '); + return `${accessibilityLabel}, ${(value as SelectOptionValue[]).length > 0 ? selectedValues : (placeholder ?? '')}${(value as SelectOptionValue[]).length > maxSelectedOptionsToShow ? ', ' + hiddenSelectedOptionsLabel : ''}`; + } + // If value is React node, fallback to only using passed in accessibility label + return `${accessibilityLabel ?? ''}${typeof singleValueContent === 'string' ? ', ' + singleValueContent : ''}`; + }, [ + accessibilityLabel, + hiddenSelectedOptionsLabel, + isMultiSelect, + maxSelectedOptionsToShow, + optionsMap, + placeholder, + singleValueContent, + value, + ]); + const controlPressableRef = useRef(null); const valueNodeContainerRef = useRef(null); const handleUnselectValue = useCallback( @@ -248,12 +293,15 @@ const DefaultSelectControlComponent = memo( data-selected-value accessibilityLabel={`${removeSelectedOptionAccessibilityLabel} ${accessibilityLabel}`} borderWidth={0} + classNames={{ content: selectedOptionChipContentCss }} disabled={option.disabled} invertColorScheme={false} maxWidth={200} onClick={(event) => handleUnselectValue(event, index)} > - {option.label ?? option.description ?? option.value ?? ''} + + {option.label ?? option.description ?? option.value ?? ''} + ); })} @@ -266,43 +314,44 @@ const DefaultSelectControlComponent = memo( ); } - const option = !isMultiSelect ? optionsMap.get(value as SelectOptionValue) : undefined; - const label = option?.label ?? option?.description ?? option?.value ?? placeholder; - const content = hasValue ? label : placeholder; - return typeof content === 'string' ? ( + return typeof singleValueContent === 'string' ? ( - {content} + {singleValueContent} ) : ( - content + singleValueContent ); }, [ hasValue, isMultiSelect, - optionsMap, - placeholder, + singleValueContent, + font, + align, value, maxSelectedOptionsToShow, hiddenSelectedOptionsLabel, + optionsMap, removeSelectedOptionAccessibilityLabel, handleUnselectValue, ]); const inputNode = useMemo( () => ( - // We don't offer control over setting the role since this must always be a button setOpen((s) => !s)} + onKeyDown={onKeyDown} paddingStart={1} + role={role} style={styles?.controlInputNode} tabIndex={tabIndex} > @@ -353,7 +404,7 @@ const DefaultSelectControlComponent = memo( > ), [ - accessibilityLabel, + computedControlAccessibilityLabel, ariaHaspopup, + open, + role, interactableBlendStyles, classNames?.controlInputNode, classNames?.controlStartNode, @@ -386,9 +439,11 @@ const DefaultSelectControlComponent = memo( styles?.controlStartNode, styles?.controlValueNode, tabIndex, + onKeyDown, startNode, shouldShowCompactLabel, labelNode, + align, isMultiSelect, valueNode, contentNode, diff --git a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx index 86186bc977..3a8068c5f0 100644 --- a/packages/web/src/alpha/select/DefaultSelectDropdown.tsx +++ b/packages/web/src/alpha/select/DefaultSelectDropdown.tsx @@ -251,6 +251,15 @@ const DefaultSelectDropdownComponent = memo( ); const handleEscPress = useCallback(() => setOpen(false), [setOpen]); + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + setOpen(false); + } + }, + [setOpen], + ); useEffect(() => { if (!controlRef.current) return; @@ -274,12 +283,14 @@ const DefaultSelectDropdownComponent = memo( aria-multiselectable={isMultiSelect} className={cx(classNames?.root, className)} display="block" + onKeyDown={handleKeyDown} role={accessibilityRoles?.dropdown} style={dropdownStyles} {...props} > ( - { + _props: SelectProps, + ref: React.Ref, + ) => { + const mergedProps = useComponentConfig('Select', _props); + const { value, type = 'single' as Type, options, @@ -74,9 +88,9 @@ const SelectBase = memo( compact, label, labelVariant, - accessibilityLabel = 'Select control', + accessibilityLabel = typeof label === 'string' ? label : 'Select dropdown', accessibilityRoles = defaultAccessibilityRoles, - controlAccessibilityLabel, + controlAccessibilityLabel = typeof label === 'string' ? label : 'Select control', selectAllLabel, emptyOptionsLabel, clearAllLabel, @@ -91,6 +105,8 @@ const SelectBase = memo( accessory, media, end, + align, + font, bordered = true, SelectOptionComponent = DefaultSelectOption, SelectAllOptionComponent = DefaultSelectAllOption, @@ -103,9 +119,7 @@ const SelectBase = memo( className, classNames, testID, - }: SelectProps, - ref: React.Ref, - ) => { + } = mergedProps; const hasMounted = useHasMounted(); const [openInternal, setOpenInternal] = useState(defaultOpen ?? false); const open = openProp ?? openInternal; @@ -131,6 +145,43 @@ const SelectBase = memo( excludeRefs: [refs.reference as React.MutableRefObject], }); + const pendingTypeAheadKeyRef = useRef(null); + + const handleControlKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (disabled || open) return; + if (event.ctrlKey || event.metaKey || event.altKey) return; + + const key = event.key; + if (/^[a-z]$/.test(key)) { + pendingTypeAheadKeyRef.current = key; + setOpen(true); + } + }, + [disabled, open, setOpen], + ); + + useEffect(() => { + if (!open || !pendingTypeAheadKeyRef.current) return; + + const key = pendingTypeAheadKeyRef.current; + pendingTypeAheadKeyRef.current = null; + + const floatingEl = refs.floating.current; + if (!floatingEl) return; + + const optionRole = accessibilityRoles?.option ?? 'option'; + const options = floatingEl.querySelectorAll(`[role="${optionRole}"]`); + const matchingOption = Array.from(options).find((option) => { + const firstLetterMatch = option.textContent?.match(/[a-z]/i); + return firstLetterMatch?.[0]?.toLowerCase() === key; + }); + + if (matchingOption) { + (matchingOption as HTMLElement).focus(); + } + }, [open, refs.floating, accessibilityRoles?.option]); + const rootStyles = useMemo( () => ({ ...style, @@ -253,6 +304,7 @@ const SelectBase = memo( { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2'], @@ -58,14 +69,14 @@ export const Default = () => { export const Compact = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2'], @@ -87,10 +98,10 @@ export const Compact = () => { export const InsideLabelVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '2', '3', '4'], @@ -112,16 +123,16 @@ export const InsideLabelVariant = () => { export const CompactManySelected = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, - { value: '10', label: 'Option 10' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, + { value: '10', label: 'Kiwi' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1', '3', '7', '8', '9', '10'], @@ -143,14 +154,14 @@ export const CompactManySelected = () => { export const HideSelectAll = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -169,17 +180,94 @@ export const HideSelectAll = () => { ); }; +export const Alignments = () => { + const exampleOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + ]; + const { value, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + + + + + + ); +}; + export const CustomSelectAllLabel = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -204,14 +292,14 @@ export const CustomClearAllLabel = () => { }); const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; return ( @@ -230,14 +318,14 @@ export const CustomClearAllLabel = () => { export const CustomSelectAllOption = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const { value, onChange } = useMultiSelect({ initialValue: ['1'], @@ -284,17 +372,41 @@ export const CustomSelectAllOption = () => { ); }; +export const LongOptionLabels = () => { + const exampleOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Fraction fraction fraction fraction fraction' }, + { value: '2', label: 'Truncation truncation truncation truncation truncation' }, + { value: '3', label: 'A A A A A A A A A A A A A A A A' }, + { value: '4', label: 'Bee Bee Bee Bee Bee Bee Bee Bee Bee Bee' }, + ]; + const { value, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + { export const AccessibilityRoles = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -320,18 +329,82 @@ export const AccessibilityRoles = () => { ); }; +export const Alignments = () => { + const exampleOptions = [ + { value: null, label: 'Remove selection' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + ]; + const [value, setValue] = useState('1'); + + return ( + + + + + + ); +}; + export const NoLabel = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -349,14 +422,14 @@ export const NoLabel = () => { export const Disabled = () => { const exampleOptionsWithDescription = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Description 1' }, - { value: '2', label: 'Option 2', description: 'Description 2' }, - { value: '3', label: 'Option 3', description: 'Description 3' }, - { value: '4', label: 'Option 4', description: 'Description 4' }, - { value: '5', label: 'Option 5', description: 'Description 5' }, - { value: '6', label: 'Option 6', description: 'Description 6' }, - { value: '7', label: 'Option 7', description: 'Description 7' }, - { value: '8', label: 'Option 8', description: 'Description 8' }, + { value: '1', label: 'Apple', description: 'Crisp and sweet' }, + { value: '2', label: 'Banana', description: 'Bright and yellow' }, + { value: '3', label: 'Cherry', description: 'Dark and tart' }, + { value: '4', label: 'Date', description: 'Dense and sweet' }, + { value: '5', label: 'Elderberry', description: 'Earthy and rich' }, + { value: '6', label: 'Fig', description: 'Fresh and jammy' }, + { value: '7', label: 'Grape', description: 'Juicy clusters' }, + { value: '8', label: 'Honeydew', description: 'Honeyed melon' }, ]; const [value, setValue] = useState('1'); @@ -372,13 +445,28 @@ export const Disabled = () => { ); }; +Disabled.parameters = { + a11y: { + options: { + /** + * Color contrast ratio doesn't need to meet 4.5:1, as the element is disabled. + * Use axe run options (instead of config.rules) to reliably disable this rule. + * @link https://dequeuniversity.com/rules/axe/4.3/color-contrast + */ + rules: { + 'color-contrast': { enabled: false }, + }, + }, + }, +}; + export const DisabledOptions = () => { const exampleOptionsWithSomeDisabled = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', disabled: true }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4', disabled: true }, + { value: '1', label: 'Apple', disabled: true }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date', disabled: true }, ]; const [value, setValue] = useState('1'); @@ -395,14 +483,14 @@ export const DisabledOptions = () => { export const WithoutNull = () => { const exampleOptionsWithoutNull = [ - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, ]; const [value, setValue] = useState(null); @@ -421,28 +509,28 @@ export const OptionsAsReactNodes = () => { const exampleOptionsWithReactNodes = [ { value: '1', - label: Option 1, - description: Description 1, + label: Apple, + description: Crisp and sweet, }, { value: '2', - label: 'Option 2', + label: 'Banana', description: 'Not a react node', }, { value: '3', - label: Option 3, - description: Description 3, + label: Cherry, + description: Dark and tart, }, { value: '4', - label: 'Option 4', + label: 'Date', description: 'Not a react node', }, { value: '5', - label: Option 5, - description: Description 5, + label: Elderberry, + description: Earthy and rich, }, ]; const [value, setValue] = useState('1'); @@ -463,20 +551,20 @@ export const MixedDefaultAndCustomComponentOptions = () => { const CustomOptionComponent: SelectOptionComponent = ({ value, onClick }) => { return ( - + - + ); }; const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', Component: CustomOptionComponent }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3', Component: CustomOptionComponent }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple', Component: CustomOptionComponent }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry', Component: CustomOptionComponent }, + { value: '4', label: 'Date' }, ]; const [value, setValue] = useState('1'); @@ -495,15 +583,15 @@ export const MixedDefaultAndCustomComponentOptions = () => { export const StartNode = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -522,15 +610,15 @@ export const StartNode = () => { export const CustomEndNode = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -549,15 +637,15 @@ export const CustomEndNode = () => { export const CustomAccessory = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -576,15 +664,15 @@ export const CustomAccessory = () => { export const CustomMedia = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -604,31 +692,31 @@ export const UniqueAccessoryAndMedia = () => { const exampleOptionsWithCustomAccessoriesAndMedia = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -649,9 +737,9 @@ export const UniqueAccessoryAndMedia = () => { export const UniqueEndNodeForEachOption = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', end: }, - { value: '2', label: 'Option 2', end: }, - { value: '3', label: 'Option 3', end: }, + { value: '1', label: 'Apple', end: }, + { value: '2', label: 'Banana', end: }, + { value: '3', label: 'Cherry', end: }, ]; const [value, setValue] = useState('1'); @@ -669,15 +757,15 @@ export const UniqueEndNodeForEachOption = () => { export const PositiveVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -697,15 +785,15 @@ export const PositiveVariant = () => { export const NegativeVariant = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -725,15 +813,15 @@ export const NegativeVariant = () => { export const CustomStyles = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -770,15 +858,15 @@ export const CustomStyles = () => { export const CustomClassNames = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -802,15 +890,15 @@ export const Typed = () => { const typedOptions: SelectOption[] = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -828,15 +916,15 @@ export const Typed = () => { export const DefaultOpen = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -852,18 +940,27 @@ export const DefaultOpen = () => { ); }; +DefaultOpen.parameters = { + a11y: { + options: { + rules: { + 'color-contrast': { enabled: false }, + }, + }, + }, +}; export const DisabledClickOutsideClose = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -882,18 +979,18 @@ export const DisabledClickOutsideClose = () => { export const ControlledOpen = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); - const [open, setOpen] = useState(true); + const [open, setOpen] = useState(false); return (
    @@ -987,14 +1084,12 @@ export const VeryLongLabels = () => { }, { value: '4', - label: 'A moderately long label that is somewhere between short and extremely long', - description: - 'A moderately long description that is somewhere between short and extremely long', + label: 'Moderately long label that is somewhere between short and extremely long', + description: 'Moderately long description that is somewhere between short and extremely long', }, { value: '5', - description: - 'This is a very long description that is somewhere between short and extremely long', + description: 'Distinctly long description that is somewhere between short and extremely long', }, ]; const [value, setValue] = useState('1'); @@ -1039,11 +1134,11 @@ export const VeryLongLabels = () => { export const MixedOptionsWithAndWithoutDescriptions = () => { const mixedOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1', description: 'Has description' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3', description: 'Also has description' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5', description: 'Another description' }, + { value: '1', label: 'Apple', description: 'Has description' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry', description: 'Also has description' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry', description: 'Another description' }, ]; const [value, setValue] = useState('1'); @@ -1062,17 +1157,17 @@ export const OptionsWithOnlyAccessory = () => { const accessoryOnlyOptions = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , }, ]; @@ -1093,17 +1188,17 @@ export const OptionsWithOnlyMedia = () => { const mediaOnlyOptions = [ { value: '1', - label: 'Option 1', + label: 'Apple', media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', media: , }, ]; @@ -1123,15 +1218,15 @@ export const OptionsWithOnlyMedia = () => { export const CompactWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1165,15 +1260,15 @@ export const CompactWithVariants = () => { export const DisabledWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1204,18 +1299,28 @@ export const DisabledWithVariants = () => { ); }; +DisabledWithVariants.parameters = { + a11y: { + options: { + rules: { + 'color-contrast': { enabled: false }, + }, + }, + }, +}; + export const StartNodeWithVariants = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [positiveValue, setPositiveValue] = useState('1'); const [negativeValue, setNegativeValue] = useState('2'); @@ -1249,15 +1354,15 @@ export const StartNodeWithVariants = () => { export const LongHelperText = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1276,15 +1381,15 @@ export const LongHelperText = () => { export const CustomLongPlaceholder = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState(null); @@ -1303,31 +1408,31 @@ export const AllCombinedFeatures = () => { const exampleOptionsWithCustomAccessoriesAndMedia = [ { value: '1', - label: 'Option 1', + label: 'Apple', accessory: , media: , }, { value: '2', - label: 'Option 2', + label: 'Banana', accessory: , media: , }, { value: '3', - label: 'Option 3', + label: 'Cherry', accessory: , media: , }, { value: '4', - label: 'Option 4', + label: 'Date', accessory: , media: , }, { value: '5', - label: 'Option 5', + label: 'Elderberry', accessory: , media: , }, @@ -1351,15 +1456,15 @@ export const AllCombinedFeatures = () => { export const ComplexStyleCombinations = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1494,15 +1599,15 @@ export const StressTestManyOptionsWithDescriptions = () => { export const CustomControlComponent = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1525,26 +1630,26 @@ export const CustomControlComponent = () => { export const CustomOptionComponent = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); const CustomOptionComponent: SelectOptionComponent = ({ value, onClick }) => { return ( - - + + - + ); }; @@ -1564,15 +1669,15 @@ export const CustomOptionComponent = () => { export const ValueDisplayed = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); @@ -1593,15 +1698,15 @@ export const ValueDisplayed = () => { export const RefImperativeHandle = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, - { value: '5', label: 'Option 5' }, - { value: '6', label: 'Option 6' }, - { value: '7', label: 'Option 7' }, - { value: '8', label: 'Option 8' }, - { value: '9', label: 'Option 9' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, + { value: '5', label: 'Elderberry' }, + { value: '6', label: 'Fig' }, + { value: '7', label: 'Grape' }, + { value: '8', label: 'Honeydew' }, + { value: '9', label: 'Jackfruit' }, ]; const [value, setValue] = useState('1'); const selectRef = useRef(null); @@ -1635,15 +1740,12 @@ export const RefImperativeHandle = () => { export const Borderless = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, - { value: '1', label: 'Option 1' }, - { value: '2', label: 'Option 2' }, - { value: '3', label: 'Option 3' }, - { value: '4', label: 'Option 4' }, + { value: '1', label: 'Apple' }, + { value: '2', label: 'Banana' }, + { value: '3', label: 'Cherry' }, + { value: '4', label: 'Date' }, ]; const [singleValue, setSingleValue] = useState('1'); - const { value: multiValue, onChange: multiOnChange } = useMultiSelect({ - initialValue: ['1', '2'], - }); return ( @@ -1655,16 +1757,6 @@ export const Borderless = () => { placeholder="Empty value" value={singleValue} /> - + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('o'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + + it('focuses the first matching option when a letter key opens the dropdown', async () => { + const user = userEvent.setup(); + const typeAheadOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + ]; + + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('b'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + const bananaOption = options.find((opt) => opt.textContent?.includes('Banana')); + expect(bananaOption).toHaveFocus(); + }); + }); + + it('does not open dropdown when letter key is pressed while disabled', async () => { + const user = userEvent.setup(); + render( + + + , + ); + + const button = screen.getByRole('button'); + button.focus(); + await user.keyboard('{Control>}a{/Control}'); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); }); describe('Ref Forwarding', () => { diff --git a/packages/web/src/alpha/select/types.ts b/packages/web/src/alpha/select/types.ts index 0266d5ccdc..547a6aaea0 100644 --- a/packages/web/src/alpha/select/types.ts +++ b/packages/web/src/alpha/select/types.ts @@ -5,7 +5,6 @@ import type { CellBaseProps } from '../../cells/Cell'; import type { InputStackBaseProps } from '../../controls/InputStack'; import type { AriaHasPopupType } from '../../hooks/useA11yControlledVisibility'; import type { BoxDefaultElement, BoxProps } from '../../layout/Box'; -import type { TrayProps } from '../../overlays/tray/Tray'; import type { PressableDefaultElement, PressableProps } from '../../system'; import type { InteractableBlendStyles } from '../../system/Interactable'; @@ -48,34 +47,34 @@ export type SelectOptionProps< accessibilityRole?: string; /** Whether to use compact styling for the option */ compact?: boolean; - /** Inline styles for the option */ + /** Inline styles for the option element */ style?: React.CSSProperties; - /** Custom styles for different parts of the option */ + /** Custom styles for individual elements of the option */ styles?: { - /** Styles for the option cell element */ + /** Option cell element */ optionCell?: React.CSSProperties; - /** Styles for the option content wrapper */ + /** Option content wrapper */ optionContent?: React.CSSProperties; - /** Styles for the option label element */ + /** Option label element */ optionLabel?: React.CSSProperties; - /** Styles for the option description element */ + /** Option description element */ optionDescription?: React.CSSProperties; - /** Styles for the select all divider element */ + /** Select all divider element */ selectAllDivider?: React.CSSProperties; }; /** CSS class name for the option */ className?: string; - /** Custom class names for different parts of the option */ + /** Custom class names for individual elements of the option */ classNames?: { - /** Class name for the option cell element */ + /** Option cell element */ optionCell?: string; - /** Class name for the option content wrapper */ + /** Option content wrapper */ optionContent?: string; - /** Class name for the option label element */ + /** Option label element */ optionLabel?: string; - /** Class name for the option description element */ + /** Option description element */ optionDescription?: string; - /** Class name for the select all divider element */ + /** Select all divider element */ selectAllDivider?: string; }; }; @@ -152,40 +151,40 @@ export type SelectOptionGroupProps< disabled?: boolean; /** Whether the options should be compact */ compact?: boolean; - /** Custom styles for the option group and options */ + /** Custom styles for individual elements of the option group */ styles?: { - /** Styles for the option group element */ + /** Option group element */ optionGroup?: React.CSSProperties; - /** Styles for individual options */ + /** Option element */ option?: React.CSSProperties; - /** Blend styles for option interactivity */ + /** Option blend styles for interactivity */ optionBlendStyles?: InteractableBlendStyles; - /** Styles for the option cell element */ + /** Option cell element */ optionCell?: React.CSSProperties; - /** Styles for the option content wrapper */ + /** Option content wrapper */ optionContent?: React.CSSProperties; - /** Styles for the option label element */ + /** Option label element */ optionLabel?: React.CSSProperties; - /** Styles for the option description element */ + /** Option description element */ optionDescription?: React.CSSProperties; - /** Styles for the select all divider element */ + /** Select all divider element */ selectAllDivider?: React.CSSProperties; }; - /** Custom class names for the option group and options */ + /** Custom class names for individual elements of the option group */ classNames?: { - /** Class name for the option group element */ + /** Option group element */ optionGroup?: string; - /** Class name for individual options */ + /** Option element */ option?: string; - /** Class name for the option cell element */ + /** Option cell element */ optionCell?: string; - /** Class name for the option content wrapper */ + /** Option content wrapper */ optionContent?: string; - /** Class name for the option label element */ + /** Option label element */ optionLabel?: string; - /** Class name for the option description element */ + /** Option description element */ optionDescription?: string; - /** Class name for the select all divider element */ + /** Select all divider element */ selectAllDivider?: string; }; }; @@ -236,18 +235,18 @@ export function isSelectOptionGroup< export type SelectEmptyDropdownContentProps = { label: string; - /** Custom styles for different parts of the empty dropdown content */ + /** Custom styles for individual elements of the empty dropdown content */ styles?: { - /** Styles for the container element */ + /** Empty contents container element */ emptyContentsContainer?: React.CSSProperties; - /** Styles for the text element */ + /** Empty contents text element */ emptyContentsText?: React.CSSProperties; }; - /** Custom class names for different parts of the empty dropdown content */ + /** Custom class names for individual elements of the empty dropdown content */ classNames?: { - /** Class name for the container element */ + /** Empty contents container element */ emptyContentsContainer?: string; - /** Class name for the text element */ + /** Empty contents text element */ emptyContentsText?: string; }; }; @@ -272,7 +271,6 @@ export type SelectDropdownProps< > = SelectState & Pick & Omit, 'onChange'> & - Pick & Pick, 'accessory' | 'media' | 'end'> & { /** Whether this is for single or multi-select */ type?: Type; @@ -296,56 +294,60 @@ export type SelectDropdownProps< hideSelectAll?: boolean; /** Reference to the control element for positioning */ controlRef: React.MutableRefObject; - /** Inline styles for the dropdown */ + /** Optional header content to render at the top of the dropdown */ + header?: React.ReactNode; + /** Optional footer content to render at the bottom of the dropdown */ + footer?: React.ReactNode; + /** Inline styles for the dropdown element */ style?: React.CSSProperties; - /** Custom styles for dropdown elements */ + /** Custom styles for individual elements of the dropdown */ styles?: { - /** Styles for the dropdown root container */ + /** Dropdown root container element */ root?: React.CSSProperties; - /** Styles for individual options */ + /** Option element */ option?: React.CSSProperties; - /** Blend styles for option interactivity */ + /** Option blend styles for interactivity */ optionBlendStyles?: InteractableBlendStyles; - /** Styles for the option cell element */ + /** Option cell element */ optionCell?: React.CSSProperties; - /** Styles for the option content wrapper */ + /** Option content wrapper */ optionContent?: React.CSSProperties; - /** Styles for the option label element */ + /** Option label element */ optionLabel?: React.CSSProperties; - /** Styles for the option description element */ + /** Option description element */ optionDescription?: React.CSSProperties; - /** Styles for the select all divider element */ + /** Select all divider element */ selectAllDivider?: React.CSSProperties; - /** Styles for the empty contents container element */ + /** Empty contents container element */ emptyContentsContainer?: React.CSSProperties; - /** Styles for the empty contents text element */ + /** Empty contents text element */ emptyContentsText?: React.CSSProperties; - /** Styles for the option group element */ + /** Option group element */ optionGroup?: React.CSSProperties; }; /** CSS class name for the dropdown */ className?: string; - /** Custom class names for dropdown elements */ + /** Custom class names for individual elements of the dropdown */ classNames?: { - /** Class name for the dropdown root container */ + /** Dropdown root container element */ root?: string; - /** Class name for individual options */ + /** Option element */ option?: string; - /** Class name for the option cell element */ + /** Option cell element */ optionCell?: string; - /** Class name for the option content wrapper */ + /** Option content wrapper */ optionContent?: string; - /** Class name for the option label element */ + /** Option label element */ optionLabel?: string; - /** Class name for the option description element */ + /** Option description element */ optionDescription?: string; - /** Class name for the select all divider element */ + /** Select all divider element */ selectAllDivider?: string; - /** Class name for the empty contents container element */ + /** Empty contents container element */ emptyContentsContainer?: string; - /** Class name for the empty contents text element */ + /** Empty contents text element */ emptyContentsText?: string; - /** Class name for the option group element */ + /** Option group element */ optionGroup?: string; }; /** Whether to use compact styling for the dropdown */ @@ -389,6 +391,11 @@ export type SelectControlProps< 'disabled' | 'startNode' | 'variant' | 'labelVariant' | 'testID' | 'endNode' > & SelectState & { + /** + * Alignment of the value node. + * @default 'start' + */ + align?: 'start' | 'center' | 'end'; /** * Determines if the control should have a default border. * @note focusedBorderWidth on the control still shows a border when focused by default. @@ -433,38 +440,38 @@ export type SelectControlProps< ariaHaspopup?: AriaHasPopupType; /** Whether to use compact styling for the control */ compact?: boolean; - /** Inline styles for the control */ + /** Inline styles for the control element */ style?: React.CSSProperties; - /** Custom styles for different parts of the control */ + /** Custom styles for individual elements of the control */ styles?: { - /** Styles for the start node element */ + /** Start node element */ controlStartNode?: React.CSSProperties; - /** Styles for the input node element */ + /** Input node element */ controlInputNode?: React.CSSProperties; - /** Styles for the value node element */ + /** Value node element */ controlValueNode?: React.CSSProperties; - /** Styles for the label node element */ + /** Label node element */ controlLabelNode?: React.CSSProperties; - /** Styles for the helper text node element */ + /** Helper text node element */ controlHelperTextNode?: React.CSSProperties; - /** Styles for the end node element */ + /** End node element */ controlEndNode?: React.CSSProperties; }; /** CSS class name for the control */ className?: string; - /** Custom class names for different parts of the control */ + /** Custom class names for individual elements of the control */ classNames?: { - /** Class name for the start node element */ + /** Start node element */ controlStartNode?: string; - /** Class name for the input node element */ + /** Input node element */ controlInputNode?: string; - /** Class name for the value node element */ + /** Value node element */ controlValueNode?: string; - /** Class name for the label node element */ + /** Label node element */ controlLabelNode?: string; - /** Class name for the helper text node element */ + /** Helper text node element */ controlHelperTextNode?: string; - /** Class name for the end node element */ + /** End node element */ controlEndNode?: string; }; }; @@ -495,6 +502,8 @@ export type SelectBaseProps< | 'disabled' | 'labelVariant' | 'endNode' + | 'align' + | 'font' | 'bordered' > & Pick, 'accessory' | 'media' | 'end'> & @@ -551,86 +560,86 @@ export type SelectProps< Type extends SelectType = 'single', SelectOptionValue extends string = string, > = SelectBaseProps & { - /** Custom styles for different parts of the select */ + /** Custom styles for individual elements of the Select component */ styles?: { - /** Styles for the root container */ + /** Root container element */ root?: React.CSSProperties; - /** Styles for the control element */ + /** Control element */ control?: React.CSSProperties; - /** Styles for the start node element */ + /** Start node element */ controlStartNode?: React.CSSProperties; - /** Styles for the input node element */ + /** Input node element */ controlInputNode?: React.CSSProperties; - /** Styles for the value node element */ + /** Value node element */ controlValueNode?: React.CSSProperties; - /** Styles for the label node element */ + /** Label node element */ controlLabelNode?: React.CSSProperties; - /** Styles for the helper text node element */ + /** Helper text node element */ controlHelperTextNode?: React.CSSProperties; - /** Styles for the end node element */ + /** End node element */ controlEndNode?: React.CSSProperties; /** Blend styles for control interactivity */ controlBlendStyles?: InteractableBlendStyles; - /** Styles for the dropdown container */ + /** Dropdown container element */ dropdown?: React.CSSProperties; - /** Styles for individual options */ + /** Option element */ option?: React.CSSProperties; - /** Styles for the option cell element */ + /** Option cell element */ optionCell?: React.CSSProperties; - /** Styles for the option content wrapper */ + /** Option content wrapper */ optionContent?: React.CSSProperties; - /** Styles for the option label element */ + /** Option label element */ optionLabel?: React.CSSProperties; - /** Styles for the option description element */ + /** Option description element */ optionDescription?: React.CSSProperties; - /** Blend styles for option interactivity */ + /** Option blend styles for interactivity */ optionBlendStyles?: InteractableBlendStyles; - /** Styles for the select all divider element */ + /** Select all divider element */ selectAllDivider?: React.CSSProperties; - /** Styles for the empty contents container element */ + /** Empty contents container element */ emptyContentsContainer?: React.CSSProperties; - /** Styles for the empty contents text element */ + /** Empty contents text element */ emptyContentsText?: React.CSSProperties; - /** Styles for the option group element */ + /** Option group element */ optionGroup?: React.CSSProperties; }; - /** Custom class names for different parts of the select */ + /** Custom class names for individual elements of the Select component */ classNames?: { - /** Class name for the root container */ + /** Root container element */ root?: string; - /** Class name for the control element */ + /** Control element */ control?: string; - /** Class name for the start node element */ + /** Start node element */ controlStartNode?: string; - /** Class name for the input node element */ + /** Input node element */ controlInputNode?: string; - /** Class name for the value node element */ + /** Value node element */ controlValueNode?: string; - /** Class name for the label node element */ + /** Label node element */ controlLabelNode?: string; - /** Class name for the helper text node element */ + /** Helper text node element */ controlHelperTextNode?: string; - /** Class name for the end node element */ + /** End node element */ controlEndNode?: string; - /** Class name for the dropdown container */ + /** Dropdown container element */ dropdown?: string; - /** Class name for individual options */ + /** Option element */ option?: string; - /** Class name for the option cell element */ + /** Option cell element */ optionCell?: string; - /** Class name for the option content wrapper */ + /** Option content wrapper */ optionContent?: string; - /** Class name for the option label element */ + /** Option label element */ optionLabel?: string; - /** Class name for the option description element */ + /** Option description element */ optionDescription?: string; - /** Class name for the select all divider element */ + /** Select all divider element */ selectAllDivider?: string; - /** Class name for the empty contents container element */ + /** Empty contents container element */ emptyContentsContainer?: string; - /** Class name for the empty contents text element */ + /** Empty contents text element */ emptyContentsText?: string; - /** Class name for the option group element */ + /** Option group element */ optionGroup?: string; }; }; diff --git a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx index b8e4032e6f..4bb4a67b41 100644 --- a/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx +++ b/packages/web/src/alpha/tabbed-chips/TabbedChips.tsx @@ -7,6 +7,7 @@ import { css } from '@linaria/core'; import type { ChipProps } from '../../chips/ChipProps'; import { MediaChip } from '../../chips/MediaChip'; import { cx } from '../../cx'; +import { useComponentConfig } from '../../hooks/useComponentConfig'; import { useHorizontalScrollToTarget } from '../../hooks/useHorizontalScrollToTarget'; import { HStack, type HStackDefaultElement, type HStackProps } from '../../layout'; import { @@ -28,11 +29,11 @@ const scrollContainerCss = css` scrollbar-width: none; `; -const DefaultTabComponent = ({ +const DefaultTabComponent = ({ label = '', id, ...tabProps -}: TabbedChipProps) => { +}: TabbedChipProps) => { const { activeTab, updateActiveTab } = useTabsContext(); const isActive = useMemo(() => activeTab?.id === id, [activeTab, id]); const chipRef = useRef(null); @@ -70,22 +71,25 @@ const DefaultTabsActiveIndicatorComponent: TabsActiveIndicatorComponent = () => return null; }; -export type TabbedChipProps = Omit & - TabValue & { - Component?: React.FC & TabValue>; +export type TabbedChipProps = Omit< + ChipProps, + 'children' | 'onClick' +> & + TabValue & { + Component?: React.FC & TabValue>; }; -export type TabbedChipsBaseProps = Omit< - TabsBaseProps, +export type TabbedChipsBaseProps = Omit< + TabsBaseProps, | 'TabComponent' | 'TabsActiveIndicatorComponent' | 'tabs' | 'onActiveTabElementChange' | 'activeBackground' > & { - TabComponent?: React.FC>; - TabsActiveIndicatorComponent?: TabsProps['TabsActiveIndicatorComponent']; - tabs: TabbedChipProps[]; + TabComponent?: React.FC>; + TabsActiveIndicatorComponent?: TabsProps['TabsActiveIndicatorComponent']; + tabs: TabbedChipProps[]; /** * Turn on to use a compact Chip component for each tab. * @default false @@ -98,7 +102,7 @@ export type TabbedChipsBaseProps = Omit< autoScrollOffset?: number; }; -export type TabbedChipsProps = TabbedChipsBaseProps & +export type TabbedChipsProps = TabbedChipsBaseProps & SharedProps & SharedAccessibilityProps & { background?: ThemeVars.Color; @@ -116,46 +120,36 @@ export type TabbedChipsProps = TabbedChipsBaseProps['width']; styles?: { - /** - * Style applied to the root container. - */ + /** Root container element */ root?: React.CSSProperties; - /** - * Style applied to the scroll container. - */ + /** Scroll container element */ scrollContainer?: React.CSSProperties; - /** - * Style applied to the paddle icon buttons. - */ + /** Paddle icon buttons */ paddle?: React.CSSProperties; - /** - * Style applied to the root of the Tabs component. - */ + /** Tabs root element */ tabs?: React.CSSProperties; }; classNames?: { - /** - * Class name applied to the root container. - */ + /** Root container element */ root?: string; - /** - * Class name applied to the scroll container. - */ + /** Scroll container element */ scrollContainer?: string; - /** - * Class name applied to the root of the Tabs component. - */ + /** Tabs root element */ tabs?: string; }; }; -type TabbedChipsFC = ( - props: TabbedChipsProps & { ref?: React.ForwardedRef }, +type TabbedChipsFC = ( + props: TabbedChipsProps & { ref?: React.ForwardedRef }, ) => React.ReactElement; const TabbedChipsComponent = memo( - forwardRef(function TabbedChips( - { + forwardRef(function TabbedChips( + _props: TabbedChipsProps, + ref: React.ForwardedRef, + ) { + const mergedProps = useComponentConfig('TabbedChips', _props); + const { tabs, activeTab, onChange, @@ -173,9 +167,7 @@ const TabbedChipsComponent = memo( classNames, autoScrollOffset = 50, ...accessibilityProps - }: TabbedChipsProps, - ref: React.ForwardedRef, - ) { + } = mergedProps; const [scrollTarget, setScrollTarget] = useState(null); const { scrollRef, isScrollContentOffscreenLeft, isScrollContentOffscreenRight, handleScroll } = useHorizontalScrollToTarget({ activeTarget: scrollTarget, autoScrollOffset }); @@ -191,7 +183,7 @@ const TabbedChipsComponent = memo( }, [scrollRef]); const TabComponentWithCompact = useCallback( - (props: TabValue) => { + (props: TabValue) => { return ; }, [TabComponent, compact], diff --git a/packages/web/src/animation/Lottie.tsx b/packages/web/src/animation/Lottie.tsx index 0da83c67ee..74d8190ca3 100644 --- a/packages/web/src/animation/Lottie.tsx +++ b/packages/web/src/animation/Lottie.tsx @@ -194,7 +194,7 @@ export const Lottie = memo( handlers, resizeMode = 'contain', filterSize = defaultFilterSize, - ...otherProps + ...boxProps }: LottieProps, // String wont work on literal unions, so use any here forwardedRef: React.ForwardedRef>>, @@ -253,7 +253,7 @@ export const Lottie = memo( ); useLottieListeners(animationRef, listeners); - return ; + return ; }, ), ); diff --git a/packages/web/src/animation/LottieStatusAnimation.tsx b/packages/web/src/animation/LottieStatusAnimation.tsx index 326ad5f752..fba777638e 100644 --- a/packages/web/src/animation/LottieStatusAnimation.tsx +++ b/packages/web/src/animation/LottieStatusAnimation.tsx @@ -1,16 +1,45 @@ -import React, { memo, useCallback, useRef, useState } from 'react'; +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 { LottieStatusAnimationProps } from '@coinbase/cds-common/types/LottieStatusAnimationProps'; +import type { SharedAccessibilityProps } from '@coinbase/cds-common/types/SharedAccessibilityProps'; +import type { SharedProps } from '@coinbase/cds-common/types/SharedProps'; import type { TradeStatusLottie } from '@coinbase/cds-lottie-files/tradeStatus'; import { tradeStatus } from '@coinbase/cds-lottie-files/tradeStatus'; +import type { LottieStatus } from 'packages/common/dts/types/LottieStatus'; import { Lottie } from './Lottie'; type LottiePlayerRef = LottiePlayer; +type LottieStatusAnimationBaseProps = { + status?: LottieStatus; + onFinish?: () => void; +}; + +type LottieStatusAnimationPropsWithWidth = { + width: DimensionValue; +} & LottieStatusAnimationBaseProps; + +type LottieStatusAnimationPropsWithHeight = { + height: DimensionValue; +} & LottieStatusAnimationBaseProps; + +export type LottieStatusAnimationProps = ( + | LottieStatusAnimationPropsWithWidth + | LottieStatusAnimationPropsWithHeight +) & + SharedProps & + SharedAccessibilityProps; export const LottieStatusAnimation = memo( - ({ status = 'loading', onFinish, testID, ...otherProps }: LottieStatusAnimationProps) => { + ({ + status = 'loading', + onFinish, + testID, + accessibilityLabel, + ...otherProps + }: LottieStatusAnimationProps) => { const [, forceUpdate] = useState(0); const lottie = useRef(); @@ -27,10 +56,18 @@ export const LottieStatusAnimation = memo( } }, []); + const label = useMemo( + () => accessibilityLabel ?? lottieStatusToAccessibilityLabel[status as LottieStatus], + [accessibilityLabel, status], + ); + return ( { - const [status, setStatus] = useState('loading'); + const [status, setStatus] = useState('loading'); const [key, setKey] = useState(0); const handleReset = () => { diff --git a/packages/web/src/animation/__tests__/LottieStatusAnimation.test.tsx b/packages/web/src/animation/__tests__/LottieStatusAnimation.test.tsx index c154e7735c..468f17f2de 100644 --- a/packages/web/src/animation/__tests__/LottieStatusAnimation.test.tsx +++ b/packages/web/src/animation/__tests__/LottieStatusAnimation.test.tsx @@ -1,12 +1,11 @@ -import React from 'react'; -import type { - LottieStatusAnimationProps, - LottieStatusAnimationType, -} from '@coinbase/cds-common/types/LottieStatusAnimationProps'; +import React, { type ComponentProps } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; +import type { LottieStatus } from 'packages/common/dts/types/LottieStatus'; import { LottieStatusAnimation } from '../LottieStatusAnimation'; +type LottieStatusAnimationProps = ComponentProps; + type StatusAnimationPollerParams = { onFinish?: () => void; }; @@ -57,7 +56,7 @@ describe('LottieStatusAnimation', () => { }); it('renders with different status values', () => { - const testStatuses: LottieStatusAnimationType[] = ['loading', 'success', 'failure', 'pending']; + const testStatuses: LottieStatus[] = ['loading', 'success', 'failure', 'pending']; testStatuses.forEach((status) => { const props: LottieStatusAnimationProps = { @@ -70,4 +69,76 @@ describe('LottieStatusAnimation', () => { expect(screen.getByTestId(`lottie-status-animation-${status}`)).toBeTruthy(); }); }); + + describe('cardSuccess status', () => { + it('renders with cardSuccess status', () => { + render( + , + ); + expect(screen.getByTestId('lottie-card-success')).toBeTruthy(); + }); + + it('calls onFinish with cardSuccess status', async () => { + const onFinish = jest.fn(); + render( + , + ); + + expect(screen.getByTestId('lottie-card-success-finish')).toBeTruthy(); + await waitFor(() => expect(onFinish).toHaveBeenCalled(), { timeout: 1500 }); + }); + }); + + describe('status transitions', () => { + it('transitions from pending to success', async () => { + const { rerender } = render( + , + ); + expect(screen.getByTestId('lottie-transition')).toBeTruthy(); + + rerender(); + expect(screen.getByTestId('lottie-transition')).toBeTruthy(); + }); + + 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(); + }); + + it('transitions from loading to success and calls onFinish', async () => { + const onFinish = jest.fn(); + const { rerender } = render( + , + ); + expect(screen.getByTestId('lottie-loading-success')).toBeTruthy(); + + rerender( + , + ); + + await waitFor(() => expect(onFinish).toHaveBeenCalled(), { timeout: 1500 }); + }); + }); }); diff --git a/packages/web/src/animation/types.ts b/packages/web/src/animation/types.ts index a05de56066..4d37ae8f34 100644 --- a/packages/web/src/animation/types.ts +++ b/packages/web/src/animation/types.ts @@ -1,7 +1,7 @@ import type { LottieSource } from '@coinbase/cds-common/types/LottieSource'; import type { AnimationEventName, AnimationItem, SVGRendererConfig } from 'lottie-web'; -import type { BoxBaseProps } from '../layout'; +import type { BoxBaseProps, BoxDefaultElement, BoxProps } from '../layout'; export type LottieEventHandlersMap = { [key in LottieListener['name']]?: LottieListener['handler']; @@ -19,12 +19,10 @@ export type LottieBaseProps> = /** * A boolean flag indicating whether or not the animation should start automatically when * mounted. This only affects the imperative API. - * @default false */ autoplay?: boolean; /** * A boolean flag indicating whether or not the animation should loop. - * @default false */ loop?: boolean; /** @@ -55,6 +53,7 @@ export type LottieBaseProps> = export type LottieProps> = LottieBaseProps< T, Source ->; +> & + BoxProps; export type LottieAnimationRef = React.MutableRefObject; diff --git a/packages/web/src/banner/Banner.tsx b/packages/web/src/banner/Banner.tsx index fed483098b..34f5af8523 100644 --- a/packages/web/src/banner/Banner.tsx +++ b/packages/web/src/banner/Banner.tsx @@ -19,6 +19,7 @@ import type { import { css } from '@linaria/core'; import { Collapsible } from '../collapsible'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons/Icon'; import { Box, HStack, type HStackDefaultElement, type HStackProps, VStack } from '../layout'; import type { ResponsiveProps, StaticStyleProps } from '../styles/styleProps'; @@ -90,233 +91,225 @@ export type BannerProps = BannerBaseProps & Omit, 'children' | 'title'>; export const Banner = memo( - forwardRef( - ( - { - variant, - startIcon, - startIconActive, - onClose, - primaryAction, - secondaryAction, - title, - children, - showDismiss = false, - testID, - style, - className, - numberOfLines = 3, - label, - styleVariant = 'contextual', - startIconAccessibilityLabel, - closeAccessibilityLabel = 'close', - borderRadius = styleVariant === 'contextual' ? 400 : undefined, - margin, - marginY, - marginX, - marginTop, - marginBottom, - marginStart, - marginEnd, - ...props - }: BannerProps, - ref: React.ForwardedRef, - ) => { - const [isCollapsed, setIsCollapsed] = useState(false); - const titleId = useId(); + forwardRef((_props: BannerProps, ref: React.ForwardedRef) => { + const mergedProps = useComponentConfig('Banner', _props); + const { + variant, + startIcon, + startIconActive, + onClose, + primaryAction, + secondaryAction, + title, + children, + showDismiss = false, + testID, + style, + className, + numberOfLines = 3, + label, + styleVariant = 'contextual', + startIconAccessibilityLabel, + closeAccessibilityLabel = 'close', + borderRadius = styleVariant === 'contextual' ? 400 : undefined, + margin, + marginY, + marginX, + marginTop, + marginBottom, + marginStart, + marginEnd, + width = '100%', + ...props + } = mergedProps; + const [isCollapsed, setIsCollapsed] = useState(false); + const titleId = useId(); - const accessibilityLabelledBy = typeof title === 'string' ? titleId : undefined; + const accessibilityLabelledBy = typeof title === 'string' ? titleId : undefined; - // Setup color configs - const { - iconColor, - textColor, - background, - primaryActionColor, - secondaryActionColor, - iconButtonColor, - borderColor, - }: BannerVariantStyle = variants[variant]; + // Setup color configs + const { + iconColor, + textColor, + background, + primaryActionColor, + secondaryActionColor, + iconButtonColor, + borderColor, + }: BannerVariantStyle = variants[variant]; - // Events - const handleOnDismiss = useCallback(() => { - setIsCollapsed(true); - onClose?.(); - }, [onClose]); + // Events + const handleOnDismiss = useCallback(() => { + setIsCollapsed(true); + onClose?.(); + }, [onClose]); - const clonedPrimaryAction = useMemo(() => { - if (!isValidElement>(primaryAction)) return null; + const clonedPrimaryAction = useMemo(() => { + if (!isValidElement>(primaryAction)) return null; - if (primaryAction.type === Link) { - return React.cloneElement(primaryAction, { - font: 'label1', - color: primaryActionColor, - testID: `${testID}-action--primary`, - ...primaryAction.props, - }); - } else { - return React.cloneElement(primaryAction, { - testID: `${testID}-action--primary`, - ...primaryAction.props, - }); - } - }, [primaryAction, primaryActionColor, testID]); + if (primaryAction.type === Link) { + return React.cloneElement(primaryAction, { + font: 'label1', + color: primaryActionColor, + testID: `${testID}-action--primary`, + ...primaryAction.props, + }); + } else { + return React.cloneElement(primaryAction, { + testID: `${testID}-action--primary`, + ...primaryAction.props, + }); + } + }, [primaryAction, primaryActionColor, testID]); - const clonedSecondaryAction = useMemo(() => { - if (!isValidElement>(secondaryAction)) return null; + const clonedSecondaryAction = useMemo(() => { + if (!isValidElement>(secondaryAction)) return null; - if (secondaryAction.type === Link) { - return React.cloneElement(secondaryAction, { - font: 'label1', - color: secondaryActionColor, - testID: `${testID}-action--secondary`, - ...secondaryAction.props, - }); - } else { - return React.cloneElement(secondaryAction, { - testID: `${testID}-action--secondary`, - ...secondaryAction.props, - }); - } - }, [secondaryAction, secondaryActionColor, testID]); + if (secondaryAction.type === Link) { + return React.cloneElement(secondaryAction, { + font: 'label1', + color: secondaryActionColor, + testID: `${testID}-action--secondary`, + ...secondaryAction.props, + }); + } else { + return React.cloneElement(secondaryAction, { + testID: `${testID}-action--secondary`, + ...secondaryAction.props, + }); + } + }, [secondaryAction, secondaryActionColor, testID]); - const marginStyles = useMemo( - () => ({ - margin, - marginY, - marginX, - marginTop, - marginBottom, - marginStart, - marginEnd, - }), - [margin, marginX, marginY, marginStart, marginEnd, marginTop, marginBottom], - ); + const marginStyles = useMemo( + () => ({ + margin, + marginY, + marginX, + marginTop, + marginBottom, + marginStart, + marginEnd, + }), + [margin, marginX, marginY, marginStart, marginEnd, marginTop, marginBottom], + ); - const borderBox = useMemo( - () => , - [borderColor], - ); + const borderBox = useMemo( + () => , + [borderColor], + ); - const content = ( - + - + + + - {/** 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 && ( + + + + + + )} + + {styleVariant === 'global' && !showDismiss && borderBox} + + ); - return showDismiss ? ( - + - - {content} - - {styleVariant === 'global' && borderBox} - - ) : ( - content - ); - }, - ), + {content} + + {styleVariant === 'global' && borderBox} + + ) : ( + content + ); + }), ); diff --git a/packages/web/src/banner/__stories__/Banner.stories.tsx b/packages/web/src/banner/__stories__/Banner.stories.tsx index f6e2986112..bcc0af11bd 100644 --- a/packages/web/src/banner/__stories__/Banner.stories.tsx +++ b/packages/web/src/banner/__stories__/Banner.stories.tsx @@ -24,6 +24,7 @@ type ExampleProps = Pick< | 'startIconActive' | 'startIconAccessibilityLabel' | 'closeAccessibilityLabel' + | 'width' >; const exampleProps: ExampleProps = { @@ -38,6 +39,7 @@ const examplePropsWithMargin: ExampleProps = { ...exampleProps, marginX: -2, children: shortMessage, + width: 'calc(100% + var(--space-4))', }; const styleProps: BannerProps[] = [ @@ -282,7 +284,7 @@ export const BannerWithLink = () => { export const CustomMargin = () => { return ( - + Global diff --git a/packages/web/src/buttons/AvatarButton.tsx b/packages/web/src/buttons/AvatarButton.tsx index a138cdcd48..d9869a09c5 100644 --- a/packages/web/src/buttons/AvatarButton.tsx +++ b/packages/web/src/buttons/AvatarButton.tsx @@ -4,6 +4,7 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Avatar, type AvatarBaseProps } from '../media'; import { Pressable, type PressableBaseProps } from '../system'; @@ -44,7 +45,11 @@ const baseCss = css` export const AvatarButton: AvatarButtonComponent = memo( forwardRef, AvatarButtonBaseProps>( ( - { + _props: AvatarButtonProps, + ref?: Polymorphic.Ref, + ) => { + const mergedProps = useComponentConfig('AvatarButton', _props); + const { accessibilityLabel, as, className, @@ -56,9 +61,7 @@ export const AvatarButton: AvatarButtonComponent = memo( selected, name, ...props - }: AvatarButtonProps, - ref?: Polymorphic.Ref, - ) => { + } = mergedProps; const Component = (as ?? avatarButtonDefaultElement) satisfies React.ElementType; const height = compact ? interactableHeight.compact : interactableHeight.regular; diff --git a/packages/web/src/buttons/Button.tsx b/packages/web/src/buttons/Button.tsx index be44ad3687..01a23c4bdf 100644 --- a/packages/web/src/buttons/Button.tsx +++ b/packages/web/src/buttons/Button.tsx @@ -10,19 +10,24 @@ import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; 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` - min-width: 100px; - padding-inline-start: var(--space-4); - padding-inline-end: var(--space-4); text-decoration: none; display: inline-flex; text-align: center; @@ -30,7 +35,6 @@ const baseCss = css` align-items: center; justify-content: center; font-weight: 600; - margin: 0; position: relative; white-space: nowrap; appearance: none; @@ -46,12 +50,6 @@ const blockCss = css` white-space: normal; `; -const compactCss = css` - min-width: auto; - padding-inline-start: var(--space-2); - padding-inline-end: var(--space-2); -`; - const spinnerContainerCss = css` position: absolute; top: 50%; @@ -112,15 +110,6 @@ 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; @@ -138,6 +127,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. */ @@ -175,10 +168,8 @@ export type ButtonBaseProps = Polymorphic.ExtendableProps< } >; -export type ButtonProps = Polymorphic.Props< - AsComponent, - ButtonBaseProps ->; +export type ButtonProps = + Polymorphic.Props; type ButtonComponent = (( props: ButtonProps, @@ -188,10 +179,15 @@ type ButtonComponent = (, ButtonBaseProps>( ( - { + _props: ButtonProps, + ref?: Polymorphic.Ref, + ) => { + const mergedProps = useComponentConfig('Button', _props); + const { as, variant = 'primary', loading, + progressCircleSize = defaultProgressCircleSize, transparent, block, compact, @@ -205,6 +201,11 @@ export const Button: ButtonComponent = memo( flush, noScaleOnPress, numberOfLines, + font = 'headline', + fontFamily, + fontSize, + fontWeight, + lineHeight, background, color, className, @@ -214,10 +215,12 @@ export const Button: ButtonComponent = memo( borderWidth = 100, borderRadius = compact ? 700 : 900, accessibilityLabel, + padding, + paddingX = padding ?? (compact ? 2 : 4), + margin = 0, + minWidth = compact ? 'auto' : DEFAULT_MIN_WIDTH, ...props - }: ButtonProps, - ref?: Polymorphic.Ref, - ) => { + } = mergedProps; const Component = (as ?? buttonDefaultElement) satisfies React.ElementType; const iconSize = compact ? 's' : 'm'; const hasIcon = Boolean(startIcon ?? endIcon); @@ -241,7 +244,6 @@ export const Button: ButtonComponent = memo( className={cx( COMPONENT_STATIC_CLASSNAME, baseCss, - compact && compactCss, numberOfLines && unsetNoWrapCss, hasIcon && iconCss, block && blockCss, @@ -258,7 +260,11 @@ export const Button: ButtonComponent = memo( data-variant={variant} height={height} loading={loading} + margin={margin} + minWidth={minWidth} noScaleOnPress={noScaleOnPress} + padding={padding} + paddingX={paddingX} transparentWhileInactive={transparent} {...props} > @@ -278,13 +284,23 @@ export const Button: ButtonComponent = memo( {loading && ( - + )} {children} diff --git a/packages/web/src/buttons/ButtonGroup.tsx b/packages/web/src/buttons/ButtonGroup.tsx index 612935e46a..c2a0510cbc 100644 --- a/packages/web/src/buttons/ButtonGroup.tsx +++ b/packages/web/src/buttons/ButtonGroup.tsx @@ -7,6 +7,7 @@ import type { import { css } from '@linaria/core'; import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Box, type GroupDirection } from '../layout'; import type { ButtonBaseProps } from './Button'; @@ -40,13 +41,9 @@ const fillCss = css` flex: 1; `; -export const ButtonGroup = memo(function ButtonGroup({ - accessibilityLabel, - block, - children, - testID, - direction, -}: ButtonGroupProps) { +export const ButtonGroup = memo(function ButtonGroup(_props: ButtonGroupProps) { + const mergedProps = useComponentConfig('ButtonGroup', _props); + const { accessibilityLabel, block, children, testID, direction } = mergedProps; const isVertical = direction === 'vertical'; return ( diff --git a/packages/web/src/buttons/IconButton.tsx b/packages/web/src/buttons/IconButton.tsx index d3fe02c6f8..81f54021db 100644 --- a/packages/web/src/buttons/IconButton.tsx +++ b/packages/web/src/buttons/IconButton.tsx @@ -1,26 +1,20 @@ import React, { forwardRef, memo, useMemo } from 'react'; import { transparentVariants, variants } from '@coinbase/cds-common/tokens/button'; -import type { IconButtonVariant, IconName } from '@coinbase/cds-common/types'; +import type { IconButtonVariant, IconName, IconSize } from '@coinbase/cds-common/types'; import { css } from '@linaria/core'; import type { Polymorphic } from '../core/polymorphism'; import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { useTheme } from '../hooks/useTheme'; import { Icon } from '../icons/Icon'; -import { Spinner } from '../loaders/Spinner'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; +import { ProgressCircle } from '../visualizations/ProgressCircle'; -import { type ButtonBaseProps, spinnerHeight } from './Button'; +import { type ButtonBaseProps } from './Button'; const COMPONENT_STATIC_CLASSNAME = 'cds-IconButton'; -const baseSpinnerCss = css` - border: 2px solid; - border-top-color: var(--color-transparent); - border-right-color: var(--color-transparent); - border-left-color: var(--color-transparent); -`; - export const iconButtonDefaultElement = 'button'; export type IconButtonDefaultElement = typeof iconButtonDefaultElement; @@ -30,6 +24,11 @@ export type IconButtonBaseProps = Polymorphic.ExtendableProps< Pick & { /** Name of the icon, as defined in Figma. */ name: IconName; + /** + * Size for the icon rendered inside the button. + * @default compact ? 's' : 'm' + */ + iconSize?: IconSize; /** Whether the icon is active */ active?: boolean; /** @@ -67,7 +66,11 @@ const flushEndCss = css` export const IconButton: IconButtonComponent = memo( forwardRef, IconButtonBaseProps>( ( - { + _props: IconButtonProps, + ref?: Polymorphic.Ref, + ) => { + const mergedProps = useComponentConfig('IconButton', _props); + const { as, variant = 'secondary', transparent, @@ -84,29 +87,20 @@ export const IconButton: IconButtonComponent = memo( width = compact ? 40 : 56, className, name, + iconSize = compact ? 's' : 'm', active, flush, loading, + progressCircleSize, accessibilityLabel, accessibilityHint, ...props - }: IconButtonProps, - ref?: Polymorphic.Ref, - ) => { + } = mergedProps; const Component = (as ?? iconButtonDefaultElement) satisfies React.ElementType; const theme = useTheme(); - const iconSize = compact ? 's' : 'm'; const iconSizeValue = theme.iconSize[iconSize]; - const spinnerSizeStyles = useMemo( - () => ({ - width: iconSizeValue, - height: iconSizeValue, - }), - [iconSizeValue], - ); - const variantMap = transparent ? transparentVariants : variants; const variantStyle = variantMap[variant]; @@ -145,12 +139,13 @@ export const IconButton: IconButtonComponent = memo( {...props} > {loading ? ( - ) : ( diff --git a/packages/web/src/buttons/IconCounterButton.tsx b/packages/web/src/buttons/IconCounterButton.tsx index 1c4919f89b..782d5f9cdf 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 { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -12,7 +13,10 @@ import { Text } from '../typography/Text'; export type IconCounterButtonBaseProps = { /** Name of the icon or a ReactNode */ icon: Exclude | IconName; - /** @deprecated Use `size` instead. */ + /** + * @deprecated Use `size` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v8 + */ iconSize?: IconSize; /** Size for given icon. */ size?: IconSize; @@ -33,7 +37,11 @@ export type IconCounterButtonProps = IconCounterButtonBaseProps & export const IconCounterButton = memo( forwardRef(function IconCounterButton( - { + _props: IconCounterButtonProps, + ref: React.Ref, + ) { + const mergedProps = useComponentConfig('IconCounterButton', _props); + const { icon, iconSize = 's', size = iconSize, @@ -43,9 +51,7 @@ export const IconCounterButton = memo( dangerouslySetColor, background = 'transparent', ...props - }: IconCounterButtonProps, - ref: React.Ref, - ) { + } = mergedProps; return ( { +export const Tile = memo((_props: TileProps) => { + const mergedProps = useComponentConfig('Tile', _props); + const { title, count, showOverflow, children } = mergedProps; const [shouldOverflow, setShouldOverflow] = useState(false); const overflowTextStyles = (showOverflow ?? shouldOverflow) ? visibleCss : truncatedCss; diff --git a/packages/web/src/buttons/TileButton.tsx b/packages/web/src/buttons/TileButton.tsx index 168a422510..15c3b051ac 100644 --- a/packages/web/src/buttons/TileButton.tsx +++ b/packages/web/src/buttons/TileButton.tsx @@ -5,6 +5,7 @@ import type { IllustrationPictogramNames } from '@coinbase/cds-common/types'; import { isDevelopment } from '@coinbase/cds-utils'; import type { Polymorphic } from '../core/polymorphism'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import type { PictogramName } from '../illustrations/Pictogram'; import { Pictogram } from '../illustrations/Pictogram'; import { Pressable, type PressableBaseProps } from '../system/Pressable'; @@ -35,17 +36,11 @@ type TileButtonComponent = (, TileButtonBaseProps>( ( - { - as, - pictogram, - title, - count, - children, - showOverflow, - ...props - }: TileButtonProps, + _props: TileButtonProps, ref?: Polymorphic.Ref, ) => { + const mergedProps = useComponentConfig('TileButton', _props); + const { as, pictogram, title, count, children, showOverflow, ...props } = mergedProps; const Component = (as ?? tileButtonDefaultElement) satisfies React.ElementType; if (isDevelopment() && title.trim() === '') { diff --git a/packages/web/src/buttons/__figma__/AvatarButton.figma.tsx b/packages/web/src/buttons/__figma__/AvatarButton.figma.tsx index 490d7a7df0..8e1fdc97ba 100644 --- a/packages/web/src/buttons/__figma__/AvatarButton.figma.tsx +++ b/packages/web/src/buttons/__figma__/AvatarButton.figma.tsx @@ -7,7 +7,7 @@ figma.connect( AvatarButton, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=276-23400&m=dev', { - imports: ["import {AvatarButton} from '@coinbase/cds-web/buttons/AvatarButton';"], + imports: ["import {AvatarButton} from '@coinbase/cds-web/buttons/AvatarButton'"], props: { // state: figma.enum('state', { // active: 'active', diff --git a/packages/web/src/buttons/__figma__/Button.figma.tsx b/packages/web/src/buttons/__figma__/Button.figma.tsx index 95c9229acf..1166733331 100644 --- a/packages/web/src/buttons/__figma__/Button.figma.tsx +++ b/packages/web/src/buttons/__figma__/Button.figma.tsx @@ -8,7 +8,7 @@ figma.connect( Button, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=89-3096&m=dev', { - imports: ["import { Button } from '@coinbase/cds-web/buttons/Button';"], + imports: ["import { Button } from '@coinbase/cds-web/buttons/Button'"], props: { variant: figma.enum('variant', { primary: 'primary', diff --git a/packages/web/src/buttons/__figma__/IconButton.figma.tsx b/packages/web/src/buttons/__figma__/IconButton.figma.tsx index 26e4f890cd..a1c588b5a2 100644 --- a/packages/web/src/buttons/__figma__/IconButton.figma.tsx +++ b/packages/web/src/buttons/__figma__/IconButton.figma.tsx @@ -7,7 +7,7 @@ figma.connect( IconButton, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=47-358&m=dev', { - imports: ["import {IconButton} from '@coinbase/cds-web/buttons/IconButton';"], + imports: ["import {IconButton} from '@coinbase/cds-web/buttons/IconButton'"], props: { variant: figma.enum('variant', { primary: 'primary', diff --git a/packages/web/src/buttons/__figma__/TileButton.figma.tsx b/packages/web/src/buttons/__figma__/TileButton.figma.tsx index 95dea3758a..7903c9a93b 100644 --- a/packages/web/src/buttons/__figma__/TileButton.figma.tsx +++ b/packages/web/src/buttons/__figma__/TileButton.figma.tsx @@ -7,7 +7,7 @@ figma.connect( TileButton, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=286%3A18370', { - imports: ["import { TileButton } from '@coinbase/cds-web/buttons/TileButton';"], + imports: ["import { TileButton } from '@coinbase/cds-web/buttons/TileButton'"], props: { title: figma.string('product text'), children: figma.instance('product logo'), diff --git a/packages/web/src/buttons/__stories__/Button.stories.tsx b/packages/web/src/buttons/__stories__/Button.stories.tsx index a24ff7661b..64e8262280 100644 --- a/packages/web/src/buttons/__stories__/Button.stories.tsx +++ b/packages/web/src/buttons/__stories__/Button.stories.tsx @@ -26,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' }, @@ -37,6 +45,15 @@ const buttonStories: Omit[] = [ { startIcon: 'backArrow', endIcon: 'forwardArrow', compact: true }, { startIcon: 'backArrow', compact: true }, { endIcon: 'forwardArrow', compact: true }, + { padding: 5 }, + { paddingX: 5, padding: 4 }, + { paddingY: 4 }, + { paddingStart: 6, paddingEnd: 6 }, + { paddingTop: 6, paddingBottom: 6 }, + { marginStart: -2 }, + { font: 'body' }, + { font: 'title3' }, + { fontSize: 'title3', fontWeight: 'body' }, ]; const onClickConsole = () => console.log('clicked'); diff --git a/packages/web/src/buttons/__stories__/IconButton.stories.tsx b/packages/web/src/buttons/__stories__/IconButton.stories.tsx index 72ee44bd65..ecf888ac70 100644 --- a/packages/web/src/buttons/__stories__/IconButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/IconButton.stories.tsx @@ -73,6 +73,15 @@ export const Default = () => ( Without Compact Styles + + Icon Sizes + + + + + + + Custom Style ( Variants Loading + {variants.map((variant, index) => ( + + {variant.component({ accessibilityLabel, loading: true, compact: false })} + {variant.title} + + ))} {variants.map((variant, index) => ( {variant.component({ accessibilityLabel, loading: true })} diff --git a/packages/web/src/buttons/__stories__/TileButton.stories.tsx b/packages/web/src/buttons/__stories__/TileButton.stories.tsx index 5d6e783f0e..3539068e6e 100644 --- a/packages/web/src/buttons/__stories__/TileButton.stories.tsx +++ b/packages/web/src/buttons/__stories__/TileButton.stories.tsx @@ -57,3 +57,13 @@ export const TileButtonPictogram = () => { ); }; + +TileButtonPictogram.parameters = { + a11y: { + options: { + rules: { + 'color-contrast': { enabled: false }, + }, + }, + }, +}; diff --git a/packages/web/src/buttons/__tests__/Button.test.tsx b/packages/web/src/buttons/__tests__/Button.test.tsx index 158e0c530f..d057519f52 100644 --- a/packages/web/src/buttons/__tests__/Button.test.tsx +++ b/packages/web/src/buttons/__tests__/Button.test.tsx @@ -2,6 +2,7 @@ import { renderA11y } from '@coinbase/cds-web-utils/jest'; import { fireEvent, render, screen } from '@testing-library/react'; import { Box } from '../../layout'; +import { ComponentConfigProvider } from '../../system'; import { DefaultThemeProvider } from '../../utils/test'; import { Button } from '../Button'; @@ -177,4 +178,62 @@ describe('Button', () => { expect(button).not.toHaveAttribute('data-transparent'); expect(button).toHaveAttribute('data-variant'); }); + + it('passes font props to internal text', () => { + render( + + + , + ); + + const childTextNode = screen.getByText('Child'); + expect(childTextNode.parentElement).toHaveStyle({ + '--text-textTransform': 'var(--textTransform-body)', + }); + }); + + it('applies Button defaults from ComponentConfigProvider', () => { + render( + + + + + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-variant', 'secondary'); + }); + + it('keeps local Button props higher precedence than provider defaults', () => { + render( + + + + + , + ); + + expect(screen.getByRole('button')).toHaveAttribute('data-variant', 'positive'); + }); + + it('supports functional Button config resolvers', () => { + render( + + ({ + variant: props.loading ? 'secondary' : 'positive', + }), + }} + > + + + + , + ); + + const [loadingButton, readyButton] = screen.getAllByRole('button'); + expect(loadingButton).toHaveAttribute('data-variant', 'secondary'); + expect(readyButton).toHaveAttribute('data-variant', 'positive'); + }); }); diff --git a/packages/web/src/buttons/__tests__/IconButton.test.tsx b/packages/web/src/buttons/__tests__/IconButton.test.tsx index 2e37698a37..4b6992441a 100644 --- a/packages/web/src/buttons/__tests__/IconButton.test.tsx +++ b/packages/web/src/buttons/__tests__/IconButton.test.tsx @@ -1,3 +1,4 @@ +import { glyphMap } from '@coinbase/cds-icons/glyphMap'; import { renderA11y } from '@coinbase/cds-web-utils/jest'; import { fireEvent, render, screen } from '@testing-library/react'; @@ -138,39 +139,63 @@ 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', () => { + render( + + + , + ); + + expect(screen.getByTestId('icon-base-glyph')).toHaveTextContent( + glyphMap[`${name}-12-inactive`], + ); + }); + + it('renders ProgressCircle with overridden iconSize when loading', () => { + render( + + + , + ); + + 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/AnnouncementCard.tsx b/packages/web/src/cards/AnnouncementCard.tsx index 052c7c9737..07dd082fc4 100644 --- a/packages/web/src/cards/AnnouncementCard.tsx +++ b/packages/web/src/cards/AnnouncementCard.tsx @@ -5,7 +5,10 @@ import { CardBody, type CardBodyBaseProps } from './CardBody'; export type AnnouncementCardBaseProps = CardBaseProps & CardBodyBaseProps; export type AnnouncementCardProps = AnnouncementCardBaseProps; -/** @deprecated will be removed in v7.0.0 use NudgeCard or UpsellCard instead */ +/** + * @deprecated Use MessagingCard instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v6 + */ export const AnnouncementCard = memo(function AnnouncementCard({ width, title, diff --git a/packages/web/src/cards/Card.tsx b/packages/web/src/cards/Card.tsx index d26005bddc..43f676b497 100644 --- a/packages/web/src/cards/Card.tsx +++ b/packages/web/src/cards/Card.tsx @@ -20,6 +20,10 @@ export type CardBaseProps = Pick & 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', diff --git a/packages/web/src/cards/CardBody.tsx b/packages/web/src/cards/CardBody.tsx index 56683db4fd..74b7702346 100644 --- a/packages/web/src/cards/CardBody.tsx +++ b/packages/web/src/cards/CardBody.tsx @@ -62,6 +62,9 @@ export type CardBodyProps = CardBodyBaseProps & Omit /** * 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..e661c491ed 100644 --- a/packages/web/src/cards/CardMedia.tsx +++ b/packages/web/src/cards/CardMedia.tsx @@ -26,6 +26,10 @@ 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 ( diff --git a/packages/web/src/cards/CardRemoteImage.tsx b/packages/web/src/cards/CardRemoteImage.tsx deleted file mode 100644 index b4cf7edb03..0000000000 --- a/packages/web/src/cards/CardRemoteImage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React, { memo } from 'react'; -import type { CardRemoteImageProps } from '@coinbase/cds-common/types'; - -import { RemoteImage } from '../media/RemoteImage'; - -export type { CardRemoteImageProps }; - -/** - * @deprecated render a instead - */ -export const CardRemoteImage = memo(function CardRemoteImage({ - src, - alt = '', - ...props -}: CardRemoteImageProps) { - return ; -}); diff --git a/packages/web/src/cards/CardRoot.tsx b/packages/web/src/cards/CardRoot.tsx new file mode 100644 index 0000000000..30b0bc2ed1 --- /dev/null +++ b/packages/web/src/cards/CardRoot.tsx @@ -0,0 +1,67 @@ +import React, { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../core/polymorphism'; +import { HStack, type HStackProps } from '../layout/HStack'; +import { Pressable, type PressableBaseProps, type PressableProps } from '../system/Pressable'; + +export type CardRootBaseProps = Polymorphic.ExtendableProps< + PressableBaseProps, + { + /** Content to render inside the card. */ + children?: React.ReactNode; + /** + * If true, the CardRoot will be rendered as a Pressable component. + * When false, renders as an HStack for layout purposes. + * @default true if `as` is 'button' or 'a', otherwise false + */ + renderAsPressable?: boolean; + } +>; + +export type CardRootProps = Polymorphic.Props< + AsComponent, + CardRootBaseProps +>; + +type CardRootComponent = (( + props: Polymorphic.Props, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +/** + * CardRoot is the foundational wrapper component for card layouts. + * + * By default, it renders as a `
    ` element using HStack for horizontal layout. + * When `renderAsPressable` is true, it renders as a Pressable component (defaults to ` + + + + mediaPlacement: end with background + + + + + + + + + + + + + + + + No media with background + + + + + + + + + + + + + + + + IconCounterButtons with background + + + + + + + + + + + + + + ); +}; + +/** + * Pressable Cards + * + * To make a ContentCard interactive, wrap it in a Pressable component. + * For proper accessibility, use `as="div"` on the Pressable to render it as a + * non-interactive container, then include an internal button for keyboard and + * screen reader users. + * + * This allows: + * - Mouse/touch users: Click anywhere on the card + * - Screen reader users: Navigate through card content and focus on the action button + * - Keyboard users: Tab to the action button + */ +export const PressableCards = (): JSX.Element => { + const handleCardClick = (e: React.MouseEvent) => { + // Prevent double-triggering when clicking the button + if ((e.target as HTMLElement).closest('button, a')) return; + alert('Card pressed!'); + }; + + return ( + + + Accessible pressable card + + + Uses as="div" with an internal button for keyboard/screen reader access. + + + + + } + title="CoinDesk" + /> + + + + + + + + + + + + + + + Accessible pressable card with background + + + + + } + title="CoinDesk" + /> + + + + + + + + + + + + + + + Accessible pressable card (no media) + + + + + } + title="CoinDesk" + /> + + + + + + + + + + + + + + + Accessible pressable card (disabled) + + + + + + + + + + + + + + + + + + ); +}; + +// Custom Content +export const CustomContent = (): JSX.Element => { + return ( + + + With TextInput + + + + { - - - Full Example with product component - Custom Media - - - - Updated 1hr ago - - } - meta={null} - title={ - - Today's briefing - - } - /> - - - - ETH - - - ↗ 6.37% - - - - - } - /> - - - Full Example with IconCounterButtons + + + With IconCounterButtons - + - - - - - - - + + + + + ); }; +// Product Carousel export const ProductCarousel = () => { return ( - + Full Example with product component - Carousel - - - Crypto moves money forward - - } - /> - - + + + + {[1, 2, 3, 4, 5].map((id) => ( - - - - - Break the cycle - - - 24M views - + + + + + + + + Break the cycle + + + 24M views + + - + ))} - + + ); diff --git a/packages/web/src/cards/ContentCard/__tests__/ContentCard.test.tsx b/packages/web/src/cards/ContentCard/__tests__/ContentCard.test.tsx index 5631dd782e..bdc5d78bc3 100644 --- a/packages/web/src/cards/ContentCard/__tests__/ContentCard.test.tsx +++ b/packages/web/src/cards/ContentCard/__tests__/ContentCard.test.tsx @@ -1,89 +1,277 @@ import { renderA11y } from '@coinbase/cds-web-utils/jest'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Button } from '../../../buttons/Button'; +import { Avatar } from '../../../media/Avatar'; +import { DefaultThemeProvider } from '../../../utils/test'; import { ContentCard, ContentCardBody, ContentCardFooter, ContentCardHeader } from '..'; describe('ContentCard', () => { it('has no accessibility violations', async () => { - expect(await renderA11y(Test Content)).toHaveNoViolations(); + expect( + await renderA11y( + + Test Content + , + ), + ).toHaveNoViolations(); }); + it('renders children', () => { - render(Test Content); + render( + + Test Content + , + ); expect(screen.getByText('Test Content')).toBeInTheDocument(); }); + + it('renders as article by default', () => { + render( + + Test Content + , + ); + expect(screen.getByTestId('content-card').tagName).toBe('ARTICLE'); + }); + + it('renders with custom as prop', () => { + render( + + + Test Content + + , + ); + expect(screen.getByTestId('content-card').tagName).toBe('SECTION'); + }); + + it('renders with background prop', () => { + render( + + + Test Content + + , + ); + expect(screen.getByTestId('content-card')).toBeInTheDocument(); + }); }); describe('ContentCardHeader', () => { it('has no accessibility violations', async () => { - expect(await renderA11y()).toHaveNoViolations(); + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); }); + + it('renders as header by default', () => { + render( + + + , + ); + expect(screen.getByTestId('content-card-header').tagName).toBe('HEADER'); + }); + it('renders title', () => { - render(); + render( + + + , + ); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + }); + + it('renders custom title node', () => { + render( + + Custom Title} /> + , + ); + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + }); + + it('renders thumbnail', () => { + render( + + Test Thumbnail
    } title="Test Title" /> + , + ); + expect(screen.getByText('Test Thumbnail')).toBeInTheDocument(); + }); + + it('renders Avatar as thumbnail', () => { + render( + + } title="Test Title" /> + , + ); expect(screen.getByText('Test Title')).toBeInTheDocument(); }); - it('renders avatar', () => { - render(Test Avatar
    } title="Test Title" />); - expect(screen.getByText('Test Avatar')).toBeInTheDocument(); + it('renders subtitle', () => { + render( + + + , + ); + expect(screen.getByText('Test Subtitle')).toBeInTheDocument(); + }); + + it('renders custom subtitle node', () => { + render( + + Custom Subtitle} + title="Test Title" + /> + , + ); + expect(screen.getByTestId('custom-subtitle')).toBeInTheDocument(); }); - it('renders meta', () => { - render(Test Meta
    } title="Test Title" />); - expect(screen.getByText('Test Meta')).toBeInTheDocument(); + it('renders actions', () => { + render( + + Test Actions} title="Test Title" /> + , + ); + expect(screen.getByText('Test Actions')).toBeInTheDocument(); }); - it('renders end', () => { - render(Test End} title="Test Title" />); - expect(screen.getByText('Test End')).toBeInTheDocument(); + it('renders actions with Button', () => { + const onClick = jest.fn(); + render( + + Action} title="Test Title" /> + , + ); + fireEvent.click(screen.getByText('Action')); + expect(onClick).toHaveBeenCalled(); }); }); describe('ContentCardBody', () => { it('has no accessibility violations', async () => { expect( - await renderA11y(), + await renderA11y( + + + , + ), ).toHaveNoViolations(); }); - it('renders body and label', () => { - render(); - expect(screen.getByText('Test Body')).toBeInTheDocument(); + + it('renders as div by default', () => { + render( + + + , + ); + expect(screen.getByTestId('content-card-body').tagName).toBe('DIV'); + }); + + it('renders title and description', () => { + render( + + + , + ); + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + + it('renders custom title node', () => { + render( + + Custom Title} /> + , + ); + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + }); + + it('renders custom description node', () => { + render( + + Custom Description} + title="Test Title" + /> + , + ); + expect(screen.getByTestId('custom-description')).toBeInTheDocument(); + }); + + it('renders label', () => { + render( + + + , + ); expect(screen.getByText('Test Label')).toBeInTheDocument(); }); it('renders media', () => { - render(Test Media} />); + render( + + Test Media} title="Test Title" /> + , + ); expect(screen.getByText('Test Media')).toBeInTheDocument(); }); it('renders media at the top', () => { - render(Test Media} mediaPosition="top" />); + render( + + Test Media} mediaPlacement="top" title="Test Title" /> + , + ); const mediaElement = screen.getByText('Test Media'); expect(mediaElement).toBeInTheDocument(); - // Check that media is the first child of its parent - expect(mediaElement).toEqual(mediaElement.parentNode?.firstChild); }); it('renders media at the bottom', () => { - render(Test Media} mediaPosition="bottom" />); + render( + + Test Media} mediaPlacement="bottom" title="Test Title" /> + , + ); + const mediaElement = screen.getByText('Test Media'); + expect(mediaElement).toBeInTheDocument(); + }); + + it('renders media at the start', () => { + render( + + Test Media} mediaPlacement="start" title="Test Title" /> + , + ); const mediaElement = screen.getByText('Test Media'); expect(mediaElement).toBeInTheDocument(); - // Check that media is the last child of its parent - expect(mediaElement).toEqual(mediaElement.parentNode?.lastChild); }); - it('renders media at the right', () => { - render(Test Media} mediaPosition="right" />); + it('renders media at the end', () => { + render( + + Test Media} mediaPlacement="end" title="Test Title" /> + , + ); const mediaElement = screen.getByText('Test Media'); expect(mediaElement).toBeInTheDocument(); - // Check that media is the last child of its parent - expect(mediaElement).toEqual(mediaElement.parentNode?.lastChild); }); it('renders children', () => { render( - -
    Test Children
    -
    , + + +
    Test Children
    +
    +
    , ); expect(screen.getByText('Test Children')).toBeInTheDocument(); }); @@ -92,23 +280,82 @@ describe('ContentCardBody', () => { describe('ContentCardFooter', () => { it('has no accessibility violations', async () => { expect( - await renderA11y(Test Footer), + await renderA11y( + + Test Footer + , + ), ).toHaveNoViolations(); }); + it('renders as footer by default', () => { + render( + + Test Footer + , + ); + expect(screen.getByTestId('content-card-footer').tagName).toBe('FOOTER'); + }); + it('renders children', () => { - render(Test Footer); + render( + + Test Footer + , + ); expect(screen.getByText('Test Footer')).toBeInTheDocument(); }); it('renders multiple children', () => { render( - -
    Child 1
    -
    Child 2
    -
    , + + +
    Child 1
    +
    Child 2
    +
    +
    , ); expect(screen.getByText('Child 1')).toBeInTheDocument(); expect(screen.getByText('Child 2')).toBeInTheDocument(); }); + + it('renders with Button children', () => { + const onClick = jest.fn(); + render( + + + + + + , + ); + fireEvent.click(screen.getByText('Primary Action')); + expect(onClick).toHaveBeenCalled(); + expect(screen.getByText('Secondary Action')).toBeInTheDocument(); + }); +}); + +describe('ContentCard composition', () => { + it('renders complete card with all subcomponents', () => { + render( + + + } + title="Header Title" + /> + + + + + + , + ); + expect(screen.getByText('Header Title')).toBeInTheDocument(); + expect(screen.getByText('Subtitle')).toBeInTheDocument(); + expect(screen.getByText('Body Title')).toBeInTheDocument(); + expect(screen.getByText('Body Description')).toBeInTheDocument(); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); }); diff --git a/packages/web/src/cards/DataCard.tsx b/packages/web/src/cards/DataCard.tsx index a2a7a5565f..0407742c81 100644 --- a/packages/web/src/cards/DataCard.tsx +++ b/packages/web/src/cards/DataCard.tsx @@ -1,3 +1,38 @@ +/** + * @deprecated This component is deprecated. Please use the alpha `DataCard` from `@coinbase/cds-web/alpha/data-card` instead. + * + * ### Migration Guide + * + * The new `DataCard` provides more flexibility with custom layouts and visualization components. + * + * **Before:** + * ```jsx + * + * ``` + * + * **After:** + * ```jsx + * import { DataCard } from '@coinbase/cds-web/alpha/data-card'; + * + * } + * > + * + * + * + * + * ``` + */ import React, { memo, useMemo } from 'react'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; import { defaultMediaSize } from '@coinbase/cds-common/tokens/card'; diff --git a/packages/web/src/cards/FeatureEntryCard.tsx b/packages/web/src/cards/FeatureEntryCard.tsx index 6f289f4608..c256876509 100644 --- a/packages/web/src/cards/FeatureEntryCard.tsx +++ b/packages/web/src/cards/FeatureEntryCard.tsx @@ -5,10 +5,16 @@ import { CardBody, type CardBodyBaseProps } from './CardBody'; export type FeatureEntryCardBaseProps = CardBaseProps & CardBodyBaseProps; -/** @deprecated will be removed in v7.0.0 use NudgeCard or UpsellCard instead */ +/** + * @deprecated Use MessagingCard instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v6 + */ export type FeatureEntryCardProps = FeatureEntryCardBaseProps; -/** @deprecated This component will be removed in a future version. Use NudgeCard or UpsellCard instead. */ +/** + * @deprecated Use MessagingCard instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v6 + */ export const FeatureEntryCard = memo(function FeatureEntryCard({ onClick, testID = 'feature-entry-card', diff --git a/packages/web/src/cards/FeedCard.tsx b/packages/web/src/cards/FeedCard.tsx index aee6066333..251612ac5d 100644 --- a/packages/web/src/cards/FeedCard.tsx +++ b/packages/web/src/cards/FeedCard.tsx @@ -37,10 +37,16 @@ export type FeedCardBaseProps = Pick} + * /> + * + * // After + * } + * /> + * ``` + * + * Note: The floating variation (media outside the card container) is no longer supported. + * MediaCard provides a contained layout with media placement options (start/end). + */ export const FloatingAssetCard = ({ className, title, diff --git a/packages/web/src/cards/LikeButton.tsx b/packages/web/src/cards/LikeButton.tsx index af5cd3f2da..c717a705a2 100644 --- a/packages/web/src/cards/LikeButton.tsx +++ b/packages/web/src/cards/LikeButton.tsx @@ -3,6 +3,7 @@ import { interactableHeight } from '@coinbase/cds-common/tokens/interactableHeig import type { SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { getButtonSpacingProps } from '@coinbase/cds-common/utils/getButtonSpacingProps'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { Icon } from '../icons/Icon'; import { HStack } from '../layout/HStack'; import { Pressable, type PressableDefaultElement, type PressableProps } from '../system/Pressable'; @@ -26,13 +27,9 @@ export type LikeButtonBaseProps = Pick< export type LikeButtonProps = LikeButtonBaseProps & PressableProps; -export const LikeButton = memo(function LikeButton({ - count = 0, - compact = true, - flush, - liked = false, - ...props -}: LikeButtonProps) { +export const LikeButton = memo(function LikeButton(_props: LikeButtonProps) { + const mergedProps = useComponentConfig('LikeButton', _props); + const { count = 0, compact = true, flush, liked = false, ...props } = mergedProps; const iconSize = compact ? 's' : 'm'; const size = interactableHeight[compact ? 'compact' : 'regular']; diff --git a/packages/web/src/cards/MediaCard/MediaCardLayout.tsx b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx new file mode 100644 index 0000000000..5b34582a89 --- /dev/null +++ b/packages/web/src/cards/MediaCard/MediaCardLayout.tsx @@ -0,0 +1,157 @@ +import React, { memo, useMemo } from 'react'; + +import { Box } from '../../layout'; +import { HStack } from '../../layout/HStack'; +import { VStack } from '../../layout/VStack'; +import { Text } from '../../typography/Text'; + +export type MediaCardLayoutBaseProps = { + /** Text or React node to display as the card title. Use a Text component to override default color and font. */ + title?: React.ReactNode; + /** Text or React node to display as the card subtitle. Use a Text component to override default color and font. */ + subtitle?: React.ReactNode; + /** Text or React node to display as the card description. Use a Text component to override default color and font. */ + description?: React.ReactNode; + /** React node to display as a thumbnail in the content area. */ + thumbnail: React.ReactNode; + /** React node to display as the main media content. When provided, it will be rendered in a Box container taking up 50% of the card width. */ + media?: React.ReactNode; + /** The position of the media within the card. + * @default 'end' + */ + mediaPlacement?: 'start' | 'end'; +}; + +export type MediaCardLayoutProps = MediaCardLayoutBaseProps & { + classNames?: { + /** Layout container element */ + layoutContainer?: string; + /** Content container element */ + contentContainer?: string; + /** Text container element */ + textContainer?: string; + /** Header container element */ + headerContainer?: string; + /** Media container element */ + mediaContainer?: string; + }; + styles?: { + /** Layout container element */ + layoutContainer?: React.CSSProperties; + /** Content container element */ + contentContainer?: React.CSSProperties; + /** Text container element */ + textContainer?: React.CSSProperties; + /** Header container element */ + headerContainer?: React.CSSProperties; + /** Media container element */ + mediaContainer?: React.CSSProperties; + }; +}; + +export const MediaCardLayout = memo( + ({ + title, + subtitle, + description, + thumbnail, + media, + mediaPlacement = 'end', + classNames = {}, + styles = {}, + }: MediaCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title]); + + const subtitleNode = useMemo( + () => + typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ), + [subtitle], + ); + + const headerNode = useMemo( + () => ( + + {subtitleNode} + {titleNode} + + ), + [subtitleNode, titleNode, styles?.headerContainer, classNames?.headerContainer], + ); + + const descriptionNode = useMemo( + () => + typeof description === 'string' ? ( + + {description} + + ) : ( + description + ), + [description], + ); + + const contentNode = useMemo( + () => ( + + {thumbnail} + + {headerNode} + {descriptionNode} + + + ), + [ + thumbnail, + headerNode, + descriptionNode, + styles?.contentContainer, + classNames?.contentContainer, + classNames?.textContainer, + styles?.textContainer, + ], + ); + + const mediaNode = useMemo(() => { + if (media) { + return ( + + {media} + + ); + } + }, [media, styles?.mediaContainer, classNames?.mediaContainer]); + + return ( + + {mediaPlacement === 'start' ? mediaNode : contentNode} + {mediaPlacement === 'end' ? mediaNode : contentNode} + + ); + }, +); diff --git a/packages/web/src/cards/MediaCard/__figma__/MediaCard.figma.tsx b/packages/web/src/cards/MediaCard/__figma__/MediaCard.figma.tsx new file mode 100644 index 0000000000..0561cc52db --- /dev/null +++ b/packages/web/src/cards/MediaCard/__figma__/MediaCard.figma.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ethBackground } from '@coinbase/cds-common/internal/data/assets'; +import { figma } from '@figma/code-connect'; + +import { Avatar, RemoteImage } from '../../../media'; +import { MediaCard } from '../'; + +figma.connect( + MediaCard, + 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-18302&m=dev', + { + imports: [ + "import { MediaCard } from '@coinbase/cds-web/cards/MediaCard'", + "import { Avatar } from '@coinbase/cds-web/media/Avatar'", + ], + props: { + title: figma.string('title'), + subtitle: figma.boolean('show subtitle', { + true: figma.string('↳ subtitle'), + false: undefined, + }), + description: figma.boolean('show subdetail', { + true: figma.instance('↳ subdetail'), + false: undefined, + }), + thumbnail: figma.boolean('show media', { + true: figma.instance('↳ media'), + false: undefined, + }), + mediaPlacement: figma.enum('image placement', { + left: 'start', + right: 'end', + none: undefined, + }), + media: figma.enum('image placement', { + left: , + right: , + none: undefined, + }), + }, + example: (props) => , + }, +); diff --git a/packages/web/src/cards/MediaCard/__tests__/MediaCard.test.tsx b/packages/web/src/cards/MediaCard/__tests__/MediaCard.test.tsx new file mode 100644 index 0000000000..99d7676d18 --- /dev/null +++ b/packages/web/src/cards/MediaCard/__tests__/MediaCard.test.tsx @@ -0,0 +1,120 @@ +import { renderA11y } from '@coinbase/cds-web-utils/jest'; +import { render, screen } from '@testing-library/react'; + +import { Avatar } from '../../../media/Avatar'; +import { RemoteImage } from '../../../media/RemoteImage'; +import { DefaultThemeProvider } from '../../../utils/test'; +import { MediaCard } from '..'; + +const exampleProps = { + title: 'Test Title', + thumbnail: , + mediaPlacement: 'end' as const, +}; + +describe('MediaCard', () => { + it('passes accessibility', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('passes accessibility with all props', async () => { + expect( + await renderA11y( + + } + subtitle="Test Subtitle" + /> + , + ), + ).toHaveNoViolations(); + }); + + it('renders the card with the correct title', () => { + render( + + + , + ); + expect(screen.getByText(exampleProps.title)).toBeInTheDocument(); + }); + + it('renders the card with the correct subtitle', () => { + render( + + + , + ); + expect(screen.getByText('Test Subtitle')).toBeInTheDocument(); + }); + + it('renders the card with the correct description', () => { + render( + + + , + ); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + }); + + it('renders thumbnail content', () => { + render( + + Thumb} /> + , + ); + expect(screen.getByTestId('test-thumbnail')).toBeInTheDocument(); + }); + + it('renders media content', () => { + render( + + Media} /> + , + ); + expect(screen.getByTestId('test-media')).toBeInTheDocument(); + }); + + it('renders with mediaPlacement start', () => { + render( + + Media} + mediaPlacement="start" + /> + , + ); + expect(screen.getByTestId('test-media')).toBeInTheDocument(); + expect(screen.getByText(exampleProps.title)).toBeInTheDocument(); + }); + + it('renders custom title node', () => { + render( + + Custom Title} /> + , + ); + expect(screen.getByTestId('custom-title')).toBeInTheDocument(); + }); + + it('renders custom description node', () => { + render( + + Custom Description} + /> + , + ); + expect(screen.getByTestId('custom-description')).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/cards/MediaCard/index.tsx b/packages/web/src/cards/MediaCard/index.tsx new file mode 100644 index 0000000000..59c47427b1 --- /dev/null +++ b/packages/web/src/cards/MediaCard/index.tsx @@ -0,0 +1,82 @@ +import React, { forwardRef, memo } from 'react'; +import type { ThemeVars } from '@coinbase/cds-common'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; +import { CardRoot, type CardRootBaseProps } from '../CardRoot'; + +import { MediaCardLayout, type MediaCardLayoutProps } from './MediaCardLayout'; + +export type MediaCardBaseProps = Polymorphic.ExtendableProps< + Omit, + MediaCardLayoutProps & { + classNames?: { + /** Root element */ + root?: string; + }; + styles?: { + /** Root element */ + root?: React.CSSProperties; + }; + } +>; + +export type MediaCardProps = Polymorphic.Props< + AsComponent, + MediaCardBaseProps +>; + +const mediaCardContainerProps = { + borderRadius: 500 as ThemeVars.BorderRadius, + flexDirection: 'row' as const, + background: 'bgAlternate' as ThemeVars.Color, + overflow: 'hidden' as const, +}; + +type MediaCardComponent = (( + props: MediaCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const MediaCard: MediaCardComponent = memo( + forwardRef, MediaCardBaseProps>( + ( + { + title, + subtitle, + description, + thumbnail, + media, + children, + mediaPlacement = 'end', + as, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + styles: { root: rootStyle, ...layoutStyles } = {}, + className, + style, + ...props + }: MediaCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + + ), + ), +); diff --git a/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx new file mode 100644 index 0000000000..be5f0343a5 --- /dev/null +++ b/packages/web/src/cards/MessagingCard/MessagingCardLayout.tsx @@ -0,0 +1,284 @@ +import { memo, useMemo } from 'react'; + +import { Button } from '../../buttons/Button'; +import { IconButton } from '../../buttons/IconButton'; +import { Box, VStack } from '../../layout'; +import { HStack } from '../../layout/HStack'; +import { Pressable } from '../../system/Pressable'; +import { Tag } from '../../tag/Tag'; +import { Text } from '../../typography/Text'; + +export type MessagingCardLayoutProps = { + /** Type of messaging card. Determines background color and text color. */ + type: 'upsell' | 'nudge'; + /** Text or React node to display as the card title. Use a Text component to override default color and font. */ + title?: React.ReactNode; + /** Text or React node to display as the card description. Use a Text component to override default color and font. */ + description?: React.ReactNode; + /** Text or React node to display as a tag. When a string is provided, it will be rendered in a Tag component. */ + tag?: React.ReactNode; + /** + * Action element to display. Can be a string (renders as default button) or a custom ReactNode. + * When a string is provided, use `onActionButtonClick` to handle clicks. + */ + action?: React.ReactNode; + /** Callback fired when the action button is clicked. Only used when `action` is a string. */ + onActionButtonClick?: (event: React.MouseEvent) => void; + /** Accessibility label for the action button. Only used when `action` is a string. + * @default action value (when action is a string) + */ + actionButtonAccessibilityLabel?: string; + /** React node to display as the dismiss button. When provided, this will be rendered instead of the default dismiss button. */ + dismissButton?: React.ReactNode; + /** Callback fired when the dismiss button is clicked. When provided, a default dismiss button will be rendered in the top-right corner. */ + onDismissButtonClick?: (event: React.MouseEvent) => void; + /** Accessibility label for the dismiss button. + * @default 'Dismiss {title}' when title is a string, otherwise 'Dismiss card' + */ + dismissButtonAccessibilityLabel?: string; + /** Placement of the media content relative to the text content. + * @default 'end' + */ + mediaPlacement: 'start' | 'end'; + /** React node to display as the main media content. When provided, it will be rendered in a Box container. */ + media?: React.ReactNode; + styles?: { + /** Layout container element */ + layoutContainer?: React.CSSProperties; + /** Content container element */ + contentContainer?: React.CSSProperties; + /** Text container element */ + textContainer?: React.CSSProperties; + /** Media container element */ + mediaContainer?: React.CSSProperties; + /** Dismiss button container element */ + dismissButtonContainer?: React.CSSProperties; + }; + classNames?: { + /** Layout container element */ + layoutContainer?: string; + /** Content container element */ + contentContainer?: string; + /** Text container element */ + textContainer?: string; + /** Media container element */ + mediaContainer?: string; + /** Dismiss button container element */ + dismissButtonContainer?: string; + }; +}; + +export const MessagingCardLayout = memo( + ({ + type, + title, + description, + tag, + action, + onActionButtonClick, + actionButtonAccessibilityLabel, + onDismissButtonClick, + dismissButtonAccessibilityLabel, + mediaPlacement = 'end', + media, + styles = {}, + classNames = {}, + dismissButton, + }: MessagingCardLayoutProps) => { + const titleNode = useMemo(() => { + if (typeof title === 'string') { + return ( + + {title} + + ); + } + return title; + }, [title, type]); + + const descriptionNode = useMemo(() => { + if (typeof description === 'string') { + return ( + + {description} + + ); + } + return description; + }, [description, type]); + + const tagNode = useMemo(() => { + if (typeof tag === 'string') { + return {tag}; + } + return tag; + }, [tag]); + + const actionButtonNode = useMemo(() => { + if (!action) return null; + + // If action is a string, render in a default button + if (typeof action === 'string') { + const handleActionClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onActionButtonClick?.(event); + }; + + if (type === 'upsell') { + return ( + + ); + } + + return ( + + + {action} + + + ); + } + + // Otherwise, render action as-is (custom React element) + return action; + }, [action, actionButtonAccessibilityLabel, onActionButtonClick, type]); + + const computedDismissButtonAccessibilityLabel = useMemo(() => { + if (dismissButtonAccessibilityLabel) return dismissButtonAccessibilityLabel; + if (typeof title === 'string') return `Dismiss ${title}`; + return 'Dismiss card'; + }, [dismissButtonAccessibilityLabel, title]); + + const dismissButtonNode = useMemo(() => { + if (dismissButton) { + return dismissButton; + } + if (onDismissButtonClick) { + const handleDismiss = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onDismissButtonClick(event); + }; + + return ( + + + + ); + } + return null; + }, [ + classNames?.dismissButtonContainer, + computedDismissButtonAccessibilityLabel, + dismissButton, + onDismissButtonClick, + styles?.dismissButtonContainer, + ]); + + const contentContainerPaddingProps = useMemo(() => { + if (mediaPlacement === 'start' && dismissButtonNode) { + // needs to add additional padding to the end of the content area when media is placed at the start and there is a dismiss button + // this is to avoid dismiss button from overlapping with the content area + return { + paddingY: 2, + paddingStart: 2, + paddingEnd: 6, + } as const; + } + return { + padding: 2, + } as const; + }, [dismissButtonNode, mediaPlacement]); + + const mediaContainerPaddingProps = useMemo(() => { + if (type === 'upsell') return; + if (mediaPlacement === 'start') { + return { paddingStart: 3, paddingEnd: 1 } as const; + } + // when media is placed at the end, we need to add additional padding to the end of the media container + // this is to avoid the dismiss button from overlapping with the media + return dismissButtonNode + ? ({ paddingStart: 1, paddingEnd: 6 } as const) + : ({ paddingStart: 1, paddingEnd: 3 } as const); + }, [dismissButtonNode, mediaPlacement, type]); + + return ( + + + + {tagNode} + {titleNode} + {descriptionNode} + + {actionButtonNode} + + + {media} + + {dismissButtonNode} + + ); + }, +); diff --git a/packages/web/src/cards/MessagingCard/__figma__/MessagingCard.figma.tsx b/packages/web/src/cards/MessagingCard/__figma__/MessagingCard.figma.tsx new file mode 100644 index 0000000000..5d3b5c03b7 --- /dev/null +++ b/packages/web/src/cards/MessagingCard/__figma__/MessagingCard.figma.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { figma } from '@figma/code-connect'; + +import { MessagingCard } from '../'; + +figma.connect( + MessagingCard, + 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/%E2%9C%A8-CDS-Components?node-id=72941-20711&m=dev', + { + imports: ["import { MessagingCard } from '@coinbase/cds-web/cards/MessagingCard'"], + props: { + type: figma.enum('type', { + upsell: 'upsell', + nudge: 'nudge', + }), + title: figma.boolean('show title', { + true: figma.string('↳ title'), + false: undefined, + }), + description: figma.boolean('show subtitle', { + true: figma.string('↳ subtitle'), + false: undefined, + }), + tag: figma.boolean('show tag', { + true: figma.instance('↳ tag'), + false: undefined, + }), + media: figma.instance('media'), + mediaPlacement: figma.enum('media placement', { + left: 'start', + right: 'end', + }), + onDismissButtonClick: figma.boolean('show dismiss', { + true: () => {}, + false: undefined, + }), + }, + example: (props) => , + }, +); diff --git a/packages/web/src/cards/MessagingCard/__tests__/MessagingCard.test.tsx b/packages/web/src/cards/MessagingCard/__tests__/MessagingCard.test.tsx new file mode 100644 index 0000000000..e626d27c7c --- /dev/null +++ b/packages/web/src/cards/MessagingCard/__tests__/MessagingCard.test.tsx @@ -0,0 +1,166 @@ +import { renderA11y } from '@coinbase/cds-web-utils/jest'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { Button } from '../../../buttons/Button'; +import { Pictogram } from '../../../illustrations'; +import { DefaultThemeProvider } from '../../../utils/test'; +import { MessagingCard } from '..'; + +const NoopFn = () => {}; + +const exampleProps = { + title: 'Test Title', + description: 'Test Description', + mediaPlacement: 'end' as const, + type: 'upsell' as const, + media: , +}; + +describe('MessagingCard', () => { + it('passes accessibility for upsell type', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('passes accessibility for nudge type', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('passes accessibility when dismissable', async () => { + expect( + await renderA11y( + + + , + ), + ).toHaveNoViolations(); + }); + + it('renders the card with the correct title', () => { + render( + + + , + ); + expect(screen.getByText(exampleProps.title)).toBeInTheDocument(); + }); + + it('renders the card with the correct description', () => { + render( + + + , + ); + expect(screen.getByText(exampleProps.description)).toBeInTheDocument(); + }); + + it('renders the card with a tag', () => { + render( + + + , + ); + expect(screen.getByText('New')).toBeInTheDocument(); + }); + + it('renders the card with a string action', () => { + render( + + + , + ); + expect(screen.getByText('Learn More')).toBeInTheDocument(); + }); + + it('renders the card with a custom action button', () => { + render( + + Custom Action} + /> + , + ); + expect(screen.getByTestId('custom-action')).toBeInTheDocument(); + expect(screen.getByText('Custom Action')).toBeInTheDocument(); + }); + + it('calls onActionButtonClick when action button is clicked', () => { + const onActionButtonClick = jest.fn(); + render( + + + , + ); + fireEvent.click(screen.getByText('Learn More')); + expect(onActionButtonClick).toHaveBeenCalled(); + }); + + it('renders dismiss button when onDismissButtonClick is provided', () => { + render( + + + , + ); + expect(screen.getByLabelText('Dismiss card')).toBeInTheDocument(); + }); + + it('calls onDismissButtonClick when dismiss button is clicked', () => { + const onDismissButtonClick = jest.fn(); + render( + + + , + ); + fireEvent.click(screen.getByLabelText('Dismiss card')); + expect(onDismissButtonClick).toHaveBeenCalled(); + }); + + it('renders custom dismiss button when provided', () => { + render( + + X} + /> + , + ); + expect(screen.getByTestId('custom-dismiss')).toBeInTheDocument(); + }); + + it('renders media content', () => { + render( + + Media} /> + , + ); + expect(screen.getByTestId('test-media')).toBeInTheDocument(); + }); +}); diff --git a/packages/web/src/cards/MessagingCard/index.tsx b/packages/web/src/cards/MessagingCard/index.tsx new file mode 100644 index 0000000000..7ab9cc763f --- /dev/null +++ b/packages/web/src/cards/MessagingCard/index.tsx @@ -0,0 +1,86 @@ +import { forwardRef, memo } from 'react'; + +import type { Polymorphic } from '../../core/polymorphism'; +import { cx } from '../../cx'; +import { CardRoot, type CardRootBaseProps } from '../CardRoot'; + +import { MessagingCardLayout, type MessagingCardLayoutProps } from './MessagingCardLayout'; + +export type MessagingCardBaseProps = Polymorphic.ExtendableProps< + Omit, + MessagingCardLayoutProps & { + classNames?: { + /** Root element */ + root?: string; + }; + styles?: { + /** Root element */ + root?: React.CSSProperties; + }; + } +>; + +export type MessagingCardProps = + Polymorphic.Props; + +type MessagingCardComponent = (( + props: MessagingCardProps, +) => Polymorphic.ReactReturn) & + Polymorphic.ReactNamed; + +export const MessagingCard: MessagingCardComponent = memo( + forwardRef, MessagingCardBaseProps>( + ( + { + as, + type, + title, + description, + tag, + action, + onActionButtonClick, + actionButtonAccessibilityLabel, + onDismissButtonClick, + dismissButtonAccessibilityLabel, + mediaPlacement, + media, + dismissButton, + styles: { root: rootStyle, ...layoutStyles } = {}, + classNames: { root: rootClassName, ...layoutClassNames } = {}, + className, + style, + ...props + }: MessagingCardProps, + ref?: Polymorphic.Ref, + ) => ( + + + + ), + ), +); diff --git a/packages/web/src/cards/NudgeCard.tsx b/packages/web/src/cards/NudgeCard.tsx index 0c8c0dac73..fcdc3c797b 100644 --- a/packages/web/src/cards/NudgeCard.tsx +++ b/packages/web/src/cards/NudgeCard.tsx @@ -125,6 +125,34 @@ export type NudgeCardBaseProps = { export type NudgeCardProps = NudgeCardBaseProps & Omit, 'title'>; +/** + * @deprecated Use `MessagingCard` with `type="nudge"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + * + * Migration guide: + * ```tsx + * // Before + * + * + * // After + * } + * actions={} + * onDismiss={handleDismiss} + * mediaPlacement="end" + * /> + * ``` + */ export const NudgeCard = ({ title, description, diff --git a/packages/web/src/cards/UpsellCard.tsx b/packages/web/src/cards/UpsellCard.tsx index 6c418b1f22..256bfb7927 100644 --- a/packages/web/src/cards/UpsellCard.tsx +++ b/packages/web/src/cards/UpsellCard.tsx @@ -44,6 +44,34 @@ export type UpsellCardBaseProps = SharedProps & export type UpsellCardProps = UpsellCardBaseProps; +/** + * @deprecated Use `MessagingCard` with `type="upsell"` instead. This will be removed in a future major release. + * @deprecationExpectedRemoval v9 + * + * Migration guide: + * ```tsx + * // Before + * } + * action="Get Started" + * onActionPress={handleAction} + * onDismissPress={handleDismiss} + * /> + * + * // After + * } + * actions={} + * onDismiss={handleDismiss} + * mediaPlacement="end" + * /> + * ``` + */ export const UpsellCard = memo( ({ title, diff --git a/packages/web/src/cards/__figma__/AnnouncementCard.figma.tsx b/packages/web/src/cards/__figma__/AnnouncementCard.figma.tsx index acc86e692e..dd81b9fbd7 100644 --- a/packages/web/src/cards/__figma__/AnnouncementCard.figma.tsx +++ b/packages/web/src/cards/__figma__/AnnouncementCard.figma.tsx @@ -7,7 +7,7 @@ figma.connect( AnnouncementCard, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=61%3A956', { - imports: ["import { AnnouncementCard } from '@coinbase/cds-web/cards/AnnouncementCard';"], + imports: ["import { AnnouncementCard } from '@coinbase/cds-web/cards/AnnouncementCard'"], props: { showtopdivider29390: figma.boolean('show top divider'), illustration5960: figma.instance('illustration'), diff --git a/packages/web/src/cards/__figma__/ContainedAssetCard.figma.tsx b/packages/web/src/cards/__figma__/ContainedAssetCard.figma.tsx index e077dd3bdb..b6be8abbf9 100644 --- a/packages/web/src/cards/__figma__/ContainedAssetCard.figma.tsx +++ b/packages/web/src/cards/__figma__/ContainedAssetCard.figma.tsx @@ -7,7 +7,7 @@ figma.connect( ContainedAssetCard, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10084%3A2875', { - imports: ["import { ContainedAssetCard } from '@coinbase/cds-web/cards/ContainedAssetCard';"], + imports: ["import { ContainedAssetCard } from '@coinbase/cds-web/cards/ContainedAssetCard'"], props: { // showverified1025912: figma.boolean('↳ show verified'), header: figma.instance('header'), diff --git a/packages/web/src/cards/__figma__/FloatingAssetCard.figma.tsx b/packages/web/src/cards/__figma__/FloatingAssetCard.figma.tsx index 83d1f9c42a..74962a4e3e 100644 --- a/packages/web/src/cards/__figma__/FloatingAssetCard.figma.tsx +++ b/packages/web/src/cards/__figma__/FloatingAssetCard.figma.tsx @@ -7,7 +7,7 @@ figma.connect( FloatingAssetCard, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10085%3A3012', { - imports: ["import { FloatingAssetCard } from '@coinbase/cds-web/cards/FloatingAssetCard';"], + imports: ["import { FloatingAssetCard } from '@coinbase/cds-web/cards/FloatingAssetCard'"], props: { // showverified1025919: figma.boolean('↳ show verified'), title: figma.string('title'), diff --git a/packages/web/src/cards/__figma__/NudgeCard.figma.tsx b/packages/web/src/cards/__figma__/NudgeCard.figma.tsx index df44e7f101..f3ce14e07c 100644 --- a/packages/web/src/cards/__figma__/NudgeCard.figma.tsx +++ b/packages/web/src/cards/__figma__/NudgeCard.figma.tsx @@ -7,7 +7,7 @@ figma.connect( NudgeCard, 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10085%3A4433', { - imports: ["import { NudgeCard } from '@coinbase/cds-web/cards/NudgeCard';"], + imports: ["import { NudgeCard } from '@coinbase/cds-web/cards/NudgeCard'"], props: { // onActionPress: figma.boolean('compact', { // true: undefined, diff --git a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx index cc9c71def4..288cacf183 100644 --- a/packages/web/src/cards/__figma__/UpsellCard.figma.tsx +++ b/packages/web/src/cards/__figma__/UpsellCard.figma.tsx @@ -8,8 +8,8 @@ figma.connect( 'https://www.figma.com/design/k5CtyJccNQUGMI5bI4lJ2g/✨-CDS-Components?node-id=10085%3A6279', { imports: [ - "import { useTheme } from '@coinbase/cds-web/hooks/useTheme';", - "import { UpsellCard } from '@coinbase/cds-web/cards/UpsellCard';", + "import { useTheme } from '@coinbase/cds-web/hooks/useTheme'", + "import { UpsellCard } from '@coinbase/cds-web/cards/UpsellCard'", ], props: { media: figma.instance('media'), diff --git a/packages/web/src/cards/__stories__/Card.stories.tsx b/packages/web/src/cards/__stories__/Card.stories.tsx index bd11cd0d01..7052ee96f1 100644 --- a/packages/web/src/cards/__stories__/Card.stories.tsx +++ b/packages/web/src/cards/__stories__/Card.stories.tsx @@ -42,13 +42,6 @@ export const AnnouncementCards = announcementCardBuilder.buildSheet( announcementCards as AnnouncementCardProps[], ); -/* -------------------------------------------------------------------------- */ -/* Data Cards */ -/* -------------------------------------------------------------------------- */ -const dataCardsBuilder = builder(DataCardComponent); -export const DataCard = dataCardsBuilder.build(dataCards[0]); -export const DataCards = dataCardsBuilder.buildSheet(dataCards); - /* -------------------------------------------------------------------------- */ /* FeatureEntry Cards */ /* -------------------------------------------------------------------------- */ diff --git a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx index 564484cf52..5cc34f36db 100644 --- a/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx +++ b/packages/web/src/cards/__stories__/ContainedAssetCard.stories.tsx @@ -9,8 +9,10 @@ import type { ContainedAssetCardProps } from '../ContainedAssetCard'; import { ContainedAssetCard } from '../ContainedAssetCard'; const a11ySkipConfig = { - config: { - rules: [{ id: 'color-contrast', enabled: false }], + options: { + rules: { + 'color-contrast': { enabled: false }, + }, }, }; diff --git a/packages/web/src/cards/__stories__/MediaCard.stories.tsx b/packages/web/src/cards/__stories__/MediaCard.stories.tsx new file mode 100644 index 0000000000..6f08c754bf --- /dev/null +++ b/packages/web/src/cards/__stories__/MediaCard.stories.tsx @@ -0,0 +1,235 @@ +import React, { 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 { MediaCard } from '../MediaCard'; + +const exampleProps = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + width: 320, +} as const; + +const exampleThumbnail = ( + +); + +const exampleMedia = ( + +); + +// Basic Examples +export const Basic = (): JSX.Element => { + return ( + + + + + ); +}; + +// Media Placement +export const MediaPlacement = (): JSX.Element => { + return ( + + + + + ); +}; + +// Polymorphic and Interactive Examples +export const PolymorphicAndInteractive = (): JSX.Element => { + const articleRef = useRef(null); + const anchorPressableRef = useRef(null); + const buttonPressableRef = useRef(null); + return ( + + + + alert('Card clicked!')} + subtitle="Button" + thumbnail={exampleThumbnail} + title="Interactive Card" + width={320} + /> + + ); +}; + +// Text Content +export const TextContent = (): JSX.Element => { + const buttonRef = useRef(null); + return ( + + + + Custom description with bold text and italic text + + } + media={exampleMedia} + subtitle={Custom Subtitle} + thumbnail={exampleThumbnail} + title={Custom Title} + width={320} + /> + + ); +}; + +// Styling +export const Styling = (): JSX.Element => { + return ( + + + + + + ); +}; + +// Multiple Cards +export const MultipleCards = (): JSX.Element => { + const ref = useRef(null); + const ref2 = useRef(null); + return ( + + + {({ isVisible }) => ( + + )} + + + {({ isVisible }) => ( + + )} + + + {({ isVisible }) => ( + console.log('clicked')} + subtitle="ETH" + tabIndex={isVisible ? 0 : -1} + thumbnail={exampleThumbnail} + title="Ethereum" + width={320} + /> + )} + + + ); +}; + +export default { + title: 'Components/Cards/MediaCard', + component: MediaCard, +}; diff --git a/packages/web/src/cards/__stories__/MessagingCard.stories.tsx b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx new file mode 100644 index 0000000000..bc8d5d01cb --- /dev/null +++ b/packages/web/src/cards/__stories__/MessagingCard.stories.tsx @@ -0,0 +1,691 @@ +import React, { useRef, useState } from 'react'; +import { coinbaseOneLogo, svgs } from '@coinbase/cds-common/internal/data/assets'; + +import { Button } from '../../buttons/Button'; +import { IconButton } from '../../buttons/IconButton'; +import { Carousel } from '../../carousel/Carousel'; +import { CarouselItem } from '../../carousel/CarouselItem'; +import { Pictogram } from '../../illustrations'; +import { Box, HStack } from '../../layout'; +import { VStack } from '../../layout/VStack'; +import { RemoteImage } from '../../media/RemoteImage'; +import { Text } from '../../typography'; +import { MessagingCard } from '../MessagingCard'; + +const exampleProps = { + title: 'Title', + description: 'Description', + width: 320, +} as const; + +// Basic Types +export const BasicTypes = (): JSX.Element => { + return ( + + + } + mediaPlacement="end" + title="Upsell Card" + type="upsell" + /> + + } + mediaPlacement="start" + title="Upsell Card" + type="upsell" + /> + } + mediaPlacement="end" + title="Nudge Card" + type="nudge" + /> + } + mediaPlacement="start" + title="Nudge Card" + type="nudge" + /> + + ); +}; + +// Features +export const Features = (): JSX.Element => { + return ( + + + } + mediaPlacement="end" + onDismissButtonClick={() => alert('Card dismissed!')} + title="Dismissible Card" + type="upsell" + /> + } + mediaPlacement="end" + onDismissButtonClick={() => alert('Card dismissed!')} + title="Dismissible Nudge" + type="nudge" + /> + + } + mediaPlacement="end" + tag="New" + title="Tagged Card" + type="upsell" + /> + } + mediaPlacement="end" + tag="New" + title="Tagged Nudge" + type="nudge" + /> + + } + mediaPlacement="end" + onActionButtonClick={() => alert('Action clicked!')} + title="Upsell with Action" + type="upsell" + /> + } + mediaPlacement="end" + onActionButtonClick={() => alert('Action clicked!')} + title="Nudge with Action" + type="nudge" + /> + + } + mediaPlacement="end" + onActionButtonClick={() => alert('Action clicked!')} + onDismissButtonClick={() => alert('Dismissed')} + tag="New" + title="Complete Upsell Card" + type="upsell" + width={360} + /> + } + mediaPlacement="end" + onActionButtonClick={() => alert('Action clicked!')} + onDismissButtonClick={() => alert('Dismissed')} + tag="New" + title="Complete Nudge Card" + type="nudge" + width={360} + /> + alert('Custom button clicked!')} variant="primary"> + Custom Button + + } + description="Upsell card with custom action button" + media={ + + } + mediaPlacement="end" + title="Custom Action Button" + type="upsell" + /> + + + + + } + description="Nudge card with multiple custom buttons" + media={} + mediaPlacement="end" + title="Multiple Action Buttons" + type="nudge" + /> + + alert('Custom dismiss pressed!')} + variant="secondary" + /> +
    + } + media={ + + } + mediaPlacement="end" + title="Custom Dismiss Button" + type="upsell" + /> + + alert('Custom dismiss pressed!')} + variant="secondary" + /> + + } + media={} + mediaPlacement="end" + title="Custom Dismiss Nudge" + type="nudge" + /> + + ); +}; + +// Polymorphic and Interactive Examples +export const PolymorphicAndInteractive = (): JSX.Element => { + const articleRef = useRef(null); + const anchorRef = useRef(null); + const buttonRef = useRef(null); + return ( + + + } + mediaPlacement="end" + type="upsell" + /> + + } + mediaPlacement="end" + target="_blank" + title="Interactive Card" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + target="_blank" + title="Interactive Nudge" + type="nudge" + width={320} + /> + + } + mediaPlacement="end" + onClick={() => alert('Card clicked!')} + title="Interactive Card" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + onClick={() => alert('Card clicked!')} + title="Interactive Nudge" + type="nudge" + width={320} + /> + + ); +}; + +// Custom Background Color (use styles.root for non-interactive, blendStyles.background for interactive) +export const CustomBackgroundColor = (): JSX.Element => { + return ( + + + } + mediaPlacement="end" + onClick={() => alert('Card clicked!')} + title="Pressable with Custom Background" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + target="_blank" + title="Link with Custom Background" + type="nudge" + width={320} + /> + + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: 'rgb(var(--blue80))' } }} + title="Non-pressable with Custom Background" + type="upsell" + width={320} + /> + } + mediaPlacement="end" + renderAsPressable={false} + styles={{ root: { backgroundColor: 'rgb(var(--yellow20))' } }} + title="Non-pressable Nudge with Custom Background" + type="nudge" + width={320} + /> + + ); +}; + +// Text Content +export const TextContent = (): JSX.Element => { + return ( + + + } + mediaPlacement="end" + title="This is a very long title text that demonstrates text wrapping" + type="upsell" + width={320} + /> + + Custom description with bold text and italic text + + } + media={ + + } + mediaPlacement="end" + tag={ + + Custom Tag + + } + title={ + + Custom Title + + } + type="upsell" + width={320} + /> + + ); +}; + +// Interactive Dismissible Cards +const cards = [ + { + id: '1', + title: 'Welcome to Coinbase', + description: 'Get started with your crypto journey', + type: 'upsell' as const, + }, + { + id: '2', + title: 'Complete your profile', + description: 'Add your details to unlock more features', + type: 'nudge' as const, + }, + { + id: '3', + title: 'Enable notifications', + description: 'Stay updated on market movements', + type: 'upsell' as const, + }, + { + id: '4', + title: 'Invite friends', + description: 'Earn rewards when friends join', + type: 'nudge' as const, + }, +]; + +export const DismissibleCards = (): JSX.Element => { + const [dismissedIds, setDismissedIds] = useState>(new Set()); + + const handleDismiss = (id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); + }; + + const handleReset = () => { + setDismissedIds(new Set()); + }; + + const visibleCards = cards.filter((card) => !dismissedIds.has(card.id)); + + return ( + + + {visibleCards.map((card) => ( + + ) : ( + + ) + } + mediaPlacement="end" + onDismissButtonClick={() => handleDismiss(card.id)} + title={card.title} + type={card.type} + width={360} + /> + ))} + {visibleCards.length === 0 && ( + + All cards dismissed! + + )} + + + + ); +}; + +export const MultipleCards = (): JSX.Element => { + const ref1 = useRef(null); + const ref2 = useRef(null); + return ( + + + {({ isVisible }) => ( + + } + mediaPlacement="end" + tabIndex={isVisible ? 0 : -1} + title="Card 1" + type="upsell" + /> + )} + + + {({ isVisible }) => ( + } + mediaPlacement="end" + tabIndex={isVisible ? 0 : -1} + tag="Link" + target="_blank" + title={isVisible ? 'Card 2' : undefined} + type="nudge" + /> + )} + + + {({ isVisible }) => ( + + } + mediaPlacement="end" + onClick={() => console.log('clicked')} + tabIndex={isVisible ? 0 : -1} + tag="Action" + title={isVisible ? 'Card 3' : undefined} + type="upsell" + /> + )} + + + ); +}; + +export default { + title: 'Components/Cards/MessagingCard', + component: MessagingCard, +}; diff --git a/packages/web/src/cards/__stories__/NudgeCard.stories.tsx b/packages/web/src/cards/__stories__/NudgeCard.stories.tsx index 8c8bb9edf4..7eb3afa7bc 100644 --- a/packages/web/src/cards/__stories__/NudgeCard.stories.tsx +++ b/packages/web/src/cards/__stories__/NudgeCard.stories.tsx @@ -4,6 +4,7 @@ import { squareAssets } from '@coinbase/cds-common/internal/data/assets'; import { Button } from '../../buttons/Button'; import { HStack } from '../../layout/HStack'; import { VStack } from '../../layout/VStack'; +import { RemoteImage } from '../../media'; import { Text } from '../../typography/Text'; import type { NudgeCardProps } from '../NudgeCard'; import { NudgeCard } from '../NudgeCard'; @@ -38,7 +39,7 @@ const exampleMediaProps: NudgeCardProps = { description: 'Stand with crypto and mint your NFT. ', action: 'Join the movement', onActionPress: () => {}, - media: placeholder, + media: , }; const compactMediaProps: NudgeCardProps = { @@ -46,7 +47,7 @@ const compactMediaProps: NudgeCardProps = { description: 'It will take you to the moon, I promise. WAGMI!', action: undefined, onActionPress: undefined, - media: placeholder, + media: , }; export const Default = () => ( diff --git a/packages/web/src/cards/index.ts b/packages/web/src/cards/index.ts index dd0718d449..0e73f716ed 100644 --- a/packages/web/src/cards/index.ts +++ b/packages/web/src/cards/index.ts @@ -4,6 +4,7 @@ export * from './CardFooter'; export * from './CardGroup'; export * from './CardHeader'; export * from './CardMedia'; +export * from './CardRoot'; // Card variants export * from './AnnouncementCard'; export * from './FeatureEntryCard'; @@ -15,3 +16,7 @@ export * from './NudgeCard'; export * from './UpsellCard'; // Phoenix cards export * from './ContentCard'; +// Media card +export * from './MediaCard'; +// Messaging card +export * from './MessagingCard'; diff --git a/packages/web/src/carousel/Carousel.tsx b/packages/web/src/carousel/Carousel.tsx index b192745c12..c47a5c7619 100644 --- a/packages/web/src/carousel/Carousel.tsx +++ b/packages/web/src/carousel/Carousel.tsx @@ -2,32 +2,45 @@ import React, { forwardRef, memo, useCallback, - useContext, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; +import { useCarouselAutoplay } from '@coinbase/cds-common/carousel/useCarouselAutoplay'; import { useRefMap } from '@coinbase/cds-common/hooks/useRefMap'; import { RefMapContext } from '@coinbase/cds-common/system/RefMapContext'; import type { Rect, SharedAccessibilityProps, SharedProps } from '@coinbase/cds-common/types'; import { css } from '@linaria/core'; import { + animate, domMax, LazyMotion, m, + type Transition, useAnimation, useDragControls, useMotionValue, + useTransform, } from 'framer-motion'; import { cx } from '../cx'; +import { useComponentConfig } from '../hooks/useComponentConfig'; import { type BoxBaseProps, type BoxDefaultElement, type BoxProps } from '../layout/Box'; import { HStack } from '../layout/HStack'; import { VStack } from '../layout/VStack'; import { Text } from '../typography'; +import { + CarouselAutoplayContext, + type CarouselAutoplayContextValue, + CarouselContext, + type CarouselContextValue, + useCarouselAutoplayContext, + useCarouselContext, +} from './CarouselContext'; +import { CarouselItem } from './CarouselItem'; import { DefaultCarouselNavigation } from './DefaultCarouselNavigation'; import { DefaultCarouselPagination } from './DefaultCarouselPagination'; @@ -37,6 +50,25 @@ const defaultCarouselCss = css` } `; +const screenReaderOnlyCss = css` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +`; + +const animationConfig: Transition = { + type: 'spring', + stiffness: 900, + damping: 120, + mass: 4, +}; + export type CarouselItemRenderChildren = React.FC<{ isVisible: boolean }>; export type CarouselItemBaseProps = Omit & { @@ -49,6 +81,11 @@ export type CarouselItemBaseProps = Omit & { * Can be a React node or a function that receives the visibility state. */ children?: CarouselItemRenderChildren | React.ReactNode; + /** + * @internal Used by Carousel to mark clone items for looping. + * Clone items are non-interactive and excluded from tab order. + */ + isClone?: boolean; }; export type CarouselItemProps = Omit, 'children'> & @@ -57,24 +94,17 @@ export type CarouselItemProps = Omit, 'children'> & export type CarouselItemComponent = React.FC; export type CarouselItemElement = React.ReactElement; -export type CarouselContextValue = { - /** - * Set of item IDs that are currently visible in the carousel viewport. - */ - visibleCarouselItems: Set; -}; - -export const CarouselContext = React.createContext(undefined); - -export const useCarouselContext = (): CarouselContextValue => { - const context = useContext(CarouselContext); - if (!context) { - throw new Error('useCarouselContext must be used within a Carousel component'); - } - return context; -}; - -export type CarouselNavigationComponentBaseProps = { +export { CarouselAutoplayContext, CarouselContext, useCarouselAutoplayContext, useCarouselContext }; +export type { CarouselAutoplayContextValue, CarouselContextValue }; + +export type CarouselNavigationComponentBaseProps = Pick< + CarouselBaseProps, + | 'autoplay' + | 'nextPageAccessibilityLabel' + | 'previousPageAccessibilityLabel' + | 'startAutoplayAccessibilityLabel' + | 'stopAutoplayAccessibilityLabel' +> & { /** * Callback for when the previous button is pressed. */ @@ -92,13 +122,13 @@ export type CarouselNavigationComponentBaseProps = { */ disableGoNext?: boolean; /** - * Accessibility label for the next page button. + * Whether autoplay is currently stopped. */ - nextPageAccessibilityLabel?: string; + isAutoplayStopped?: boolean; /** - * Accessibility label for the previous page button. + * Callback fired when the autoplay button is clicked. */ - previousPageAccessibilityLabel?: string; + onToggleAutoplay?: () => void; }; export type CarouselNavigationComponentProps = CarouselNavigationComponentBaseProps & { @@ -131,6 +161,14 @@ export type CarouselPaginationComponentBaseProps = { * Accessibility label for the go to page button. You can optionally pass a function that will receive the pageIndex as an argument, and return an accessibility label string. */ 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 + */ + variant?: 'pill' | 'dot'; }; export type CarouselPaginationComponentProps = CarouselPaginationComponentBaseProps & { @@ -187,7 +225,10 @@ export type CarouselBaseProps = SharedProps & */ snapMode?: 'item' | 'page'; /** - * Hides the navigation arrows (previous/next buttons). + * Hides the navigation arrows (previous/next buttons and autoplay control). + * + * @note If you hide navigation with autoplay, you must provide + * an alternative mechanism for users to pause the carousel. */ hideNavigation?: boolean; /** @@ -213,16 +254,37 @@ export type CarouselBaseProps = SharedProps & title?: React.ReactNode; /** * Accessibility label for the next page button. + * @default 'Next page' */ nextPageAccessibilityLabel?: string; /** * Accessibility label for the previous page button. + * @default 'Previous page' */ previousPageAccessibilityLabel?: string; /** * Accessibility label for the go to page button. + * When a string is provided, it is used as-is for all indicators. + * When a function is provided, it receives the page index and returns a label. + * @default `Go to page X` */ paginationAccessibilityLabel?: string | ((pageIndex: number) => string); + /** + * Accessibility label for starting autoplay. + * @default 'Play Carousel' + */ + startAutoplayAccessibilityLabel?: string; + /** + * Accessibility label for stopping autoplay. + * @default 'Pause Carousel' + */ + stopAutoplayAccessibilityLabel?: string; + /** + * Accessibility label announced by screen readers when the page changes. + * Receives the current page index (0-based) and total pages. + * @default `Page X of Y` + */ + pageChangeAccessibilityLabel?: (activePageIndex: number, totalPages: number) => string; /** * Callback fired when the carousel page changes. */ @@ -235,6 +297,29 @@ export type CarouselBaseProps = SharedProps & * Callback fired when the user ends dragging the carousel. */ onDragEnd?: () => void; + /** + * Enables infinite looping. When true, the carousel will seamlessly + * loop from the last item back to the first. + * @note Requires at least 2 pages worth of content to function. + */ + loop?: boolean; + /** + * Whether autoplay is enabled for the carousel. + */ + autoplay?: boolean; + /** + * The interval in milliseconds for autoplay. + * @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'> & @@ -243,77 +328,65 @@ export type CarouselProps = Omit, 'title'> & * Custom class name for the root element. */ className?: string; - /** - * Custom class names for the component. - */ + /** Custom class names for individual elements of the Carousel component */ classNames?: { - /** - * Custom class name for the root element. - */ + /** Root element */ root?: string; - /** - * Custom class name for the title element. - */ + /** Title text element */ title?: string; - /** - * Custom class name for the navigation element. - */ + /** Navigation controls element */ navigation?: string; - /** - * Custom class name for the pagination element. - */ + /** Pagination indicators element */ pagination?: string; - /** - * Custom class name for the main carousel element. - */ + /** Main carousel track element */ carousel?: string; - /** - * Custom class name for the outer carousel container element. - */ + /** Outer carousel container element */ carouselContainer?: string; }; - /** - * Custom styles for the root element. - */ - style?: React.CSSProperties; - /** - * Custom styles for the component. - */ + /** Custom styles for individual elements of the Carousel component */ styles?: { - /** - * Custom styles for the root element. - */ + /** Root element */ root?: React.CSSProperties; - /** - * Custom styles for the title element. - */ + /** Title text element */ title?: React.CSSProperties; - /** - * Custom styles for the navigation element. - */ + /** Navigation controls element */ navigation?: React.CSSProperties; - /** - * Custom styles for the pagination element. - */ + /** Pagination indicators element */ pagination?: React.CSSProperties; - /** - * Custom styles for the main carousel element. - */ + /** Main carousel track element */ carousel?: React.CSSProperties; - /** - * Custom styles for the outer carousel container element. - */ + /** Outer carousel container element */ carouselContainer?: React.CSSProperties; }; }; +/** + * Wraps a value within a range (min, max) for circular indexing. + * @param min - The minimum value of the range. + * @param max - The maximum value of the range (exclusive). + * @param value - The value to wrap. + * @returns The wrapped value within the range. + */ +const wrap = (min: number, max: number, value: number): number => { + const range = max - min; + return min + ((((value - min) % range) + range) % range); +}; + /** * Calculates the locations of each item in the carousel, offset from the first item. * @param itemRects - The items to get the offsets for. * @returns The item offsets. */ const getItemOffsets = (itemRects: { [itemId: string]: Rect }) => { - const sortedItems = Object.values(itemRects).sort((a, b) => a.x - b.x); + // Filter out clone items (they have IDs starting with "clone-") + const originalItems = Object.entries(itemRects) + .filter(([id]) => !id.startsWith('clone-')) + .map(([, rect]) => rect); + + if (originalItems.length === 0) return []; + + const sortedItems = originalItems.sort((a, b) => a.x - b.x); + const initialItemOffset = sortedItems[0].x; return sortedItems.map((item) => ({ ...item, @@ -340,18 +413,59 @@ const getNearestPageIndexFromOffset = (scrollOffset: number, pageOffsets: number return closestPageIndex; }; +/** + * Finds the nearest offset from a set of candidate offsets, considering loop cycles. + * Checks current, previous, and next cycles to find the shortest path. + * @param currentOffset - The current scroll offset. + * @param candidateOffsets - Array of candidate offsets within a single loop cycle. + * @param loopLength - The total length of one loop cycle. + * @returns The nearest offset and its index in the candidates array. + */ +const findNearestLoopOffset = ( + currentOffset: number, + candidateOffsets: number[], + loopLength: number, +): { offset: number; index: number } => { + const currentCycle = Math.floor(currentOffset / loopLength); + let nearest = { offset: 0, index: 0, distance: Infinity }; + + for (const [index, candidateOffset] of candidateOffsets.entries()) { + for (const cycle of [currentCycle - 1, currentCycle, currentCycle + 1]) { + const cycleOffset = cycle * loopLength + candidateOffset; + const distance = Math.abs(currentOffset - cycleOffset); + if (distance < nearest.distance) { + nearest = { offset: cycleOffset, index, distance }; + } + } + } + + return { offset: nearest.offset, index: nearest.index }; +}; + /** * Calculates the offsets for a given set of items grouped by item. + * @note when looping, all items have a page offset, otherwise we find + * the last item that can start a page and still show all remaining items. * @param items - The items to get the page offsets for. * @param containerWidth - The width of the container. * @param maxScrollOffset - The maximum scroll offset. + * @param loop - Whether looping is enabled. * @returns The page offsets and the total number of pages. */ const getSnapItemPageOffsets = ( items: Rect[], containerWidth: number, maxScrollOffset: number, + loop?: boolean, ): { totalPages: number; pageOffsets: number[] } => { + if (loop) { + const offsets: number[] = []; + for (let i = 0; i < items.length; i++) { + offsets.push(items[i].x); + } + return { totalPages: offsets.length, pageOffsets: offsets }; + } + let lastPageStartIndex = items.length - 1; const lastItem = items[lastPageStartIndex]; const lastItemsEndPosition = lastItem.x + lastItem.width; @@ -441,6 +555,26 @@ const clampWithElasticResistance = ( return offset; }; +/** + * Calculates how many items need to be cloned for looping to fill the viewport. + * For backward clones, pass the items array reversed. + * @param items - The item rects sorted by position (or reversed for backward clones). + * @param containerWidth - The width of the container viewport. + * @returns The number of items to clone. + */ +const getCloneCount = (items: Rect[], containerWidth: number): number => { + let widthSum = 0; + let count = 0; + + for (const item of items) { + widthSum += item.width; + count++; + if (widthSum >= containerWidth) break; + } + + return Math.max(1, count); +}; + /** * Calculates which items are visible in the carousel based on scroll offset and viewport. * @param itemRects - The items to get the visibility for. @@ -472,10 +606,113 @@ const getVisibleItems = ( return visibleItems; }; +/** + * Finds the carousel item element and its rect from a focus event target. + * Returns null if the target is not within a carousel item or is a clone. + * @param target - The focused element. + * @param carouselItemRects - The item rects to search. + * @returns The item ID and rect, or null if not found. + */ +const getFocusedCarouselItemInfo = ( + target: HTMLElement, + carouselItemRects: { [itemId: string]: Rect }, +): { itemId: string; itemRect: Rect } | null => { + const carouselItemElement = target.closest('[data-carousel-item-id]') as HTMLElement | null; + if (!carouselItemElement) return null; + + const itemId = carouselItemElement.dataset.carouselItemId; + if (!itemId || itemId.startsWith('clone-')) return null; + + const itemRect = carouselItemRects[itemId]; + if (!itemRect) return null; + + return { itemId, itemRect }; +}; + +/** + * Checks if an item is fully visible within the current viewport. + * @param itemRect - The item rect to check. + * @param scrollOffset - The current scroll offset (positive value). + * @param containerWidth - The width of the container viewport. + * @param isLoopingActive - Whether looping is active. + * @param loopLength - The total length of one loop cycle. + * @returns Whether the item is fully visible. + */ +const isItemFullyVisible = ( + itemRect: Rect, + scrollOffset: number, + containerWidth: number, + isLoopingActive: boolean, + loopLength: number, +): boolean => { + const adjustedOffset = isLoopingActive + ? ((scrollOffset % loopLength) + loopLength) % loopLength + : scrollOffset; + + const viewportLeft = adjustedOffset; + const viewportRight = adjustedOffset + containerWidth; + const itemLeft = itemRect.x; + const itemRight = itemRect.x + itemRect.width; + + return itemLeft >= viewportLeft && itemRight <= viewportRight; +}; + +/** + * Finds the first focusable element within the first visible carousel item. + * @param visibleCarouselItems - Set of visible item IDs. + * @param carouselItemRects - The item rects for sorting by position. + * @param containerElement - The container element to search within. + * @returns The first focusable element, or null if not found. + */ +const findFirstVisibleItem = ( + visibleCarouselItems: Set, + carouselItemRects: { [itemId: string]: Rect }, + containerElement: HTMLElement | null, +): HTMLElement | null => { + const visibleItemIds = Array.from(visibleCarouselItems).filter((id) => !id.startsWith('clone-')); + + if (visibleItemIds.length === 0 || !containerElement) return null; + + const sortedVisibleIds = visibleItemIds.sort((a, b) => { + const rectA = carouselItemRects[a]; + const rectB = carouselItemRects[b]; + return (rectA?.x ?? 0) - (rectB?.x ?? 0); + }); + + const firstVisibleElement = containerElement.querySelector( + `[data-carousel-item-id="${sortedVisibleIds[0]}"]`, + ); + + if (!firstVisibleElement) return null; + + return firstVisibleElement.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); +}; + +/** + * Finds the page index that best displays the given item. + * @param itemRect - The item rect to find the page for. + * @param pageOffsets - The page offsets to search. + * @returns The page index that shows the item. + */ +const findPageIndexForItem = (itemRect: Rect, pageOffsets: number[]): number => { + for (let i = pageOffsets.length - 1; i >= 0; i--) { + if (pageOffsets[i] <= itemRect.x) { + return i; + } + } + return 0; +}; + +const defaultPageChangeAccessibilityLabel = (activePageIndex: number, totalPages: number) => + `Page ${activePageIndex + 1} of ${totalPages}`; + export const Carousel = memo( forwardRef( - ( - { + (_props: CarouselProps, ref: React.ForwardedRef) => { + const mergedProps = useComponentConfig('Carousel', _props); + const { children, title, hideNavigation, @@ -491,20 +728,24 @@ export const Carousel = memo( nextPageAccessibilityLabel, previousPageAccessibilityLabel, paginationAccessibilityLabel, + startAutoplayAccessibilityLabel, + stopAutoplayAccessibilityLabel, + pageChangeAccessibilityLabel = defaultPageChangeAccessibilityLabel, onChangePage, onDragStart, onDragEnd, + loop, + autoplay, + autoplayInterval = 3000, + paginationVariant, ...props - }: CarouselProps, - ref: React.ForwardedRef, - ) => { + } = mergedProps; const animationApi = useAnimation(); const carouselScrollX = useMotionValue(0); const dragControls = useDragControls(); const [activePageIndex, setActivePageIndex] = useState(0); const containerRef = useRef(null); - const rootRef = useRef(null); const [containerWidth, setContainerWidth] = useState(0); const carouselItemRefMap = useRefMap(); const [carouselItemRects, setCarouselItemRects] = useState<{ @@ -514,31 +755,6 @@ export const Carousel = memo( const isDragEnabled = drag !== 'none'; - const updateVisibleCarouselItems = useCallback( - (scrollOffset: number) => { - if (containerWidth === 0) { - setVisibleCarouselItems(new Set()); - return; - } - - setVisibleCarouselItems(getVisibleItems(carouselItemRects, containerWidth, scrollOffset)); - }, - [carouselItemRects, containerWidth], - ); - - useEffect(() => { - const element = containerRef.current; - if (!element) return; - const observer = new window.ResizeObserver((entries) => { - for (const entry of entries) { - setContainerWidth(entry.contentRect.width); - updateVisibleCarouselItems(Math.abs(carouselScrollX.get())); - } - }); - observer.observe(element); - return () => observer.unobserve(element); - }, [carouselItemRects, carouselScrollX, updateVisibleCarouselItems]); - useEffect(() => { const observer = new window.ResizeObserver(() => { const newRects: { [itemId: string]: Rect } = {}; @@ -579,6 +795,34 @@ export const Carousel = memo( const maxScrollOffset = Math.max(0, contentWidth - containerWidth); const hasDimensions = contentWidth > 0 && containerWidth > 0; + // Calculate gap between items (needed for loopLength to maintain consistent spacing at wrap seam) + const gap = useMemo(() => { + if (Object.keys(carouselItemRects).length < 2) return 0; + const items = getItemOffsets(carouselItemRects); + const firstItemEnd = items[0].x + items[0].width; + const secondItemStart = items[1].x; + return Math.max(0, secondItemStart - firstItemEnd); + }, [carouselItemRects]); + + const shouldLoop = useMemo( + () => loop && hasDimensions && maxScrollOffset > 0, + [loop, hasDimensions, maxScrollOffset], + ); + + const loopLength = useMemo(() => { + if (!shouldLoop) return 0; + return contentWidth + gap; + }, [shouldLoop, contentWidth, gap]); + + const isLoopingActive = Boolean(shouldLoop && loopLength > 0); + + // Derived transform: physics (carouselScrollX) can go to ±∞, visuals (wrappedX) stay bounded + const wrappedX = useTransform(carouselScrollX, (value) => { + if (!shouldLoop || !loopLength) return value; + const wrapped = value % loopLength; + return wrapped > 0 ? wrapped - loopLength : wrapped; + }); + const updateActivePageIndex = useCallback( (newPageIndexOrUpdater: number | ((prevIndex: number) => number)) => { setActivePageIndex((prevIndex) => { @@ -587,9 +831,7 @@ export const Carousel = memo( ? newPageIndexOrUpdater(prevIndex) : newPageIndexOrUpdater; - if (prevIndex !== newPageIndex && onChangePage) { - onChangePage(newPageIndex); - } + if (prevIndex !== newPageIndex) onChangePage?.(newPageIndex); return newPageIndex; }); @@ -597,6 +839,155 @@ export const Carousel = memo( [onChangePage], ); + // Calculate how many items to clone for each direction (enough to fill viewport) + const cloneCounts = useMemo(() => { + if (!shouldLoop || Object.keys(carouselItemRects).length === 0 || containerWidth === 0) { + return { forward: 0, backward: 0 }; + } + const items = getItemOffsets(carouselItemRects); + return { + forward: getCloneCount(items, containerWidth), + backward: getCloneCount([...items].reverse(), containerWidth), + }; + }, [shouldLoop, carouselItemRects, containerWidth]); + + const updateVisibleCarouselItems = useCallback( + (localScrollOffset: number) => { + if (containerWidth === 0) { + setVisibleCarouselItems(new Set()); + return; + } + + // For original items: wrap the offset to check visibility within one cycle + const adjustedOffset = isLoopingActive + ? ((localScrollOffset % loopLength) + loopLength) % loopLength + : localScrollOffset; + + const visibleItems = getVisibleItems(carouselItemRects, containerWidth, adjustedOffset); + + // For clones: check visibility against actual (unwrapped) scroll position + if (isLoopingActive && children) { + const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; + const items = getItemOffsets(carouselItemRects); + const viewportLeft = localScrollOffset; + const viewportRight = localScrollOffset + containerWidth; + + // Check backward clones visibility + const backwardStartIndex = childrenArray.length - cloneCounts.backward; + for (let i = 0; i < cloneCounts.backward; i++) { + const originalIndex = backwardStartIndex + i; + const itemData = items[originalIndex]; + if (itemData) { + const cloneX = itemData.x - loopLength; + const cloneRight = cloneX + itemData.width; + if (cloneX < viewportRight && cloneRight > viewportLeft) { + visibleItems.add(`clone-backward-${childrenArray[originalIndex].props.id}`); + } + } + } + + // Check forward clones visibility + for (let i = 0; i < cloneCounts.forward; i++) { + const itemData = items[i]; + if (itemData) { + const cloneX = itemData.x + loopLength; + const cloneRight = cloneX + itemData.width; + if (cloneX < viewportRight && cloneRight > viewportLeft) { + visibleItems.add(`clone-forward-${childrenArray[i].props.id}`); + } + } + } + } + + setVisibleCarouselItems(visibleItems); + }, + [ + containerWidth, + isLoopingActive, + loopLength, + carouselItemRects, + children, + cloneCounts.backward, + cloneCounts.forward, + ], + ); + + useEffect(() => { + const element = containerRef.current; + if (!element) return; + const observer = new window.ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + updateVisibleCarouselItems(Math.abs(carouselScrollX.get())); + } + }); + observer.observe(element); + return () => observer.unobserve(element); + }, [carouselItemRects, carouselScrollX, updateVisibleCarouselItems]); + + const childrenWithClones = useMemo(() => { + if (!shouldLoop || !loopLength || !children) return children; + if (cloneCounts.forward === 0 && cloneCounts.backward === 0) return children; + + const childrenArray = React.Children.toArray(children) as CarouselItemElement[]; + if (childrenArray.length === 0) return children; + + const result: React.ReactNode[] = []; + const items = getItemOffsets(carouselItemRects); + + // Add backward clones (absolutely positioned before original items) + const itemsToCloneBackward = childrenArray.slice(-cloneCounts.backward); + itemsToCloneBackward.forEach((child, cloneIndex) => { + const originalIndex = childrenArray.length - cloneCounts.backward + cloneIndex; + const itemData = items[originalIndex]; + const cloneId = `clone-backward-${child.props.id}`; + result.push( + + {child.props.children} + , + ); + }); + + // Add original items (in flex flow, normal positions) + result.push(...childrenArray); + + // Add forward clones (in flex flow after original items) + const itemsToCloneForward = childrenArray.slice(0, cloneCounts.forward); + itemsToCloneForward.forEach((child, cloneIndex) => { + const itemData = items[cloneIndex]; + const cloneId = `clone-forward-${child.props.id}`; + result.push( + + {child.props.children} + , + ); + }); + + return result; + }, [shouldLoop, loopLength, children, carouselItemRects, cloneCounts]); + // Calculate pages and their offsets based on snapMode const { totalPages, pageOffsets } = useMemo(() => { if (!hasDimensions || Object.keys(carouselItemRects).length === 0) { @@ -610,6 +1001,7 @@ export const Carousel = memo( getItemOffsets(carouselItemRects), containerWidth, maxScrollOffset, + shouldLoop, ); } else { pageOffsets = getSnapPageOffsets( @@ -628,22 +1020,51 @@ export const Carousel = memo( snapMode, containerWidth, maxScrollOffset, + shouldLoop, updateActivePageIndex, ]); + const { + isPlaying, + isStopped, + isPaused, + start, + stop, + toggle, + reset, + pause, + resume, + getRemainingTime, + addCompletionListener, + } = useCarouselAutoplay({ + enabled: autoplay ?? false, + interval: autoplayInterval, + }); + const goToPage = useCallback( (page: number) => { const newPage = Math.max(0, Math.min(totalPages - 1, page)); updateActivePageIndex(newPage); - const targetOffset = pageOffsets[newPage]; - updateVisibleCarouselItems(targetOffset); - // Carousel needs to scroll to the left to view pages to the right - animationApi.start({ - x: -targetOffset, - transition: { type: 'tween', duration: 0.25 }, - }); + updateVisibleCarouselItems(pageOffsets[newPage]); + + const targetOffset = isLoopingActive + ? findNearestLoopOffset(-carouselScrollX.get(), [pageOffsets[newPage]], loopLength) + .offset + : pageOffsets[newPage]; + + animate(carouselScrollX, -targetOffset, animationConfig); + reset(); }, - [totalPages, pageOffsets, animationApi, updateVisibleCarouselItems, updateActivePageIndex], + [ + totalPages, + updateActivePageIndex, + updateVisibleCarouselItems, + pageOffsets, + isLoopingActive, + carouselScrollX, + loopLength, + reset, + ], ); useImperativeHandle( @@ -656,40 +1077,195 @@ export const Carousel = memo( [activePageIndex, totalPages, goToPage], ); + useEffect(() => { + if (!autoplay || totalPages === 0) return; + + const unsubscribe = addCompletionListener(() => { + const nextPage = wrap(0, totalPages, activePageIndex + 1); + goToPage(nextPage); + }); + return unsubscribe; + }, [autoplay, addCompletionListener, activePageIndex, totalPages, goToPage]); + + const handleGoNext = useCallback(() => { + const nextPage = shouldLoop + ? wrap(0, totalPages, activePageIndex + 1) + : activePageIndex + 1; + goToPage(nextPage); + }, [shouldLoop, totalPages, activePageIndex, goToPage]); + + const handleGoPrevious = useCallback(() => { + const prevPage = shouldLoop + ? wrap(0, totalPages, activePageIndex - 1) + : activePageIndex - 1; + goToPage(prevPage); + }, [shouldLoop, totalPages, activePageIndex, goToPage]); + const handleDragTransition = useCallback( (targetOffsetScroll: number) => { if (drag === 'none') return targetOffsetScroll; - const negatedTargetOffsetScroll = -targetOffsetScroll; - // Allows us to calculate where the scroll offset will end up - const clampedScrollOffset = clampWithElasticResistance( - negatedTargetOffsetScroll, - maxScrollOffset, - 0, - ); - const closestPageIndex = getNearestPageIndexFromOffset(clampedScrollOffset, pageOffsets); - updateActivePageIndex(closestPageIndex); + const targetOffset = -targetOffsetScroll; + + if (isLoopingActive) { + const { offset: nearestOffset, index: pageIndex } = findNearestLoopOffset( + targetOffset, + pageOffsets, + loopLength, + ); + + if (pageIndex !== activePageIndex) reset(); + + updateActivePageIndex(pageIndex); + + if (drag === 'snap') { + updateVisibleCarouselItems(pageOffsets[pageIndex]); + return -nearestOffset; + } + + const currentCycle = Math.floor(targetOffset / loopLength); + const localOffset = targetOffset - currentCycle * loopLength; + updateVisibleCarouselItems(localOffset); + return targetOffsetScroll; + } else { + // Non-looping logic with clamping + const clampedScrollOffset = clampWithElasticResistance( + targetOffset, + maxScrollOffset, + 0, + ); + const closestPageIndex = getNearestPageIndexFromOffset( + clampedScrollOffset, + pageOffsets, + ); + + if (closestPageIndex !== activePageIndex) reset(); + + updateActivePageIndex(closestPageIndex); + + if (drag === 'snap') { + const snapOffset = pageOffsets[closestPageIndex]; + updateVisibleCarouselItems(snapOffset); + return -snapOffset; + } - if (drag === 'snap') { - const snapOffset = pageOffsets[closestPageIndex]; - updateVisibleCarouselItems(snapOffset); - return -snapOffset; + updateVisibleCarouselItems(clampedScrollOffset); + return targetOffsetScroll; } - // We need the clamped scroll offset to properly determine which items will be visible - updateVisibleCarouselItems(clampedScrollOffset); - // Keeping the target scroll offset will allow framer motion to clamp smoothly - return targetOffsetScroll; }, - [drag, maxScrollOffset, pageOffsets, updateVisibleCarouselItems, updateActivePageIndex], + [ + drag, + isLoopingActive, + pageOffsets, + loopLength, + activePageIndex, + updateActivePageIndex, + updateVisibleCarouselItems, + maxScrollOffset, + reset, + ], ); const handleDragStart = useCallback(() => { onDragStart?.(); - }, [onDragStart]); + pause(); + }, [onDragStart, pause]); const handleDragEnd = useCallback(() => { onDragEnd?.(); - }, [onDragEnd]); + resume(); + }, [onDragEnd, resume]); + + const handlePointerEnter = useCallback(() => { + pause(); + }, [pause]); + + const handlePointerLeave = useCallback(() => { + resume(); + }, [resume]); + + // Resume autoplay when focus leaves the carousel items container + const handleBlur = useCallback( + (event: React.FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + // Only resume if we know focus is going outside the container. + // If relatedTarget is null (e.g., focus leaving window), also resume. + const isLeavingContainer = + !relatedTarget || !containerRef.current?.contains(relatedTarget); + if (isLeavingContainer) { + resume(); + } + }, + [resume], + ); + + // Handle focus moving to an element inside the carousel items container. + // Pauses autoplay when focus enters from outside (keyboard navigation a11y). + // Scrolls to show focused items that are not fully visible. + const handleFocusIn = useCallback( + (event: React.FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + // Check if focus is entering from outside the carousel items container + const isEnteringFromOutside = + !relatedTarget || !containerRef.current?.contains(relatedTarget); + + // Pause autoplay when focus enters the container from outside. + // Only pause if we positively know focus came from outside (relatedTarget exists). + // This avoids false pauses during render, programmatic focus, or test environments. + if (relatedTarget && isEnteringFromOutside) { + pause(); + } + + if (pageOffsets.length === 0 || Object.keys(carouselItemRects).length === 0) return; + + const target = event.target as HTMLElement; + const focusedItem = getFocusedCarouselItemInfo(target, carouselItemRects); + if (!focusedItem) return; + + const { itemRect } = focusedItem; + const currentOffset = Math.abs(carouselScrollX.get()); + + // Item is already visible - no action needed + if ( + isItemFullyVisible(itemRect, currentOffset, containerWidth, isLoopingActive, loopLength) + ) { + return; + } + + if (isEnteringFromOutside) { + // Redirect focus to first focusable element on current page + const focusable = findFirstVisibleItem( + visibleCarouselItems, + carouselItemRects, + containerRef.current, + ); + if (focusable && focusable !== target) { + focusable.focus({ preventScroll: true }); + return; + } + } + + // Navigate to show the focused item and reset autoplay progress + const targetPageIndex = findPageIndexForItem(itemRect, pageOffsets); + if (targetPageIndex !== activePageIndex) { + reset(); + goToPage(targetPageIndex); + } + }, + [ + pause, + reset, + pageOffsets, + carouselItemRects, + carouselScrollX, + isLoopingActive, + loopLength, + containerWidth, + visibleCarouselItems, + activePageIndex, + goToPage, + ], + ); const carouselContextValue = useMemo( () => ({ @@ -698,97 +1274,154 @@ export const Carousel = memo( [visibleCarouselItems], ); + const autoplayContextValue = useMemo( + () => ({ + isEnabled: !!autoplay, + isStopped, + isPaused, + isPlaying, + interval: autoplayInterval, + getRemainingTime, + start, + stop, + toggle, + reset, + pause, + resume, + }), + [ + autoplay, + isStopped, + isPaused, + isPlaying, + autoplayInterval, + getRemainingTime, + start, + stop, + toggle, + reset, + pause, + resume, + ], + ); + return ( - {(title || !hideNavigation) && ( - - {typeof title === 'string' ? ( - - {title} - - ) : ( - title - )} - {!hideNavigation && ( - = totalPages - 1} - disableGoPrevious={activePageIndex <= 0} - nextPageAccessibilityLabel={nextPageAccessibilityLabel} - onGoNext={() => goToPage(activePageIndex + 1)} - onGoPrevious={() => goToPage(activePageIndex - 1)} - previousPageAccessibilityLabel={previousPageAccessibilityLabel} - style={styles?.navigation} - /> - )} - - )} -
    { - if (isDragEnabled) { - // Allows us to grab between items where child wouldn't be selected - dragControls.start(e); - handleDragStart(); - } - }} - style={{ - width: '100%', - position: 'relative', - ...styles?.carouselContainer, - }} - > - - - {children} - - -
    - {!hidePagination && ( - - )} + + {(title || !hideNavigation) && ( + + {typeof title === 'string' ? ( + + {title} + + ) : ( + title + )} + {!hideNavigation && ( + = totalPages - 1) + } + disableGoPrevious={totalPages <= 1 || (!shouldLoop && activePageIndex <= 0)} + isAutoplayStopped={isStopped} + nextPageAccessibilityLabel={nextPageAccessibilityLabel} + onGoNext={handleGoNext} + onGoPrevious={handleGoPrevious} + onToggleAutoplay={toggle} + previousPageAccessibilityLabel={previousPageAccessibilityLabel} + startAutoplayAccessibilityLabel={startAutoplayAccessibilityLabel} + stopAutoplayAccessibilityLabel={stopAutoplayAccessibilityLabel} + style={styles?.navigation} + /> + )} + + )} +
    { + if (isDragEnabled) { + // Allows us to grab between items where child wouldn't be selected + dragControls.start(e); + handleDragStart(); + } + }} + style={{ + width: '100%', + position: 'relative', + ...styles?.carouselContainer, + }} + > + + {totalPages > 0 && ( +
    + {pageChangeAccessibilityLabel(activePageIndex, totalPages)} +
    + )} + + {childrenWithClones} + +
    +
    + {!hidePagination && ( + + )} +
    diff --git a/packages/web/src/carousel/CarouselContext.ts b/packages/web/src/carousel/CarouselContext.ts new file mode 100644 index 0000000000..ab6f535a9a --- /dev/null +++ b/packages/web/src/carousel/CarouselContext.ts @@ -0,0 +1,45 @@ +import React, { useContext } from 'react'; +import type { CarouselAutoplay } from '@coinbase/cds-common'; + +export type CarouselContextValue = { + /** + * Set of item IDs that are currently visible in the carousel viewport. + */ + visibleCarouselItems: Set; +}; + +export const CarouselContext = React.createContext(undefined); + +export const useCarouselContext = (): CarouselContextValue => { + const context = useContext(CarouselContext); + if (!context) { + throw new Error('useCarouselContext must be used within a Carousel component'); + } + return context; +}; + +export type CarouselAutoplayContextValue = Omit< + CarouselAutoplay, + 'remainingTime' | 'addCompletionListener' +> & { + /** + * Whether autoplay is enabled via props. + */ + isEnabled: boolean; + /** + * The autoplay interval duration in milliseconds. + */ + interval: number; +}; + +export const CarouselAutoplayContext = React.createContext< + CarouselAutoplayContextValue | undefined +>(undefined); + +export const useCarouselAutoplayContext = (): CarouselAutoplayContextValue => { + const context = useContext(CarouselAutoplayContext); + if (!context) { + throw new Error('useCarouselAutoplayContext must be used within a Carousel component'); + } + return context; +}; diff --git a/packages/web/src/carousel/CarouselItem.tsx b/packages/web/src/carousel/CarouselItem.tsx index 65c721b613..02bd07cb07 100644 --- a/packages/web/src/carousel/CarouselItem.tsx +++ b/packages/web/src/carousel/CarouselItem.tsx @@ -1,42 +1,51 @@ import React, { memo, useCallback } from 'react'; import { useRefMapContext } from '@coinbase/cds-common/system/RefMapContext'; +import { css } from '@linaria/core'; +import { cx } from '../cx'; import { Box } from '../layout/Box'; -import { type CarouselItemProps, useCarouselContext } from './Carousel'; +import type { CarouselItemProps } from './Carousel'; +import { useCarouselContext } from './CarouselContext'; + +const carouselItemCss = css` + flex-shrink: 0; +`; /** * Individual carousel item component that registers itself with the carousel via RefMapContext. */ -export const CarouselItem = memo(({ id, children, testID, style, ...props }: CarouselItemProps) => { - const { registerRef } = useRefMapContext(); - const { visibleCarouselItems } = useCarouselContext(); +export const CarouselItem = memo( + ({ id, children, testID, style, className, isClone, ...props }: CarouselItemProps) => { + const { registerRef } = useRefMapContext(); + const { visibleCarouselItems } = useCarouselContext(); - const isVisible = visibleCarouselItems.has(id); + const isVisible = visibleCarouselItems.has(id); - const refCallback = useCallback( - (ref: HTMLDivElement) => { - registerRef(id, ref); - }, - [registerRef, id], - ); + const refCallback = useCallback( + (ref: HTMLDivElement) => { + registerRef(id, ref); + }, + [registerRef, id], + ); - return ( - - {typeof children === 'function' ? children({ isVisible }) : children} - - ); -}); + return ( + + {typeof children === 'function' ? children({ isVisible }) : children} + + ); + }, +); diff --git a/packages/web/src/carousel/DefaultCarouselNavigation.tsx b/packages/web/src/carousel/DefaultCarouselNavigation.tsx index 9caf0eebed..854664df93 100644 --- a/packages/web/src/carousel/DefaultCarouselNavigation.tsx +++ b/packages/web/src/carousel/DefaultCarouselNavigation.tsx @@ -36,6 +36,10 @@ export type DefaultCarouselNavigationProps = CarouselNavigationComponentProps & * Test ID for the next button. */ nextButton?: string; + /** + * Test ID for the autoplay button. + */ + autoplayButton?: string; }; /** * Icon to use for the previous button. @@ -45,6 +49,14 @@ export type DefaultCarouselNavigationProps = CarouselNavigationComponentProps & * Icon to use for the next button. */ nextIcon?: IconName; + /** + * Icon to use for the start autoplay button. + */ + startIcon?: IconName; + /** + * Icon to use for the stop autoplay button. + */ + stopIcon?: IconName; /** * Variant of the icon button. */ @@ -73,6 +85,10 @@ export type DefaultCarouselNavigationProps = CarouselNavigationComponentProps & * Custom class name for the next button. */ nextButton?: string; + /** + * Custom class name for the autoplay button. + */ + autoplayButton?: string; }; /** * Custom styles for the component. @@ -90,6 +106,10 @@ export type DefaultCarouselNavigationProps = CarouselNavigationComponentProps & * Custom styles for the next button. */ nextButton?: React.CSSProperties; + /** + * Custom styles for the autoplay button. + */ + autoplayButton?: React.CSSProperties; }; }; @@ -100,8 +120,15 @@ export const DefaultCarouselNavigation = memo(function DefaultCarouselNavigation disableGoNext, nextPageAccessibilityLabel = 'Next page', previousPageAccessibilityLabel = 'Previous page', + autoplay, + isAutoplayStopped, + onToggleAutoplay, + startAutoplayAccessibilityLabel = 'Play Carousel', + stopAutoplayAccessibilityLabel = 'Pause Carousel', previousIcon = 'caretLeft', nextIcon = 'caretRight', + startIcon = 'play', + stopIcon = 'pause', variant = 'secondary', compact, className, @@ -118,6 +145,20 @@ export const DefaultCarouselNavigation = memo(function DefaultCarouselNavigation gap={1} style={{ ...style, ...styles?.root }} > + {autoplay && ( + + )} & { - id: string; +type PaginationIndicatorProps = PressableProps<'button'> & { isActive?: boolean; }; -const PaginationDot = memo(function PressableWithRef({ - id, +const PaginationPill = memo(function PaginationPill({ isActive, ...props -}: PaginationDotProps) { - const { registerRef } = useRefMapContext(); - const refCallback = useCallback( - (ref: HTMLButtonElement) => registerRef(id, ref), - [registerRef, id], - ); +}: PaginationIndicatorProps) { return ( ); }); +const PaginationDot = memo(function PaginationDot({ + isActive, + className, + ...props +}: PaginationIndicatorProps) { + const autoplayContext = useCarouselAutoplayContext(); + const { isPlaying, isEnabled, interval, getRemainingTime } = autoplayContext; + + const showProgress = isActive && isEnabled; + + // Track the progress width as a percentage string for animation + const [progressState, setProgressState] = useState<{ + width: string; + duration: number; + }>({ width: '0%', duration: 0 }); + + // Use a ref to track the last paused progress so we can resume from it + const lastProgressRef = useRef(0); + + useEffect(() => { + if (!showProgress) { + setProgressState({ width: '0%', duration: 0 }); + lastProgressRef.current = 0; + return; + } + + const remainingTime = getRemainingTime(); + const currentProgress = 1 - remainingTime / interval; + + if (isPlaying) { + lastProgressRef.current = currentProgress; + setProgressState({ + width: '100%', + duration: remainingTime / 1000, + }); + } else { + setProgressState({ + width: `${currentProgress * 100}%`, + duration: 0, + }); + lastProgressRef.current = currentProgress; + } + }, [isPlaying, showProgress, interval, getRemainingTime]); + + return ( + + {showProgress && ( + + )} + + ); +}); + +const defaultPaginationAccessibilityLabel = (pageIndex: number) => `Go to page ${pageIndex + 1}`; + export const DefaultCarouselPagination = memo(function DefaultCarouselPagination({ totalPages, activePageIndex, onClickPage, - paginationAccessibilityLabel = 'Go to page', + paginationAccessibilityLabel = defaultPaginationAccessibilityLabel, className, classNames, style, styles, testID = 'carousel-pagination', + variant = 'pill', }: DefaultCarouselPaginationProps) { - const paginationRefMap = useRefMap(); - - const getPaginationKeyDownHandler = useCallback( - (pageIndex: number) => { - const lastIndex = totalPages - 1; - const nextIndex = pageIndex < lastIndex ? pageIndex + 1 : 0; - const prevIndex = pageIndex !== 0 ? pageIndex - 1 : lastIndex; - return function handleKeyDown(e: KeyboardEvent) { - switch (e.key) { - case 'ArrowRight': - e.preventDefault(); - paginationRefMap.getRef(`${testID}-${nextIndex}`)?.focus(); - break; - case 'ArrowLeft': - e.preventDefault(); - paginationRefMap.getRef(`${testID}-${prevIndex}`)?.focus(); - break; - case 'Home': { - e.preventDefault(); - paginationRefMap.getRef(`${testID}-0`)?.focus(); - break; - } - case 'End': { - e.preventDefault(); - paginationRefMap.getRef(`${testID}-${lastIndex}`)?.focus(); - break; - } - case ' ': - case 'Enter': - e.preventDefault(); - onClickPage?.(pageIndex); - break; - default: - break; - } - }; - }, - [paginationRefMap, testID, totalPages, onClickPage], + const isDot = variant === 'dot'; + + const getAccessibilityLabel = useCallback( + (index: number) => + typeof paginationAccessibilityLabel === 'function' + ? paginationAccessibilityLabel(index) + : paginationAccessibilityLabel, + [paginationAccessibilityLabel], ); return ( - - - {totalPages > 0 ? ( - Array.from({ length: totalPages }, (_, index) => ( + + {totalPages > 0 ? ( + Array.from({ length: totalPages }, (_, index) => + isDot ? ( onClickPage?.(index)} + style={styles?.dot} + testID={`${testID}-${index}`} + /> + ) : ( + onClickPage?.(index)} - onKeyDown={getPaginationKeyDownHandler(index)} style={styles?.dot} testID={`${testID}-${index}`} /> - )) - ) : ( - - + ), + ) + ) : ( +