From 40aaa8abaedc0a031431d4f9962e6373c0e61697 Mon Sep 17 00:00:00 2001 From: Maximo Macchi <232606069+maximo-macchi-cb@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:46:14 -0600 Subject: [PATCH 01/12] fix: Prevent `` from changing value on scroll (#413) * Web implementation * Add web example * Bump changelogs --- packages/common/CHANGELOG.md | 4 ++++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 4 ++++ packages/mobile/package.json | 2 +- packages/web/CHANGELOG.md | 6 ++++++ packages/web/package.json | 2 +- packages/web/src/controls/TextInput.tsx | 12 ++++++++++-- .../src/controls/__stories__/TextInput.stories.tsx | 4 ++++ 10 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index ccade5c2fd..dd3e8f0ff4 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index 9a016cc3c6..fe11e240bd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.47.2", + "version": "8.47.3", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 7295b17606..0258284d74 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 5ea99e2fce..95174db179 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.47.2", + "version": "8.47.3", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index d6b320056b..cb205edfa9 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 7f4cec0b36..8981876632 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.47.2", + "version": "8.47.3", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index ed7b6ad17d..61f07915e8 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/web/package.json b/packages/web/package.json index 4961adf451..b4370e4e2f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.47.2", + "version": "8.47.3", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/controls/TextInput.tsx b/packages/web/src/controls/TextInput.tsx index 9fdef90e33..aabb00dc9a 100644 --- a/packages/web/src/controls/TextInput.tsx +++ b/packages/web/src/controls/TextInput.tsx @@ -203,20 +203,28 @@ export const TextInput = memo( 'cds-textinput-label', ]); + // Native browser behavior adjusts the value of numeric inputs when the user is focused on the input + // and scrolls the page. This prevents that behavior so accidental values changes don't occur. + const preventWheelScroll = useCallback((event: WheelEvent) => { + event.preventDefault(); + }, []); + const handleOnFocus = useCallback( (e: React.FocusEvent) => { setFocused(true); onFocus?.(e); + internalRef.current?.addEventListener('wheel', preventWheelScroll); }, - [onFocus], + [onFocus, internalRef, preventWheelScroll], ); const handleOnBlur = useCallback( (e: React.FocusEvent) => { onBlur?.(e); setFocused(false); + internalRef.current?.removeEventListener('wheel', preventWheelScroll); }, - [onBlur], + [onBlur, preventWheelScroll], ); const handleNodePress = useCallback(() => { diff --git a/packages/web/src/controls/__stories__/TextInput.stories.tsx b/packages/web/src/controls/__stories__/TextInput.stories.tsx index 40fda42a10..03b460a67f 100644 --- a/packages/web/src/controls/__stories__/TextInput.stories.tsx +++ b/packages/web/src/controls/__stories__/TextInput.stories.tsx @@ -153,6 +153,10 @@ export const ColorSurge = () => { ); }; +export const NumberInput = function NumberInput() { + return ; +}; + export const Width = function Width() { const widths = ['100%', '30%', '75%', '10%'] as const; From 31eaf4cff39638bbbf6db577254b1b8464a29d29 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Mon, 23 Feb 2026 18:27:55 -0500 Subject: [PATCH 02/12] fix: set paddingStart on Input for compact label (#423) * fix: set paddingStart on Input for compact label * Bump version --- packages/common/CHANGELOG.md | 4 ++++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 ++++++ packages/mobile/package.json | 2 +- packages/mobile/src/controls/TextInput.tsx | 2 +- packages/web/CHANGELOG.md | 4 ++++ packages/web/package.json | 2 +- 9 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index dd3e8f0ff4..e4461931e4 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index fe11e240bd..564b5c49d5 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.47.3", + "version": "8.47.4", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 0258284d74..993128e6bf 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 95174db179..8bbd5367ce 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.47.3", + "version": "8.47.4", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index cb205edfa9..b85e2747b0 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 8981876632..f68fd68795 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.47.3", + "version": "8.47.4", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/controls/TextInput.tsx b/packages/mobile/src/controls/TextInput.tsx index 97aaa20072..9e8e6e487d 100644 --- a/packages/mobile/src/controls/TextInput.tsx +++ b/packages/mobile/src/controls/TextInput.tsx @@ -346,7 +346,7 @@ export const TextInput = memo( importantForAccessibility={startIconA11yLabel ? 'auto' : 'no'} onPress={handleNodePress} > - + {compact && (labelNode ? labelNode : !!label && {label})} {!!start && ( diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 61f07915e8..ac2c8ea772 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/web/package.json b/packages/web/package.json index b4370e4e2f..cca19fc76b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.47.3", + "version": "8.47.4", "description": "Coinbase Design System - Web", "repository": { "type": "git", From 27961f5fd74d95dc26bbdc1765b5904888ecac14 Mon Sep 17 00:00:00 2001 From: Stephen Vergara Date: Tue, 24 Feb 2026 14:22:05 -0500 Subject: [PATCH 03/12] feat: enhance Tag component with icon support and custom nodes (#421) * feat: enhance Tag component with icon support and custom nodes - Added support for start and end icons in the Tag component. - Introduced props for custom start and end nodes. - Updated stories to demonstrate new icon and custom node functionality. - Added tests to verify rendering of icons and custom nodes. * changelog versions * format * gap and padding props --- .../typography/Tag/_mobileExamples.mdx | 43 ++++++++++++++ .../typography/Tag/_webExamples.mdx | 43 ++++++++++++++ packages/common/CHANGELOG.md | 4 ++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 ++ packages/mobile/package.json | 2 +- packages/mobile/src/tag/Tag.tsx | 39 ++++++++++++- .../src/tag/__stories__/Tag.stories.tsx | 30 ++++++++++ .../mobile/src/tag/__tests__/Tag.test.tsx | 48 +++++++++++++++- packages/web/CHANGELOG.md | 6 ++ packages/web/package.json | 2 +- packages/web/src/tag/Tag.tsx | 56 ++++++++++++++++--- .../web/src/tag/__stories__/Tag.stories.tsx | 36 ++++++++++++ packages/web/src/tag/__tests__/Tag.test.tsx | 46 +++++++++++++++ 16 files changed, 355 insertions(+), 14 deletions(-) diff --git a/apps/docs/docs/components/typography/Tag/_mobileExamples.mdx b/apps/docs/docs/components/typography/Tag/_mobileExamples.mdx index dd4328636f..ac890e4e85 100644 --- a/apps/docs/docs/components/typography/Tag/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Tag/_mobileExamples.mdx @@ -32,6 +32,49 @@ You can control the visual prominence of the Tag using the `emphasis` prop. By d ``` +### Icons + +Use the `startIcon` and `endIcon` props to render an icon at the start or end of the tag. + +```jsx + + + Start icon + + + End icon + + + Both icons + + + Promotional with icons + + +``` + +### Custom Nodes + +Use the `start` and `end` props to render custom nodes at the start or end of the tag for full control over styling. + +```jsx + + }> + Custom start node + + }> + Custom end node + + } + end={} + > + Both custom nodes + + +``` + ### Composed Examples #### Account Status diff --git a/apps/docs/docs/components/typography/Tag/_webExamples.mdx b/apps/docs/docs/components/typography/Tag/_webExamples.mdx index 97efb0b4fa..4adf685816 100644 --- a/apps/docs/docs/components/typography/Tag/_webExamples.mdx +++ b/apps/docs/docs/components/typography/Tag/_webExamples.mdx @@ -90,6 +90,49 @@ You can control the visual prominence of the Tag using the `emphasis` prop. By d ``` +### Icons + +Use the `startIcon` and `endIcon` props to render an icon at the start or end of the tag. + +```jsx live + + + Start icon + + + End icon + + + Both icons + + + Promotional with icons + + +``` + +### Custom Nodes + +Use the `start` and `end` props to render custom nodes at the start or end of the tag for full control over styling. + +```jsx live + + }> + Custom start node + + }> + Custom end node + + } + end={} + > + Both custom nodes + + +``` + ### Composed Examples #### Account Status diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index e4461931e4..688831f9d8 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index 564b5c49d5..15aea5200d 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.47.4", + "version": "8.48.0", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 993128e6bf..0eee55b028 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 8bbd5367ce..dacc5ca564 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.47.4", + "version": "8.48.0", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index b85e2747b0..97acdf5dae 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index f68fd68795..4b0eba778a 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.47.4", + "version": "8.48.0", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/tag/Tag.tsx b/packages/mobile/src/tag/Tag.tsx index b6c4d3ba75..ce625bd35f 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, @@ -16,6 +17,7 @@ import type { } from '@coinbase/cds-common/types'; import { useTheme } from '../hooks/useTheme'; +import { Icon } from '../icons/Icon'; import { Box, type BoxProps } from '../layout'; import { Text } from '../typography/Text'; @@ -44,6 +46,18 @@ 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 & @@ -59,8 +73,17 @@ export const Tag = memo( 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 }: TagProps, @@ -78,12 +101,20 @@ export const Tag = memo( background="bg" borderRadius={tagBorderRadiusMap[intent]} dangerouslySetBackground={backgroundColor} + flexDirection={flexDirection} + gap={gap} justifyContent={justifyContent} paddingX={tagHorizontalSpacing[intent]} - paddingY={0.25} + paddingY={paddingY} testID={testID} {...props} > + {start ? ( + start + ) : startIcon ? ( + + ) : null} + {children} + + {end ? ( + end + ) : endIcon ? ( + + ) : null} ); }, 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/web/CHANGELOG.md b/packages/web/CHANGELOG.md index ac2c8ea772..3af2fb90d8 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/web/package.json b/packages/web/package.json index cca19fc76b..61dca2d2fd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.47.4", + "version": "8.48.0", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/tag/Tag.tsx b/packages/web/src/tag/Tag.tsx index 88dfdc1425..b1abfd0273 100644 --- a/packages/web/src/tag/Tag.tsx +++ b/packages/web/src/tag/Tag.tsx @@ -7,17 +7,25 @@ import { tagHorizontalSpacing, } from '@coinbase/cds-common/tokens/tags'; import type { + IconName, SharedAccessibilityProps, SharedProps, TagColorScheme, TagEmphasis, TagIntent, } from '@coinbase/cds-common/types'; +import { css } from '@linaria/core'; import { useTheme } from '../hooks/useTheme'; +import { Icon } from '../icons/Icon'; import { Box, type BoxDefaultElement, type BoxProps } from '../layout/Box'; import { Text } from '../typography/Text'; +const nodeCss = css` + display: inline-flex; + align-items: center; +`; + export const tagStaticClassName = 'cds-tag'; export type TagBaseProps = SharedProps & @@ -45,6 +53,18 @@ 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 & @@ -59,9 +79,17 @@ export const Tag = memo( colorScheme = 'blue', background: customBackground, color: customColor, + start, + startIcon, + startIconActive, + end, + endIcon, + endIconActive, display = 'inline-flex', alignItems = 'center', + gap = 0.5, justifyContent = 'center', + paddingY = 0.25, testID = tagStaticClassName, ...props }: TagProps, @@ -72,14 +100,9 @@ export const Tag = memo( const boxStyles = useMemo( () => ({ backgroundColor: `rgb(${theme.spectrum[customBackground ?? background]})`, - }), - [background, customBackground, theme.spectrum], - ); - const textStyles = useMemo( - () => ({ color: `rgb(${theme.spectrum[customColor ?? foreground]})`, }), - [foreground, customColor, theme.spectrum], + [background, customBackground, foreground, customColor, theme.spectrum], ); return ( @@ -91,22 +114,39 @@ export const Tag = memo( className={tagStaticClassName} data-testid={testID} display={display} + gap={gap} justifyContent={justifyContent} paddingX={tagHorizontalSpacing[intent]} - paddingY={0.25} + paddingY={paddingY} style={boxStyles} testID={testID} {...props} > + {start ? ( + {start} + ) : startIcon ? ( + + + + ) : null} + {children} + + {end ? ( + {end} + ) : endIcon ? ( + + + + ) : null} ); }), diff --git a/packages/web/src/tag/__stories__/Tag.stories.tsx b/packages/web/src/tag/__stories__/Tag.stories.tsx index 34e8bc060f..4e9c9a8682 100644 --- a/packages/web/src/tag/__stories__/Tag.stories.tsx +++ b/packages/web/src/tag/__stories__/Tag.stories.tsx @@ -1,6 +1,7 @@ import React from 'react'; import startCase from 'lodash/startCase'; +import { Icon } from '../../icons/Icon'; import { VStack } from '../../layout'; import { Tag, type TagBaseProps } from '../Tag'; @@ -104,6 +105,41 @@ export const Truncated = () => ( ); +export const WithIcons = () => ( + + + Start icon + + + End icon + + + Both icons + + + Promotional with icons + + +); + +export const WithCustomNodes = () => ( + + }> + Custom start node + + }> + Custom end node + + } + start={} + > + Both custom nodes + + +); + const textStyles = { padding: 0, margin: 0, diff --git a/packages/web/src/tag/__tests__/Tag.test.tsx b/packages/web/src/tag/__tests__/Tag.test.tsx index 7cdfd83aae..3bbdb2de87 100644 --- a/packages/web/src/tag/__tests__/Tag.test.tsx +++ b/packages/web/src/tag/__tests__/Tag.test.tsx @@ -113,6 +113,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')).toBeInTheDocument(); + }); + + it('renders with a custom end node', () => { + render( + + *} testID={TEST_ID}> + Tag + + , + ); + expect(screen.getByTestId('custom-end')).toBeInTheDocument(); + }); + it('verifies tagColorMap maps correctly to tagEmphasisColorMap for backward compatibility', () => { expect(tagColorMap.informational).toEqual(tagEmphasisColorMap.low); expect(tagColorMap.promotional).toEqual(tagEmphasisColorMap.high); From da7229c75a1e3d86213a4e1eaf5121e3bb5d346b Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Tue, 24 Feb 2026 16:59:00 -0500 Subject: [PATCH 04/12] feat: improve link docs (#428) --- .../typography/Link/_mobileExamples.mdx | 134 ++++++++++++----- .../typography/Link/_webExamples.mdx | 139 ++++++++++++++---- .../docs/components/typography/Link/index.mdx | 26 ++-- 3 files changed, 221 insertions(+), 78 deletions(-) diff --git a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx index 60ecbdbda6..bb063fbb78 100644 --- a/apps/docs/docs/components/typography/Link/_mobileExamples.mdx +++ b/apps/docs/docs/components/typography/Link/_mobileExamples.mdx @@ -1,16 +1,60 @@ -### Default +Link renders a pressable [Text](/components/typography/Text) element that opens URLs in an in-app browser by default. It inherits parent text styles and supports the same `font` and `color` props as Text. -Default Link text will inherit parent text styles. +## Basics + +By default, Link inherits the text styles of its parent. Pass a `to` prop to set the destination URL. ```jsx -Default is inherited text + + Check out the Coinbase homepage for more info. + +``` + +## Underline + +Use the `underline` prop to add a text decoration underline to the link. This is important for inline links within body text to ensure they are visually distinguishable from surrounding text. + +```jsx + + Read our{' '} + + terms and conditions + {' '} + before proceeding. + +``` + +### Underline with different fonts + +The underline works across all font styles. + +```jsx + + + body link + + + label1 link + + + caption link + + + legal link + + + title2 link + + ``` -### Styling your link +## Styling + +### Font -To style a `` component, simply wrap the `Link` node in the desired `Text` component, using the `as` prop for your usecase (for example, `p` for body text, `h1`/`h2`/`h3` for titles, etc). However, there may be times when a rendering a span is exactly what you want. In those cases, providing a font prop makes sense. +To style a Link, either wrap it in the desired [Text](/components/typography/Text) component or use the `font` prop directly on the Link. -Wrapping your Link in the appropriate Text element +#### Wrapping in Text ```jsx @@ -57,9 +101,9 @@ Wrapping your Link in the appropriate Text element ``` -### Using the font prop +#### Using the font prop -If you find yourself in a situation where you simply need to style a link as a Text component without wrapping it in the semantically appropriate text element, reach for the `font` prop. +If you need to style a link without wrapping it in a parent text element, use the `font` prop directly. ```jsx @@ -71,39 +115,45 @@ If you find yourself in a situation where you simply need to style a link as a T ``` -### Color override - -```jsx - - With color override - -``` +### Color -### openInNewWindow and rel props +Override the default link color using the `color` prop. ```jsx - - Open window in existing tab + + fgPrimary (default) - - Sets rel to noreferrer + + fgNegative - - Sets rel to noopener + + fgNegative with underline ``` -### Handling onPress +## Navigation + +### Browser options + +Control how the link opens with `forceOpenOutsideApp`, `preventRedirectionIntoApp`, and `readerMode`. ```jsx - console.log('pressed link')} rel="noopener"> - Handles onPress - + + + Opens outside the app + + + Prevents redirect back into app + + + Opens in reader mode (iOS only) + + ``` -### Accessibility +## Accessibility :::tip Accessibility tip @@ -115,10 +165,25 @@ _The link text must have a 3:1 contrast ratio from the surrounding non-link text ::: -### A11y for nested link in text +Use the `underline` prop on inline links within body text to ensure they are distinguishable without relying on color alone. + +```jsx + + By continuing, you agree to the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + +``` + +### Nested link in text -React Native will flatten the nested Text into a string and therefore not able to focus internal link for a11y purpose. You can read more about it in the [official documentation](https://reactnative.dev/docs/text#nested-text). -For better accessibility, we recommend this pattern in mobile. +React Native flattens nested Text into a string and cannot focus internal links for accessibility. See the [official documentation](https://reactnative.dev/docs/text#nested-text) for details. For better accessibility, use this pattern: ```jsx import { AccessibilityInfo, Linking } from 'react-native'; @@ -134,7 +199,6 @@ import { AccessibilityInfo, Linking } from 'react-native'; await openURL('https://www.coinbase.com/'); } } catch (error) { - // Handle or log the error appropriately console.error('Error in onPress handler:', error); } }} @@ -144,11 +208,9 @@ import { AccessibilityInfo, Linking } from 'react-native'; ; ``` -### A11y for multiple nested links +### Multiple nested links -It is an design anti-pattern to have multiple nested links in a single block of text in -react native, since it's bad for accessibility. Consider pattern like this if more than -one link is necessary in one paragraph. +It is a design anti-pattern to have multiple nested links in a single block of text in React Native since it is bad for accessibility. If more than one link is necessary in one paragraph, separate them: ```jsx function MultipleLinksA11yExample() { @@ -166,7 +228,7 @@ function MultipleLinksA11yExample() { ### With padding -When applying padding to a `Text` component that contains instances of `Link`, wrap in a `Box` to prevent the hitbox from being misaligned. +When applying padding to a `Text` component that contains a `Link`, wrap in a `Box` to prevent the hitbox from being misaligned. ```jsx diff --git a/apps/docs/docs/components/typography/Link/_webExamples.mdx b/apps/docs/docs/components/typography/Link/_webExamples.mdx index 0f07afcad9..5bc2905dee 100644 --- a/apps/docs/docs/components/typography/Link/_webExamples.mdx +++ b/apps/docs/docs/components/typography/Link/_webExamples.mdx @@ -1,16 +1,74 @@ -### Default +Link renders a pressable [Text](/components/typography/Text) element as an anchor (``) by default. It inherits parent text styles and supports the same `font` and `color` props as Text. -Default Link text will inherit parent text styles. +## Basics + +By default, Link inherits the text styles of its parent. Pass an `href` to set the destination URL. + +```jsx live + + Check out the Coinbase homepage for more info. + +``` + +## Underline + +Use the `underline` prop to add a text decoration underline to the link. This is particularly important for inline links within body text to meet [WCAG 2.0 accessibility requirements](https://webaim.org/standards/wcag/checklist). + +```jsx live + + Read our{' '} + + terms and conditions + {' '} + before proceeding. + +``` + +### Underline with different fonts + +The underline works across all font styles. + +```jsx live + + + body link + + + label1 link + + + caption link + + + legal link + + + title2 link + + +``` + +### Underline within a paragraph + +When a link appears inline within body text, always use `underline` so users can distinguish the link from surrounding text without relying on color alone. ```jsx live -Default is inherited text + + This is a paragraph with an{' '} + + inline underlined link + {' '} + that is clearly distinguishable from the surrounding text. + ``` -### Styling your link +## Styling + +### Font -To style a `` component, simply wrap the `Link` node in the desired `Text` component, using the `as` prop for your usecase (for example, `p` for body text, `h1`/`h2`/`h3` for titles, etc). However, there may be times when a rendering a span is exactly what you want. In those cases, providing a font prop makes sense. +To style a Link, either wrap it in the desired [Text](/components/typography/Text) component with the appropriate `as` prop for semantic HTML, or use the `font` prop directly on the Link. -Wrapping your Link in the appropriate Text element +#### Wrapping in Text ```jsx live @@ -57,9 +115,9 @@ Wrapping your Link in the appropriate Text element ``` -### Using the font prop +#### Using the font prop -If you find yourself in a situation where you simply need to style a link as a Text component without wrapping it in the semantically appropriate text element, reach for the `font` prop. +If you need to style a link without wrapping it in a semantically appropriate text element, use the `font` prop directly. ```jsx live @@ -71,39 +129,52 @@ If you find yourself in a situation where you simply need to style a link as a T ``` -### Color override - -```jsx live - - With color override - -``` +### Color -### openInNewWindow and rel props +Override the default link color using the `color` prop with any CDS foreground token. ```jsx live - - Open window in existing tab + + fgPrimary (default) - - Sets rel to noreferrer + + fgNegative - - Sets rel to noopener + + fgNegative with underline ``` -### Handling onClick +## Navigation + +### openInNewWindow + +Set `openInNewWindow` to open the link in a new browser tab. ```jsx live - console.log('pressed link')} rel="noopener"> - Handles onClick + + Opens in a new tab ``` -### Accessibility +### rel + +Use the `rel` prop to set the relationship between the current document and the linked resource. + +```jsx live + + + rel=noreferrer + + + rel=noopener + + +``` + +## Accessibility :::tip Accessibility tip @@ -114,3 +185,19 @@ If you find yourself in a situation where you simply need to style a link as a T _The link text must have a 3:1 contrast ratio from the surrounding non-link text. The link must present a "non-color designator" (typically the introduction of the underline) on both mouse hover and keyboard focus. These two requirements help ensure that all users can differentiate links from non-link text, even if they have low vision, color deficiency, or have overridden page colors._ ::: + +Use the `underline` prop on inline links within body text to ensure they are distinguishable without relying on color alone. + +```jsx live + + By continuing, you agree to the{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + + . + +``` diff --git a/apps/docs/docs/components/typography/Link/index.mdx b/apps/docs/docs/components/typography/Link/index.mdx index 0255324b9d..8d5a635c05 100644 --- a/apps/docs/docs/components/typography/Link/index.mdx +++ b/apps/docs/docs/components/typography/Link/index.mdx @@ -5,22 +5,17 @@ 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 { LinkBanner } from '@site/src/components/page/ComponentBanner/LinkBanner'; import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer'; -import MobilePropsTable from './_mobilePropsTable.mdx'; -import mobilePropsToc from ':docgen/mobile/typography/Text/toc-props'; +import webPropsToc from ':docgen/web/typography/Link/toc-props'; +import mobilePropsToc from ':docgen/mobile/typography/Link/toc-props'; import WebPropsTable from './_webPropsTable.mdx'; -import webPropsToc from ':docgen/web/typography/Text/toc-props'; - -import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; +import MobilePropsTable from './_mobilePropsTable.mdx'; import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx'; - -import { sharedParentTypes } from ':docgen/_types/sharedParentTypes'; -import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases'; -import { VStack } from '@coinbase/cds-web/layout'; - +import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx'; import webMetadata from './webMetadata.json'; import mobileMetadata from './mobileMetadata.json'; @@ -30,16 +25,15 @@ import mobileMetadata from './mobileMetadata.json'; webMetadata={webMetadata} mobileMetadata={mobileMetadata} banner={} -/> - + /> } webExamples={} - mobilePropsTable={} - mobileExamples={} webExamplesToc={webExamplesToc} - mobileExamplesToc={mobileExamplesToc} + webPropsTable={} webPropsToc={webPropsToc} + mobileExamples={} + mobileExamplesToc={mobileExamplesToc} + mobilePropsTable={} mobilePropsToc={mobilePropsToc} /> From e4422e6fb5902b20a99824141799be7eae8b1aeb Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Tue, 24 Feb 2026 20:41:33 -0500 Subject: [PATCH 05/12] feat: chart transitions (#400) * feat: support customizing transition on web * Fix enter for scrubber * Wip mobile * Simplify animation and fix issues * Fix bar issues * Support scrubber beacon label transitions * Support stagger delay * Update documentation * Update docs * Fix web transition * Ruse transition types * Move imports * Simplify transition * Fix lint * Regenerate routes * Rename functions and routes * Fix lint * Fix issue * Cleanup comments * Simplify transition names * Simplify types * Continue cleaning up types * Fix lint * Cleanup bar transition props * Remove unused story * Drop ChartTransition * Simplify scrubber * Improve accessories * Fix animation issues with point * Remove extra line * Cleanup resolved transitions * Cleanup isIdleTransition * Cleanup exit transition * Simplify logic * Reuse transition for bar * Cleanup bar chart * Fix formatting * Fix lint * Fix lint * Mock path generation * Re add default border radius for bar * Bump version * Simplify code * Update examples * Update bar stagger delay logic * Simplify transition logic * Add more tests for transitions * Improve examples --- .percy.js | 2 +- .../graphs/AreaChart/_mobileExamples.mdx | 473 ++++++++++++++ .../graphs/AreaChart/_webExamples.mdx | 10 +- .../graphs/BarChart/_mobileExamples.mdx | 593 ++++++++++++++++++ .../graphs/BarChart/_webExamples.mdx | 519 +++++++++++---- .../graphs/CartesianChart/_mobileExamples.mdx | 248 ++++++++ .../graphs/CartesianChart/_webExamples.mdx | 249 ++++++++ .../graphs/LineChart/_mobileExamples.mdx | 6 +- .../graphs/LineChart/_webExamples.mdx | 6 +- apps/mobile-app/scripts/utils/routes.mjs | 15 +- apps/mobile-app/src/routes.ts | 5 +- packages/mobile-visualization/CHANGELOG.md | 6 + packages/mobile-visualization/package.json | 2 +- .../mobile-visualization/src/chart/Path.tsx | 93 ++- .../__stories__/CartesianChart.stories.tsx | 77 --- .../src/chart/__stories__/Chart.stories.tsx | 87 --- .../__stories__/ChartTransitions.stories.tsx | 547 ++++++++++++++++ .../src/chart/area/Area.tsx | 20 +- .../src/chart/area/DottedArea.tsx | 4 +- .../src/chart/area/GradientArea.tsx | 2 + .../src/chart/area/SolidArea.tsx | 13 +- .../src/chart/bar/Bar.tsx | 36 +- .../src/chart/bar/BarChart.tsx | 3 + .../src/chart/bar/BarPlot.tsx | 5 +- .../src/chart/bar/BarStack.tsx | 18 +- .../src/chart/bar/BarStackGroup.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 70 +-- .../src/chart/bar/DefaultBarStack.tsx | 35 +- .../bar/__stories__/BarChart.stories.tsx | 504 +++++++++++++-- .../src/chart/line/DottedLine.tsx | 2 + .../src/chart/line/Line.tsx | 37 +- .../src/chart/line/SolidLine.tsx | 2 + .../line/__stories__/LineChart.stories.tsx | 221 +------ .../src/chart/point/Point.tsx | 112 +++- .../chart/scrubber/DefaultScrubberBeacon.tsx | 12 +- .../src/chart/scrubber/Scrubber.tsx | 78 ++- .../scrubber/ScrubberBeaconLabelGroup.tsx | 54 +- .../src/chart/utils/__tests__/bar.test.ts | 5 + .../chart/utils/__tests__/transition.test.ts | 65 +- .../src/chart/utils/bar.ts | 48 ++ .../src/chart/utils/path.ts | 10 + .../src/chart/utils/transition.ts | 209 ++++-- packages/ui-mobile-playground/CHANGELOG.md | 6 + packages/ui-mobile-playground/package.json | 2 +- packages/ui-mobile-playground/src/routes.ts | 15 +- packages/ui-mobile-visreg/src/routes.ts | 15 +- packages/web-visualization/CHANGELOG.md | 6 +- packages/web-visualization/package.json | 2 +- packages/web-visualization/src/chart/Path.tsx | 141 +++-- .../__stories__/ChartTransitions.stories.tsx | 410 ++++++++++++ .../web-visualization/src/chart/area/Area.tsx | 19 +- .../src/chart/area/AreaChart.tsx | 20 +- .../src/chart/area/DottedArea.tsx | 12 +- .../src/chart/area/GradientArea.tsx | 4 +- .../src/chart/area/SolidArea.tsx | 4 +- .../area/__stories__/AreaChart.stories.tsx | 5 +- .../web-visualization/src/chart/bar/Bar.tsx | 36 +- .../src/chart/bar/BarChart.tsx | 3 + .../src/chart/bar/BarPlot.tsx | 5 +- .../src/chart/bar/BarStack.tsx | 17 +- .../src/chart/bar/BarStackGroup.tsx | 1 + .../src/chart/bar/DefaultBar.tsx | 80 ++- .../src/chart/bar/DefaultBarStack.tsx | 64 +- .../bar/__stories__/BarChart.stories.tsx | 99 ++- .../src/chart/line/DottedLine.tsx | 4 +- .../web-visualization/src/chart/line/Line.tsx | 63 +- .../src/chart/line/LineChart.tsx | 4 + .../src/chart/line/SolidLine.tsx | 4 +- .../line/__stories__/LineChart.stories.tsx | 134 ---- .../src/chart/point/Point.tsx | 98 ++- .../chart/scrubber/DefaultScrubberBeacon.tsx | 58 +- .../scrubber/DefaultScrubberBeaconLabel.tsx | 32 +- .../src/chart/scrubber/Scrubber.tsx | 109 ++-- .../scrubber/ScrubberBeaconLabelGroup.tsx | 48 +- .../chart/utils/__tests__/transition.test.ts | 47 +- .../web-visualization/src/chart/utils/bar.ts | 50 ++ .../web-visualization/src/chart/utils/path.ts | 10 + .../src/chart/utils/transition.ts | 135 ++-- 78 files changed, 4873 insertions(+), 1393 deletions(-) delete mode 100644 packages/mobile-visualization/src/chart/__stories__/Chart.stories.tsx create mode 100644 packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx create mode 100644 packages/web-visualization/src/chart/__stories__/ChartTransitions.stories.tsx diff --git a/.percy.js b/.percy.js index 553b54c9af..abbb673f91 100644 --- a/.percy.js +++ b/.percy.js @@ -17,7 +17,7 @@ module.exports = { 'Components/SparklineInteractive: Fallback Positive', 'Components/LottieStatusAnimation: Default', 'Components/Loaders/MaterialSpinner: Material Spinner Default', - 'Components/Chart/LineChart: Transitions', + 'Components/Chart/CartesianChart: Transitions', ], include: [ // 'Core Components/SparklineInteractive:*', diff --git a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx index d70e905a0e..4f1766b4bf 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx @@ -130,3 +130,476 @@ You can have different area styles for each series. ]} /> ``` + +## Animations + +You can configure chart transitions using the `transitions` prop on individual chart elements, or use the `animate` prop to toggle animations entirely. + +### Customized Transitions + +You can pass in a custom spring or timing based transition to your chart for custom update animations. Use `CartesianChart` with `Area` and `Line` components directly to configure `transitions` per element. + +```jsx +function AnimatedStackedAreas() { + const theme = useTheme(); + const dataCount = 20; + const minYValue = 5000; + const maxDataOffset = 15000; + const minStepOffset = 2500; + const maxStepOffset = 10000; + const updateInterval = 500; + const seriesSpacing = 2000; + const myTransition = { + update: { type: 'spring', stiffness: 700, damping: 20 }, + }; + + const seriesConfig = [ + { id: 'red', label: 'Red', color: `rgb(${theme.spectrum.red40})` }, + { id: 'orange', label: 'Orange', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'yellow', label: 'Yellow', color: `rgb(${theme.spectrum.yellow40})` }, + { id: 'green', label: 'Green', color: `rgb(${theme.spectrum.green40})` }, + { id: 'blue', label: 'Blue', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'indigo', label: 'Indigo', color: `rgb(${theme.spectrum.indigo40})` }, + { id: 'purple', label: 'Purple', color: `rgb(${theme.spectrum.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 +function AnimatedStackedAreas() { + const theme = useTheme(); + const dataCount = 20; + const minYValue = 5000; + const maxDataOffset = 15000; + const minStepOffset = 2500; + const maxStepOffset = 10000; + const updateInterval = 500; + const seriesSpacing = 2000; + + const seriesConfig = [ + { id: 'red', label: 'Red', color: `rgb(${theme.spectrum.red40})` }, + { id: 'orange', label: 'Orange', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'yellow', label: 'Yellow', color: `rgb(${theme.spectrum.yellow40})` }, + { id: 'green', label: 'Green', color: `rgb(${theme.spectrum.green40})` }, + { id: 'blue', label: 'Blue', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'indigo', label: 'Indigo', color: `rgb(${theme.spectrum.indigo40})` }, + { id: 'purple', label: 'Purple', color: `rgb(${theme.spectrum.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 ; +} +``` + +## Gradients + +You can use the `gradient` prop on `series` 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. + +```jsx +function ContinuousGradient() { + const theme = useTheme(); + 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(${theme.spectrum[`${color}20`]})`, + borderColor: `rgb(${theme.spectrum[`${color}50`]})`, + borderWidth: 2, + borderRadius: 1000, + }} + width={16} + /> + ))} + + [ + { offset: min, color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})` }, + { offset: max, color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})` }, + ], + }, + }, + ]} + showYAxis + yAxis={{ + showGrid: true, + }} + > + + + + ); +} +``` + +### Discrete + +You can set multiple stops at the same offset to create a discrete gradient. + +```jsx +function DiscreteGradient() { + const theme = useTheme(); + 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(${theme.spectrum[`${color}20`]})`, + borderColor: `rgb(${theme.spectrum[`${color}50`]})`, + borderWidth: 2, + borderRadius: 1000, + }} + width={16} + /> + ))} + + [ + { 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`]})` }, + ], + }, + }, + ]} + 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 +function XAxisGradient() { + const theme = useTheme(); + 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(${theme.spectrum[`${color}20`]})`, + borderColor: `rgb(${theme.spectrum[`${color}50`]})`, + borderWidth: 2, + borderRadius: 1000, + }} + width={16} + /> + ))} + + [ + { + offset: min, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}80`]})`, + opacity: 0, + }, + { + offset: max, + color: `rgb(${theme.spectrum[`${currentSpectrumColor}20`]})`, + opacity: 1, + }, + ], + }, + }, + ]} + showYAxis + yAxis={{ + showGrid: true, + }} + > + + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx index 2eb5751cb3..21eeb92c3f 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx @@ -132,11 +132,11 @@ You can have different area styles for each series. ## Animations -You can configure chart transitions using the `transition` prop. +You can configure chart transitions using the `transitions` prop. ### Customized Transitions -You can pass in a custom spring based transition to your `AreaChart` for a custom transition. +You can pass in a custom spring based transition to your `AreaChart` for a custom update transition. ```jsx live function AnimatedStackedAreas() { @@ -147,7 +147,6 @@ function AnimatedStackedAreas() { 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))' }, @@ -230,7 +229,10 @@ function AnimatedStackedAreas() { type="dotted" showLines AreaComponent={MemoizedDottedArea} - transition={myTransition} + transitions={{ + enter: { type: 'spring', stiffness: 700, damping: 80 }, + update: { type: 'spring', stiffness: 700, damping: 20 }, + }} inset={0} showYAxis yAxis={{ diff --git a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx index 245f147f70..6b545298e8 100644 --- a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx @@ -661,3 +661,596 @@ function MultipleYAxes() { ); } ``` + +## Animations + +You can configure chart transitions using the `transitions` prop. + +### Customized Transitions + +You can pass in a custom spring based transition to your `BarChart` for a custom update transition. + +```jsx +function AnimatedStackedBars() { + const theme = useTheme(); + 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(${theme.spectrum.red40})` }, + { id: 'orange', label: 'Orange', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'yellow', label: 'Yellow', color: `rgb(${theme.spectrum.yellow40})` }, + { id: 'green', label: 'Green', color: `rgb(${theme.spectrum.green40})` }, + { id: 'blue', label: 'Blue', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'indigo', label: 'Indigo', color: `rgb(${theme.spectrum.indigo40})` }, + { id: 'purple', label: 'Purple', color: `rgb(${theme.spectrum.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() { + 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 }, + }} + /> + ); + } + + return ; +} +``` + +### Disable Animations + +You can also disable animations by setting the `animate` prop to `false`. + +```jsx +function AnimatedStackedBars() { + const theme = useTheme(); + 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(${theme.spectrum.red40})` }, + { id: 'orange', label: 'Orange', color: `rgb(${theme.spectrum.orange40})` }, + { id: 'yellow', label: 'Yellow', color: `rgb(${theme.spectrum.yellow40})` }, + { id: 'green', label: 'Green', color: `rgb(${theme.spectrum.green40})` }, + { id: 'blue', label: 'Blue', color: `rgb(${theme.spectrum.blue40})` }, + { id: 'indigo', label: 'Indigo', color: `rgb(${theme.spectrum.indigo40})` }, + { id: 'purple', label: 'Purple', color: `rgb(${theme.spectrum.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() { + 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 }, + }} + /> + ); + } + + return ; +} +``` + +### Stagger Delay + +You can use the `staggerDelay` property 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 +function StaggeredBars() { + const [data, setData] = useState([45, 80, 120, 95, 150, 110, 85]); + const [key, setKey] = useState(0); + + return ( + + + + + + + Staggered Enter + + + Staggered Update + + + ); +} +``` + +### Delay + +You can use the `delay` property on transitions to add a pause before the animation starts. This is useful for coordinating animations between different chart elements or creating intentional pauses. + +```jsx +function DelayedBars() { + const [key, setKey] = useState(0); + + return ( + + + + No Delay + + + 500ms Delay + + + Stagger + Delay + + + ); +} +``` + +## 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. + + + ); +} +``` diff --git a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx index 253361b837..f88f9ca3d5 100644 --- a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx @@ -658,9 +658,288 @@ You can render bars from separate y axes in one `BarPlot`, however they aren't a ### Custom Components -#### Candlesticks +#### Outlined Stacks -You can set the `BarComponent` prop to render a custom component for bars. +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; + }, + }} + yAxis={{ range: ({ min, max }) => ({ min, max: max - 4 }) }} + style={{ margin: '0 auto' }} + /> + ); +} +``` + +## Animations + +You can configure chart transitions using the `transitions` prop. + +### Customized Transitions + +You can pass in a custom spring based transition to your `BarChart` for a custom update transition. + +```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() { + 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 }, + }} + /> + ); + } + + return ; +} +``` + +### Disable Animations + +You can also disable animations by setting the `animate` prop to `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() { + 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 }, + }} + /> + ); + } + + return ; +} +``` + +## Composed Examples + +### Candlesticks + +You can render a candlestick chart by setting the `BarComponent` prop to a custom candlestick component. ```jsx live function Candlesticks() { @@ -698,10 +977,26 @@ function Candlesticks() { 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 } = useCartesianChartContext(); + 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]; @@ -718,10 +1013,10 @@ function Candlesticks() { const bodyY = openY < closeY ? openY : closeY; return ( - + - + ); }); @@ -795,7 +1090,6 @@ function Candlesticks() { showYAxis BarComponent={CandlestickBarComponent} BarStackComponent={({ children }) => {children}} - animate={false} borderRadius={0} height={{ base: 150, tablet: 200, desktop: 250 }} inset={{ top: 8, bottom: 8, left: 0, right: 0 }} @@ -830,142 +1124,103 @@ function Candlesticks() { } ``` -#### Outlined Stacks - -You can set the `BarStackComponent` prop to render a custom component for stacks. +### 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 }, + ]; -```jsx live -function MonthlyRewards() { - const CustomBarStackComponent = ({ children, ...props }) => { + function SunlightChart({ + data, + height = 300, + ...props + }: Omit & { data: SunlightChartData }) { 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 - value), + yAxisId: 'sunlight', + color: 'rgb(var(--yellow40))', + }, + { + id: 'day', + data: data.map(() => dayLength), + yAxisId: 'day', + color: 'rgb(var(--blue100))', }, ]} - showXAxis - showYAxis xAxis={{ - data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], - }} - yAxis={{ - showGrid: true, - domain: { max: 250 }, + ...props.xAxis, + scaleType: 'band', + data: data.map(({ label }) => label), }} - /> - - Custom Update Animations - - - ); + > + + + + + + ); + } + + function Example() { + return ( + + + + 2026 sunlight data for the first day of each month in Atlanta, Georgia, provided by{' '} + + NOAA + + . + + + ); + } + + return ; } ``` diff --git a/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx index b56d6d7833..6806c631bc 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx @@ -421,6 +421,254 @@ By default, the scrubber will not allow overflow gestures. You can allow overflo ``` +## 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 diff --git a/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx b/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx index a5343805e7..713998db21 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx @@ -364,6 +364,255 @@ function Scrubbing() { } ``` +## 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 diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx index 263dbbbb8c..2ba91024b0 100644 --- a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx @@ -542,7 +542,7 @@ By default, charts will not track gestures that go outside of the chart bounds. ## 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`. +You can configure chart transitions using `transitions` on Line (or LineChart) and `transitions` on [Scrubber](/components/graphs/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 function Transitions() { @@ -666,12 +666,12 @@ function Transitions() { AreaComponent={MyGradient} seriesId="prices" strokeWidth={3} - transition={myTransitionConfig} + transitions={{ update: myTransitionConfig }} /> ); diff --git a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx index df93930cf4..f694ee891a 100644 --- a/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/LineChart/_webExamples.mdx @@ -390,7 +390,7 @@ function Points() { ## 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`. +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() { @@ -514,12 +514,12 @@ function Transitions() { AreaComponent={MyGradient} seriesId="prices" strokeWidth={3} - transition={myTransitionConfig} + transitions={{ update: myTransitionConfig }} /> ); diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs index 20e857e39e..0d2ce796d5 100644 --- a/apps/mobile-app/scripts/utils/routes.mjs +++ b/apps/mobile-app/scripts/utils/routes.mjs @@ -142,9 +142,10 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -246,6 +247,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: () => @@ -840,6 +846,11 @@ export const routes = [ 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/routes.ts b/apps/mobile-app/src/routes.ts index 45dc154916..0d2ce796d5 100644 --- a/apps/mobile-app/src/routes.ts +++ b/apps/mobile-app/src/routes.ts @@ -142,9 +142,10 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', diff --git a/packages/mobile-visualization/CHANGELOG.md b/packages/mobile-visualization/CHANGELOG.md index 72f646363a..8b8249a9db 100644 --- a/packages/mobile-visualization/CHANGELOG.md +++ b/packages/mobile-visualization/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile-visualization/package.json b/packages/mobile-visualization/package.json index 020326eb82..d15fe92a58 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.18", + "version": "3.4.0-beta.19", "description": "Coinbase Design System - Mobile Visualization Native", "repository": { "type": "git", diff --git a/packages/mobile-visualization/src/chart/Path.tsx b/packages/mobile-visualization/src/chart/Path.tsx index d0d9bfddd9..30cf54a5be 100644 --- a/packages/mobile-visualization/src/chart/Path.tsx +++ b/packages/mobile-visualization/src/chart/Path.tsx @@ -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,40 @@ 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 }, + * 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 subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. + */ + transition?: Transition; /** * The SVG path data string. */ @@ -90,21 +130,16 @@ 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< + PathProps, + 'animate' | 'clipRect' | 'clipOffset' | 'clipPath' | 'transitions' | 'transition' + > & { + transitions?: { enter?: Transition; update?: Transition }; + } +>( ({ d = '', initialPath, @@ -116,17 +151,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 +220,7 @@ export const Path = memo((props) => { strokeCap, strokeJoin, children, + transitions, transition, ...pathProps } = props; @@ -197,6 +231,21 @@ export const Path = memo((props) => { 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], + ); + // 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 @@ -206,9 +255,9 @@ export const Path = memo((props) => { useEffect(() => { if (animate && isReady) { - clipProgress.value = withTiming(1, { duration: pathEnterTransitionDuration }); + clipProgress.value = buildTransition(1, enterTransition); } - }, [animate, isReady, clipProgress]); + }, [animate, isReady, clipProgress, enterTransition]); // Create initial and target clip paths for animation const { initialClipPath, targetClipPath } = useMemo(() => { @@ -308,7 +357,7 @@ export const Path = memo((props) => { strokeJoin={strokeJoin} strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} - transition={transition} + transitions={{ enter: enterTransition, update: updateTransition }} > {children} diff --git a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx index e05850f1da..6ffb3b5b93 100644 --- a/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/__stories__/CartesianChart.stories.tsx @@ -425,83 +425,6 @@ function TradingTrends() { ); } -const UVGradient: GradientDefinition = { - axis: 'y', - stops: [ - { offset: 0, color: 'green' }, - { offset: 3, color: 'yellow' }, - { offset: 5, color: 'orange' }, - { offset: 8, color: 'red' }, - { offset: 10, color: 'purple' }, - ], -}; - -const PreviousData = memo( - ({ - children, - currentHour, - clipOffset = 0, - }: { - children: React.ReactNode; - currentHour: number; - clipOffset?: number; - }) => { - // 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__/ChartTransitions.stories.tsx b/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx new file mode 100644 index 0000000000..128e944046 --- /dev/null +++ b/packages/mobile-visualization/src/chart/__stories__/ChartTransitions.stories.tsx @@ -0,0 +1,547 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Button } from '@coinbase/cds-mobile/buttons/Button'; +import { IconButton } from '@coinbase/cds-mobile/buttons/IconButton'; +import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; +import { Text } from '@coinbase/cds-mobile/typography'; + +import { 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 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 && ( + + + + )} + + ); +} + +// --- 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: '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/area/Area.tsx b/packages/mobile-visualization/src/chart/area/Area.tsx index a7d59ae53e..b10433e00b 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,13 +37,13 @@ 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. @@ -57,19 +58,14 @@ 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 @@ -95,6 +91,7 @@ export const Area = memo( baseline, connectNulls, gradient: gradientProp, + transitions, transition, animate, }) => { @@ -159,6 +156,7 @@ export const Area = memo( fillOpacity={fillOpacity} gradient={gradient} transition={transition} + transitions={transitions} yAxisId={matchedSeries?.yAxisId} /> ); diff --git a/packages/mobile-visualization/src/chart/area/DottedArea.tsx b/packages/mobile-visualization/src/chart/area/DottedArea.tsx index 43c77d79c6..2d52eacbca 100644 --- a/packages/mobile-visualization/src/chart/area/DottedArea.tsx +++ b/packages/mobile-visualization/src/chart/area/DottedArea.tsx @@ -66,6 +66,7 @@ export const DottedArea = memo( yAxisId, gradient: gradientProp, animate: animateProp, + transitions, transition, ...pathProps }) => { @@ -96,7 +97,7 @@ export const DottedArea = memo( const animatedClipPath = usePathTransition({ currentPath: d, - transition, + transitions: { update: transition }, }); const staticClipPath = useMemo(() => { @@ -119,6 +120,7 @@ export const DottedArea = memo( d={dottedPath} fill={fill} transition={transition} + transitions={transitions} {...pathProps} > {gradient && } diff --git a/packages/mobile-visualization/src/chart/area/GradientArea.tsx b/packages/mobile-visualization/src/chart/area/GradientArea.tsx index 1f59e0cd34..87e75d7aea 100644 --- a/packages/mobile-visualization/src/chart/area/GradientArea.tsx +++ b/packages/mobile-visualization/src/chart/area/GradientArea.tsx @@ -52,6 +52,7 @@ export const GradientArea = memo( baseline, yAxisId, animate, + transitions, transition, ...pathProps }) => { @@ -80,6 +81,7 @@ export const GradientArea = memo( fill={fill} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} > {gradient && } diff --git a/packages/mobile-visualization/src/chart/area/SolidArea.tsx b/packages/mobile-visualization/src/chart/area/SolidArea.tsx index 4f0d5c8a58..fdbc477559 100644 --- a/packages/mobile-visualization/src/chart/area/SolidArea.tsx +++ b/packages/mobile-visualization/src/chart/area/SolidArea.tsx @@ -27,7 +27,17 @@ 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, + yAxisId, + animate, + transitions, + transition, + gradient, + ...pathProps + }) => { const theme = useTheme(); return ( @@ -37,6 +47,7 @@ export const SolidArea = memo( fill={fill ?? theme.color.fgPrimary} fillOpacity={fillOpacity} transition={transition} + transitions={transitions} {...pathProps} > {gradient && } diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx index 7a156cb9dd..6b5709690a 100644 --- a/packages/mobile-visualization/src/chart/bar/Bar.tsx +++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx @@ -1,7 +1,7 @@ import React, { memo, useMemo } from 'react'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { getBarPath, type Transition } from '../utils'; +import { type BarTransition, getBarPath, type Transition } from '../utils'; import { DefaultBar } from './DefaultBar'; @@ -75,7 +75,37 @@ export type BarBaseProps = { export type BarProps = BarBaseProps & { /** - * Transition configuration for bar animations. + * Transition configuration for enter and update animations. + * @note Disable an animation by passing in null. + * + * @default transitions = {{ + * enter: { type: 'spring', stiffness: 900, damping: 120, staggerDelay: 250 }, + * update: { type: 'spring', stiffness: 900, damping: 120 } + * }} + * + * @example + * // Custom staggered enter and spring update + * transitions={{ enter: { type: 'timing', duration: 500, staggerDelay: 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?: BarTransition | null; + /** + * Transition for subsequent data update animations. + * Set to `null` to disable. + */ + update?: BarTransition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. */ transition?: Transition; }; @@ -119,6 +149,7 @@ export const Bar = memo( borderRadius = 4, roundTop = true, roundBottom = true, + transitions, transition, }) => { const theme = useTheme(); @@ -155,6 +186,7 @@ export const Bar = memo( stroke={stroke} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} width={width} x={x} y={y} diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx index e19577fdaa..8149307de2 100644 --- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx @@ -26,6 +26,7 @@ export type BarChartBaseProps = Omit & { /** @@ -90,6 +91,7 @@ export const BarChart = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, ...chartProps }, @@ -183,6 +185,7 @@ export const BarChart = memo( stroke={stroke} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} /> {children} diff --git a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx index 7d24fefd9f..b265b931d0 100644 --- a/packages/mobile-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarPlot.tsx @@ -29,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. @@ -51,6 +52,7 @@ export const BarPlot = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); @@ -135,6 +137,7 @@ export const BarPlot = memo( strokeWidth={defaultStrokeWidth} totalStacks={stackGroups.length} transition={transition} + transitions={transitions} yAxisId={group.yAxisId} /> ))} diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx index 6bc56b4a96..81c3212db5 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx @@ -3,11 +3,11 @@ 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 type { ChartScaleFunction, Series } from '../utils'; import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient'; import { convertToSerializableScale } from '../utils/scale'; -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; @@ -23,7 +23,7 @@ export type BarSeries = Series & { }; export type BarStackBaseProps = Pick< - BarProps, + BarBaseProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' > & { /** @@ -79,16 +79,11 @@ 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' + 'x' | 'width' | 'categoryIndex' | 'borderRadius' | 'transitions' | 'transition' > & { /** * The y position of the stack. @@ -140,6 +135,7 @@ export const BarStack = memo( barMinSize, stackMinSize, roundBaseline, + transitions, transition, }) => { const theme = useTheme(); @@ -692,6 +688,7 @@ export const BarStack = memo( stroke={defaultStroke} strokeWidth={defaultStrokeWidth} transition={transition} + transitions={transitions} width={bar.width} x={bar.x} y={bar.y} @@ -711,6 +708,7 @@ export const BarStack = memo( roundBottom={stackRoundBottom} roundTop={stackRoundTop} transition={transition} + transitions={transitions} width={stackRect.width} x={stackRect.x} y={stackRect.y} diff --git a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 2ce56d065a..064a1e5763 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -17,6 +17,7 @@ export type BarStackGroupProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'transitions' | 'transition' > & Pick & { diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx index ff9bd80149..d575a75627 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBar.tsx @@ -3,7 +3,8 @@ import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; import { useCartesianChartContext } from '../ChartProvider'; import { Path } from '../Path'; -import { getBarPath } from '../utils'; +import { defaultBarEnterTransition, getBarPath, withStaggerDelayTransition } from '../utils'; +import { defaultTransition, getTransition } from '../utils/transition'; import type { BarComponentProps } from './Bar'; @@ -18,7 +19,7 @@ export const DefaultBar = memo( y, width, height, - borderRadius, + borderRadius = 4, roundTop, roundBottom, d, @@ -27,60 +28,59 @@ export const DefaultBar = memo( stroke, strokeWidth, originY, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea } = 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 normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); - return ( - d || - getBarPath( - x, - y, - width, - height, - effectiveBorderRadius, - effectiveRoundTop, - effectiveRoundBottom, - ) - ); - }, [x, y, width, height, borderRadius, roundTop, roundBottom, d]); + const enterTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition(transitions?.enter, animate, defaultBarEnterTransition), + normalizedX, + ), + [transitions?.enter, animate, normalizedX], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedX, + ), + [transitions?.update, transition, animate, normalizedX], + ); const initialPath = useMemo(() => { - const effectiveBorderRadius = borderRadius ?? 0; - const effectiveRoundTop = roundTop ?? true; - const effectiveRoundBottom = roundBottom ?? true; const baselineY = originY ?? y + height; - - return getBarPath( - x, - baselineY, - width, - 1, - effectiveBorderRadius, - effectiveRoundTop, - effectiveRoundBottom, - ); + return getBarPath(x, baselineY, width, 1, borderRadius, !!roundTop, !!roundBottom); }, [x, originY, y, height, width, borderRadius, roundTop, roundBottom]); return ( ); }, diff --git a/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx b/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx index 18bf3064c5..783c8bd417 100644 --- a/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx +++ b/packages/mobile-visualization/src/chart/bar/DefaultBarStack.tsx @@ -3,7 +3,8 @@ import { Group } from '@shopify/react-native-skia'; import { useCartesianChartContext } from '../ChartProvider'; import { getBarPath } from '../utils'; -import { usePathTransition } from '../utils/transition'; +import { defaultBarEnterTransition, withStaggerDelayTransition } from '../utils/bar'; +import { defaultTransition, getTransition, usePathTransition } from '../utils/transition'; import type { BarStackComponentProps } from './BarStack'; @@ -23,9 +24,37 @@ export const DefaultBarStack = memo( roundTop = true, roundBottom = true, yOrigin, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea } = useCartesianChartContext(); + + // Compute normalized x position for stagger delay calculation + const normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); + + const enterTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition(transitions?.enter, animate, defaultBarEnterTransition), + normalizedX, + ), + [animate, transitions?.enter, normalizedX], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedX, + ), + [animate, transitions?.update, transition, normalizedX], + ); // Generate target clip path (full bar) const targetPath = useMemo(() => { @@ -41,7 +70,7 @@ export const DefaultBarStack = memo( const animatedClipPath = usePathTransition({ currentPath: targetPath, initialPath, - transition, + transitions: { enter: enterTransition, update: updateTransition }, }); const clipPath = animate ? animatedClipPath : targetPath; 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..7232e669b4 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,12 +1,20 @@ -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 { 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 { 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 { BarPlot } from '../BarPlot'; @@ -612,56 +620,442 @@ const BandGridPositionExample = ({ ); -const BarChartStories = () => { +// --- Composed Examples --- + +const candlestickStockData = btcCandles.slice(0, 90).reverse(); + +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, 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}} + /> + + ); + }, +); + +const Candlesticks = () => { + const infoTextId = useId(); + const [currentIndex, setCurrentIndex] = useState(); + + return ( + + + + + ); +}; + +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. + + ); }; -export default BarChartStories; +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: '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: , + }, + ], + [], + ); + + 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/line/DottedLine.tsx b/packages/mobile-visualization/src/chart/line/DottedLine.tsx index 3045e9472c..fe61472fff 100644 --- a/packages/mobile-visualization/src/chart/line/DottedLine.tsx +++ b/packages/mobile-visualization/src/chart/line/DottedLine.tsx @@ -37,6 +37,7 @@ export const DottedLine = memo( yAxisId, d, animate, + transitions, transition, ...props }) => { @@ -54,6 +55,7 @@ export const DottedLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} {...props} > diff --git a/packages/mobile-visualization/src/chart/line/Line.tsx b/packages/mobile-visualization/src/chart/line/Line.tsx index 547461ef77..9eb9aef30e 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'; @@ -110,16 +106,11 @@ 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 & { /** @@ -150,6 +141,7 @@ export const Line = memo( opacity = 1, points, connectNulls, + transitions, transition, gradient: gradientProp, ...props @@ -158,21 +150,6 @@ export const Line = memo( const { animate, getSeries, getSeriesData, getXScale, getYScale, getXAxis } = useCartesianChartContext(); - const isReady = !!getXScale(); - - // 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 && isReady) { - pointsOpacity.value = withDelay( - accessoryFadeTransitionDelay, - withTiming(1, { duration: accessoryFadeTransitionDuration }), - ); - } - }, [animate, isReady, pointsOpacity]); - const matchedSeries = useMemo(() => getSeries(seriesId), [getSeries, seriesId]); const gradient = useMemo( () => gradientProp ?? matchedSeries?.gradient, @@ -263,6 +240,7 @@ export const Line = memo( gradient={gradient} seriesId={seriesId} transition={transition} + transitions={transitions} type={areaType} /> )} @@ -273,11 +251,12 @@ export const Line = memo( stroke={stroke} strokeOpacity={strokeOpacity ?? opacity} transition={transition} + transitions={transitions} yAxisId={matchedSeries?.yAxisId} {...props} /> {points && ( - + {chartData.map((value: number | null, index: number) => { if (value === null) return; diff --git a/packages/mobile-visualization/src/chart/line/SolidLine.tsx b/packages/mobile-visualization/src/chart/line/SolidLine.tsx index ddd8938333..75ae5214f5 100644 --- a/packages/mobile-visualization/src/chart/line/SolidLine.tsx +++ b/packages/mobile-visualization/src/chart/line/SolidLine.tsx @@ -30,6 +30,7 @@ export const SolidLine = memo( yAxisId, d, animate, + transitions, transition, ...props }) => { @@ -47,6 +48,7 @@ export const SolidLine = memo( strokeOpacity={strokeOpacity} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} {...props} > {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 5e2a24bb30..3926086eb3 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, @@ -9,7 +9,6 @@ import { } from 'react-native-reanimated'; import type { ThemeVars } from '@coinbase/cds-common/core/theme'; 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'; @@ -32,8 +31,6 @@ import { Circle, FontWeight, Group, - Line as SkiaLine, - Rect, Skia, type SkTextStyle, TextAlign, @@ -41,7 +38,6 @@ import { import { Area, DottedArea, type DottedAreaProps } from '../../area'; import { DefaultAxisTickLabel, XAxis, YAxis } from '../../axis'; -import { type BarComponentProps, BarPlot } from '../../bar'; import { CartesianChart } from '../../CartesianChart'; import { useCartesianChartContext } from '../../ChartProvider'; import { PeriodSelector, PeriodSelectorActiveIndicator } from '../../PeriodSelector'; @@ -57,7 +53,6 @@ import { buildTransition, defaultTransition, getLineData, - getPointOnSerializableScale, projectPointWithSerializableScale, type Transition, unwrapAnimatedValue, @@ -68,7 +63,6 @@ import { type DottedLineProps, Line, LineChart, - type LineComponentProps, ReferenceLine, SolidLine, type SolidLineProps, @@ -1327,215 +1321,6 @@ function Performance() { ); } -const candlestickStockData = btcCandles.slice(0, 90).reverse(); - -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 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; @@ -2288,10 +2073,6 @@ function ExampleNavigator() { title: 'Performance', component: , }, - { - title: 'Candlesticks', - component: , - }, { title: 'Monotone Asset Price', component: , diff --git a/packages/mobile-visualization/src/chart/point/Point.tsx b/packages/mobile-visualization/src/chart/point/Point.tsx index 39a302cc63..0d10a96d76 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'; @@ -123,8 +129,24 @@ 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. */ transition?: Transition; }; @@ -144,7 +166,8 @@ export const Point = memo( labelPosition = 'center', labelOffset, labelFont, - transition = defaultTransition, + transitions, + transition, animate: animateProp, }) => { const theme = useTheme(); @@ -164,6 +187,21 @@ export const Point = memo( 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 +223,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 +241,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 +282,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 +303,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 +347,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 d4fa5bd422..96cb70314a 100644 --- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx +++ b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx @@ -16,7 +16,13 @@ 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, + instantTransition, + type Transition, +} from '../utils/transition'; import type { ScrubberBeaconProps, ScrubberBeaconRef } from './Scrubber'; @@ -85,8 +91,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, diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx index c256f0460f..d4c377671c 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'; @@ -25,14 +23,15 @@ import { } from '../line'; 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. */ @@ -89,18 +88,36 @@ export type ScrubberBeaconProps = { * @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; /** @@ -110,16 +127,6 @@ export type ScrubberBeaconProps = { */ pulseRepeatDelay?: number; }; - /** - * Opacity of the beacon. - * @default 1 - */ - opacity?: AnimatedProp; - /** - * Stroke color of the beacon circle. - * @default theme.color.bg - */ - stroke?: string; }; export type ScrubberBeaconComponent = React.FC< @@ -211,10 +218,6 @@ export type ScrubberBaseProps = Pick * Stroke color for the scrubber line. */ lineStroke?: ReferenceLineBaseProps['stroke']; - /** - * Transition configuration for the scrubber beacon. - */ - beaconTransitions?: ScrubberBeaconProps['transitions']; /** * Stroke color of the scrubber beacon circle. * @default theme.color.bg @@ -222,7 +225,18 @@ export type ScrubberBaseProps = Pick 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. + */ + beaconTransitions?: ScrubberBeaconProps['transitions']; +}; export type ScrubberRef = ScrubberBeaconGroupRef; @@ -253,6 +267,7 @@ export const Scrubber = memo( beaconLabelFont, idlePulse, beaconTransitions, + transitions = beaconTransitions, beaconStroke, }, ref, @@ -364,14 +379,16 @@ export const Scrubber = memo( const isReady = !!xScale; + const groupEnterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [transitions?.enter, animate], + ); + useEffect(() => { if (animate && isReady) { - scrubberOpacity.value = withDelay( - accessoryFadeTransitionDelay, - withTiming(1, { duration: accessoryFadeTransitionDuration }), - ); + scrubberOpacity.value = buildTransition(1, groupEnterTransition); } - }, [animate, isReady, scrubberOpacity]); + }, [animate, isReady, scrubberOpacity, groupEnterTransition]); if (!isReady) return; @@ -406,7 +423,7 @@ export const Scrubber = memo( idlePulse={idlePulse} seriesIds={filteredSeriesIds} stroke={beaconStroke} - transitions={beaconTransitions} + transitions={transitions} /> {!hideBeaconLabels && beaconLabels.length > 0 && ( )} diff --git a/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/mobile-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx index 449c409891..31161a4ed9 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 { ChartTextChildren, ChartTextProps } from '../text'; -import { applySerializableScale, useScrubberContext } from '../utils'; +import { applySerializableScale, unwrapAnimatedValue, useScrubberContext } from '../utils'; import { calculateLabelYPositions, getLabelPosition, @@ -13,14 +13,26 @@ 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; + isIdle: AnimatedProp; + updateTransition: Transition; label: ChartTextChildren; color?: string; seriesId: string; @@ -33,6 +45,8 @@ const PositionedLabel = memo<{ index, positions, position, + isIdle, + updateTransition, label, color, seriesId, @@ -46,7 +60,20 @@ 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 animatedY = useSharedValue(0); + useAnimatedReaction( + () => ({ y: targetY.value, idle: unwrapAnimatedValue(isIdle) }), + (current, previous) => { + if (previous === null || !previous.idle || !current.idle) { + animatedY.value = current.y; + } else { + animatedY.value = buildTransition(current.y, updateTransition); + } + }, + [updateTransition], + ); const dx = useDerivedValue(() => { return position.value === 'right' ? labelHorizontalOffset : -labelHorizontalOffset; @@ -68,7 +95,7 @@ const PositionedLabel = memo<{ opacity={opacity} seriesId={seriesId} x={x} - y={y} + y={animatedY} /> ); }, @@ -107,6 +134,10 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & * @default DefaultScrubberBeaconLabel */ BeaconLabelComponent?: ScrubberBeaconLabelComponent; + /** + * Transition configuration for beacon label animations. + */ + transitions?: ScrubberBeaconProps['transitions']; }; export const ScrubberBeaconLabelGroup = memo( @@ -117,6 +148,7 @@ export const ScrubberBeaconLabelGroup = memo( labelFont, labelPreferredSide = 'right', BeaconLabelComponent = DefaultScrubberBeaconLabel, + transitions, }) => { const { getSeries, @@ -126,9 +158,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) => { @@ -281,6 +323,7 @@ export const ScrubberBeaconLabelGroup = memo( BeaconLabelComponent={BeaconLabelComponent} color={labelInfo.color} index={index} + isIdle={isIdle} label={labelInfo.label} labelFont={labelFont} labelHorizontalOffset={labelHorizontalOffset} @@ -288,6 +331,7 @@ export const ScrubberBeaconLabelGroup = memo( position={currentPosition} positions={allLabelPositions} seriesId={info.seriesId} + updateTransition={updateTransition} /> ); }); 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..f6b5730db7 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/bar.test.ts @@ -1,5 +1,10 @@ import { getBarSizeAdjustment } from '../bar'; +jest.mock('@shopify/react-native-skia', () => ({ + Skia: { Path: { Make: jest.fn(), MakeFromSVGString: jest.fn() } }, + notifyChange: jest.fn(), +})); + describe('getBarSizeAdjustment', () => { it('should return 0 when barCount is 0', () => { const result = getBarSizeAdjustment(0, 10); 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..7973e4830c 100644 --- a/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/mobile-visualization/src/chart/utils/__tests__/transition.test.ts @@ -5,7 +5,6 @@ import { buildTransition, defaultTransition, type Transition, - useD3PathInterpolation, usePathTransition, } from '../transition'; @@ -165,68 +164,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,7 +299,7 @@ describe('usePathTransition', () => { const { result } = renderHook(() => usePathTransition({ currentPath, - transition, + transitions: { update: transition }, }), ); diff --git a/packages/mobile-visualization/src/chart/utils/bar.ts b/packages/mobile-visualization/src/chart/utils/bar.ts index c30de9154e..e69c1224b4 100644 --- a/packages/mobile-visualization/src/chart/utils/bar.ts +++ b/packages/mobile-visualization/src/chart/utils/bar.ts @@ -1,3 +1,51 @@ +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 horizontal position (leftmost starts first, rightmost last). + * + * @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; +}; + +/** + * Strips `staggerDelay` from a transition and computes a positional delay. + * + * @param transition - The transition config (may include staggerDelay) + * @param normalizedX - The bar's normalized x position (0 = left edge, 1 = right edge) + * @returns A standard Transition with computed delay + */ +export const withStaggerDelayTransition = ( + transition: BarTransition, + normalizedX: number, +): Transition => { + const { staggerDelay, ...baseTransition } = transition; + if (!staggerDelay) return transition; + return { + ...baseTransition, + delay: (baseTransition?.delay ?? 0) + normalizedX * 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, +}; + /** * 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 diff --git a/packages/mobile-visualization/src/chart/utils/path.ts b/packages/mobile-visualization/src/chart/utils/path.ts index dd3330e925..089976dbe0 100644 --- a/packages/mobile-visualization/src/chart/utils/path.ts +++ b/packages/mobile-visualization/src/chart/utils/path.ts @@ -14,6 +14,16 @@ import { import { projectPoint, 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' diff --git a/packages/mobile-visualization/src/chart/utils/transition.ts b/packages/mobile-visualization/src/chart/utils/transition.ts index 5824668c10..2337b672b7 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,27 @@ 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); - - useAnimatedReaction( - () => progress.value, - (t) => { - 'worklet'; - result.value = i1.interpolate(i0, t) ?? toSkiaPath; - notifyChange(result); - }, - [fromSkiaPath, i0, i1, toSkiaPath], - ); +export const defaultAccessoryEnterTransition: Transition = { + type: 'timing', + duration: accessoryFadeTransitionDuration, + delay: accessoryFadeTransitionDelay, +}; - 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 => { + if (!animate || value === null) return instantTransition; + return value ?? defaultValue; }; // Interpolator and useInterpolator are brought over from non exported code in @shopify/react-native-skia @@ -147,18 +150,29 @@ export const useInterpolator = ( */ export const buildTransition = (targetValue: number, transition: Transition): number => { 'worklet'; - switch (transition.type) { + const { delay: delayMs, ...config } = transition; + + let animation: number; + switch (config.type) { case 'timing': { - return withTiming(targetValue, transition); + animation = withTiming(targetValue, config); + break; } case 'spring': { - return withSpring(targetValue, transition); + animation = withSpring(targetValue, config); + 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 +180,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 +197,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 +220,92 @@ 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; + /** + * Transition for subsequent data update animations. + * @default defaultTransition + */ + update?: Transition; + }; + /** + * 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 updateTransition = transitions?.update ?? transition; + const enterTransition = transitions?.enter; + + 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 pathInterpolator = interpolatePath(fromPath, currentPath); + interpolatorRef.current = pathInterpolator; - if (shouldAnimate) { - // Update ref for next path change (happens after this render) - previousPathRef.current = currentPath; + normalizedStartShared.value = + Skia.Path.MakeFromSVGString(pathInterpolator(0)) ?? Skia.Path.Make(); + normalizedEndShared.value = + Skia.Path.MakeFromSVGString(pathInterpolator(1)) ?? Skia.Path.Make(); + fallbackPathShared.value = Skia.Path.MakeFromSVGString(currentPath) ?? Skia.Path.Make(); + + const activeTransition = + isFirstAnimation.current && enterTransition !== undefined + ? enterTransition + : updateTransition; + + isFirstAnimation.current = false; - // Animate from old path to new path progress.value = 0; - progress.value = buildTransition(1, transition); + progress.value = buildTransition(1, activeTransition); } - }, [currentPath, transition, progress]); + }, [ + currentPath, + updateTransition, + enterTransition, + progress, + normalizedStartShared, + normalizedEndShared, + fallbackPathShared, + ]); - 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/ui-mobile-playground/CHANGELOG.md b/packages/ui-mobile-playground/CHANGELOG.md index 76e93afb4c..826c941c7c 100644 --- a/packages/ui-mobile-playground/CHANGELOG.md +++ b/packages/ui-mobile-playground/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/ui-mobile-playground/package.json b/packages/ui-mobile-playground/package.json index e0846121aa..fbf4104fe3 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.8.0", + "version": "4.9.0", "description": "Mobile UI Components in a Playground", "repository": { "type": "git", diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts index 20e857e39e..0d2ce796d5 100644 --- a/packages/ui-mobile-playground/src/routes.ts +++ b/packages/ui-mobile-playground/src/routes.ts @@ -142,9 +142,10 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -246,6 +247,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: () => @@ -840,6 +846,11 @@ export const routes = [ 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/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts index 20e857e39e..0d2ce796d5 100644 --- a/packages/ui-mobile-visreg/src/routes.ts +++ b/packages/ui-mobile-visreg/src/routes.ts @@ -142,9 +142,10 @@ export const routes = [ .default, }, { - key: 'Chart', + key: 'ChartTransitions', getComponent: () => - require('@coinbase/cds-mobile-visualization/chart/__stories__/Chart.stories').default, + require('@coinbase/cds-mobile-visualization/chart/__stories__/ChartTransitions.stories') + .default, }, { key: 'Checkbox', @@ -246,6 +247,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: () => @@ -840,6 +846,11 @@ export const routes = [ 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/web-visualization/CHANGELOG.md b/packages/web-visualization/CHANGELOG.md index 3c7023d45b..56b3563cb8 100644 --- a/packages/web-visualization/CHANGELOG.md +++ b/packages/web-visualization/CHANGELOG.md @@ -8,7 +8,11 @@ All notable changes to this project will be documented in this file. -## Unreleased +## 3.4.0-beta.19 (2/20/2026 PST) + +#### 🚀 Updates + +- Support custom enter transitions [[#400](https://github.com/coinbase/cds/pull/400/)] #### 📘 Misc diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json index 8c31aa5ed8..1b0d1afffe 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.18", + "version": "3.4.0-beta.19", "description": "Coinbase Design System - Web Sparkline", "repository": { "type": "git", diff --git a/packages/web-visualization/src/chart/Path.tsx b/packages/web-visualization/src/chart/Path.tsx index cfc8892f5b..a9881a2c81 100644 --- a/packages/web-visualization/src/chart/Path.tsx +++ b/packages/web-visualization/src/chart/Path.tsx @@ -3,11 +3,13 @@ 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. */ export const pathEnterTransitionDuration = 0.5; @@ -16,6 +18,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 +50,40 @@ 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 }, + * 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 subsequent data update animations. + * Set to `null` to disable. + */ + update?: Transition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. + */ + transition?: Transition; /** * Offset added to the clip rect boundaries. */ @@ -44,36 +94,52 @@ 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 AnimatedPath = memo< + Omit & { + transitions?: { enter?: Transition; update?: Transition }; + } +>(({ d = '', initialPath, transitions, ...pathProps }) => { const interpolatedPath = usePathTransition({ currentPath: d, - transition, + initialPath, + transitions, }); 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 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], + ); + // 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 @@ -84,13 +150,10 @@ export const Path = memo( hidden: { width: 0 }, visible: { width: rect.width + totalOffset, - transition: { - type: 'timing', - duration: pathEnterTransitionDuration, - }, + transition: enterTransition, }, }; - }, [rect, totalOffset]); + }, [rect, totalOffset, enterTransition]); const clipPath = useMemo( () => (rect !== null ? `url(#${clipPathId})` : undefined), @@ -102,31 +165,23 @@ export const Path = memo( {rect !== null && ( - {!animate ? ( - - ) : ( - - )} + )} - {!animate ? ( - - ) : ( - - )} + ); }, 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..f2d786742c --- /dev/null +++ b/packages/web-visualization/src/chart/__stories__/ChartTransitions.stories.tsx @@ -0,0 +1,410 @@ +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; + +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 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 && ( + + + + )} + + ); +} + +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 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/area/Area.tsx b/packages/web-visualization/src/chart/area/Area.tsx index 74e9c52221..ad1320a616 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,13 +37,13 @@ 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. @@ -58,19 +58,14 @@ 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 @@ -96,6 +91,7 @@ export const Area = memo( baseline, connectNulls, gradient: gradientProp, + transitions, transition, animate, }) => { @@ -160,6 +156,7 @@ export const Area = memo( fillOpacity={fillOpacity} gradient={gradient} transition={transition} + transitions={transitions} yAxisId={matchedSeries?.yAxisId} /> ); diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx index 7d3f9c3337..c99f374e49 100644 --- a/packages/web-visualization/src/chart/area/AreaChart.tsx +++ b/packages/web-visualization/src/chart/area/AreaChart.tsx @@ -21,7 +21,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 +43,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 & { /** @@ -99,6 +112,7 @@ export const AreaChart = memo( fillOpacity, type, connectNulls, + transitions, transition, LineComponent, strokeWidth, @@ -222,6 +236,7 @@ export const AreaChart = memo( fillOpacity={fillOpacity} seriesId={id} transition={seriesTransition ?? transition} + transitions={transitions} type={type} {...areaPropsFromSeries} /> @@ -253,6 +268,7 @@ export const AreaChart = memo( seriesId={id} strokeWidth={strokeWidth} transition={seriesTransition ?? transition} + transitions={transitions} type={seriesLineType ?? lineType} {...otherPropsFromSeries} /> diff --git a/packages/web-visualization/src/chart/area/DottedArea.tsx b/packages/web-visualization/src/chart/area/DottedArea.tsx index df83fa5775..95a64c116d 100644 --- a/packages/web-visualization/src/chart/area/DottedArea.tsx +++ b/packages/web-visualization/src/chart/area/DottedArea.tsx @@ -61,6 +61,7 @@ export const DottedArea = memo( yAxisId, gradient: gradientProp, animate, + transitions, transition, ...pathProps }) => { @@ -94,14 +95,20 @@ export const DottedArea = memo( - + {gradient && ( )} @@ -112,6 +119,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..a5afa3aacc 100644 --- a/packages/web-visualization/src/chart/area/GradientArea.tsx +++ b/packages/web-visualization/src/chart/area/GradientArea.tsx @@ -54,6 +54,7 @@ export const GradientArea = memo( yAxisId, gradient: gradientProp, animate, + transitions, transition, ...pathProps }) => { @@ -78,7 +79,7 @@ export const GradientArea = memo( animate={animate} gradient={gradient} id={patternId} - transition={transition} + transition={transitions?.update ?? transition} yAxisId={yAxisId} /> @@ -89,6 +90,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..3487bb9773 100644 --- a/packages/web-visualization/src/chart/area/SolidArea.tsx +++ b/packages/web-visualization/src/chart/area/SolidArea.tsx @@ -32,6 +32,7 @@ export const SolidArea = memo( fillOpacity = 1, yAxisId, animate, + transitions, transition, gradient, ...pathProps @@ -46,7 +47,7 @@ export const SolidArea = memo( animate={animate} gradient={gradient} id={patternId} - transition={transition} + transition={transitions?.update ?? transition} yAxisId={yAxisId} /> @@ -57,6 +58,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..8835eab92e 100644 --- a/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx +++ b/packages/web-visualization/src/chart/area/__stories__/AreaChart.stories.tsx @@ -1,9 +1,10 @@ import { VStack } from '@coinbase/cds-web/layout'; import { Text } from '@coinbase/cds-web/typography'; -import { DottedLine } from '../../line'; +import { CartesianChart } from '../../CartesianChart'; +import { DottedLine, Line } from '../../line'; import { Scrubber } from '../../scrubber/Scrubber'; -import { AreaChart } from '..'; +import { Area, AreaChart } from '..'; export default { title: 'Components/Chart/AreaChart', diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx index dc6e5d8738..953a78e196 100644 --- a/packages/web-visualization/src/chart/bar/Bar.tsx +++ b/packages/web-visualization/src/chart/bar/Bar.tsx @@ -2,7 +2,7 @@ import React, { memo, useMemo } from 'react'; import type { SVGProps } from 'react'; import type { Transition } from 'framer-motion'; -import { getBarPath } from '../utils'; +import { type BarTransition, getBarPath } from '../utils'; import { DefaultBar } from './'; @@ -77,7 +77,37 @@ export type BarBaseProps = { 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 }, + * 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 subsequent data update animations. + * Set to `null` to disable. + */ + update?: BarTransition | null; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. */ transition?: Transition; }; @@ -121,6 +151,7 @@ export const Bar = memo( borderRadius = 4, roundTop = true, roundBottom = true, + transitions, transition, }) => { const barPath = useMemo(() => { @@ -149,6 +180,7 @@ export const Bar = memo( 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 6aa15d8561..62cb31f05b 100644 --- a/packages/web-visualization/src/chart/bar/BarChart.tsx +++ b/packages/web-visualization/src/chart/bar/BarChart.tsx @@ -25,6 +25,7 @@ export type BarChartBaseProps = Omit & { /** @@ -89,6 +90,7 @@ export const BarChart = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, ...chartProps }, @@ -182,6 +184,7 @@ export const BarChart = memo( stroke={stroke} strokeWidth={strokeWidth} transition={transition} + transitions={transitions} /> {children} diff --git a/packages/web-visualization/src/chart/bar/BarPlot.tsx b/packages/web-visualization/src/chart/bar/BarPlot.tsx index 08e1c21a6f..f8152c560f 100644 --- a/packages/web-visualization/src/chart/bar/BarPlot.tsx +++ b/packages/web-visualization/src/chart/bar/BarPlot.tsx @@ -28,7 +28,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,6 +51,7 @@ export const BarPlot = memo( stackGap, barMinSize, stackMinSize, + transitions, transition, }) => { const { series: allSeries, drawingArea } = useCartesianChartContext(); @@ -130,6 +132,7 @@ export const BarPlot = memo( strokeWidth={defaultStrokeWidth} totalStacks={stackGroups.length} transition={transition} + transitions={transitions} yAxisId={group.yAxisId} /> ))} diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx index 64453e9803..128219b6ff 100644 --- a/packages/web-visualization/src/chart/bar/BarStack.tsx +++ b/packages/web-visualization/src/chart/bar/BarStack.tsx @@ -1,12 +1,11 @@ import React, { memo, useMemo } from 'react'; import type { Rect } from '@coinbase/cds-common'; -import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartScaleFunction, Series } from '../utils'; import { evaluateGradientAtValue, 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; @@ -22,7 +21,7 @@ export type BarSeries = Series & { }; export type BarStackBaseProps = Pick< - BarProps, + BarBaseProps, 'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius' > & { /** @@ -78,16 +77,11 @@ export type BarStackBaseProps = Pick< stackMinSize?: number; }; -export type BarStackProps = BarStackBaseProps & { - /** - * Transition configuration for animation. - */ - transition?: Transition; -}; +export type BarStackProps = BarStackBaseProps & Pick; export type BarStackComponentProps = Pick< BarStackProps, - 'x' | 'width' | 'categoryIndex' | 'borderRadius' | 'transition' + 'x' | 'width' | 'categoryIndex' | 'borderRadius' | 'transitions' | 'transition' > & { /** * The y position of the stack. @@ -139,6 +133,7 @@ export const BarStack = memo( barMinSize, stackMinSize, roundBaseline, + transitions, transition, }) => { const { getSeriesData, getXAxis, getXScale, getSeries } = useCartesianChartContext(); @@ -701,6 +696,7 @@ export const BarStack = memo( stroke={bar.stroke ?? defaultStroke} strokeWidth={bar.strokeWidth ?? defaultStrokeWidth} transition={transition} + transitions={transitions} width={bar.width} x={bar.x} y={bar.y} @@ -720,6 +716,7 @@ export const BarStack = memo( roundBottom={stackRoundBottom} roundTop={stackRoundTop} transition={transition} + transitions={transitions} width={stackRect.width} x={stackRect.x} y={stackRect.y} diff --git a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 31ea2e64ab..06ccc18482 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -19,6 +19,7 @@ export type BarStackGroupProps = Pick< | 'barMinSize' | 'stackMinSize' | 'BarStackComponent' + | 'transitions' | 'transition' > & Pick & { diff --git a/packages/web-visualization/src/chart/bar/DefaultBar.tsx b/packages/web-visualization/src/chart/bar/DefaultBar.tsx index 4382b81b6b..d72d3156ce 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBar.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBar.tsx @@ -1,8 +1,14 @@ 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 { + defaultBarEnterTransition, + defaultTransition, + getBarPath, + getTransition, + withStaggerDelayTransition, +} from '../utils'; import type { BarComponentProps } from './Bar'; @@ -34,32 +40,66 @@ export const DefaultBar = memo( dataX, dataY, seriesId, + transitions, transition, ...props }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea } = useCartesianChartContext(); + + const normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); + + const enterTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition(transitions?.enter, animate, defaultBarEnterTransition), + normalizedX, + ), + [transitions?.enter, animate, normalizedX], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedX, + ), + [transitions?.update, transition, animate, normalizedX], + ); 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 && initialPath) { - return ( - + return getBarPath( + x, + initialY, + width, + minHeight, + borderRadius ?? 0, + !!roundTop, + !!roundBottom, ); - } + }, [x, originY, width, borderRadius, roundTop, roundBottom]); - return ; + return ( + + ); }, ); diff --git a/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx b/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx index 932a3c5b13..b5547d74ca 100644 --- a/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx +++ b/packages/web-visualization/src/chart/bar/DefaultBarStack.tsx @@ -2,7 +2,14 @@ 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, + getTransition, + withStaggerDelayTransition, +} from '../utils'; +import { usePathTransition } from '../utils/transition'; import type { BarStackComponentProps } from './BarStack'; @@ -33,33 +40,58 @@ export const DefaultBarStack = memo( roundTop = true, roundBottom = true, yOrigin, + transitions, transition, }) => { - const { animate } = useCartesianChartContext(); + const { animate, drawingArea } = useCartesianChartContext(); const clipPathId = useId(); - const clipPathData = useMemo(() => { + const normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); + + const enterTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition(transitions?.enter, animate, defaultBarEnterTransition), + normalizedX, + ), + [animate, transitions?.enter, normalizedX], + ); + const updateTransition = useMemo( + () => + withStaggerDelayTransition( + getTransition( + transitions?.update !== undefined ? transitions.update : transition, + animate, + defaultTransition, + ), + normalizedX, + ), + [animate, transitions?.update, transition, normalizedX], + ); + + const targetPath = useMemo(() => { return getBarPath(x, y, width, height, borderRadius, roundTop, roundBottom); }, [x, y, width, height, borderRadius, roundTop, roundBottom]); - 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]); + 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]); + + const animatedClipPath = usePathTransition({ + currentPath: targetPath, + initialPath, + transitions: { enter: enterTransition, update: updateTransition }, + }); return ( <> - {animate ? ( - - ) : ( - - )} + 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..6858fc3883 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,8 @@ -import React, { memo, useId } from 'react'; +import React, { memo, useEffect, useId, useMemo, useState } from 'react'; 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'; @@ -223,6 +224,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]); @@ -264,9 +267,23 @@ const Candlesticks = () => { const CandlestickBarComponent = memo( ({ x, y, width, height, originY, dataX, ...props }) => { - const { getYScale } = useCartesianChartContext(); + const { getYScale, drawingArea } = useCartesianChartContext(); const yScale = getYScale(); + const normalizedX = useMemo( + () => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0), + [x, drawingArea.x, drawingArea.width], + ); + + const transition: Transition = useMemo( + () => ({ + type: 'tween', + duration: 0.325, + delay: normalizedX * staggerDelay, + }), + [normalizedX], + ); + const wickX = x + width / 2; const timePeriodValue = stockData[dataX as number]; @@ -283,10 +300,14 @@ const Candlesticks = () => { const bodyY = openY < closeY ? openY : closeY; return ( - + - + ); }, ); @@ -361,7 +382,6 @@ const Candlesticks = () => { showYAxis BarComponent={CandlestickBarComponent} BarStackComponent={({ children, ...props }) => {children}} - animate={false} aria-labelledby={infoTextId} borderRadius={0} height={400} @@ -404,6 +424,72 @@ const Candlesticks = () => { ); }; +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', + }, + ]} + > + + + + + + ); +}; + export const All = () => { return ( @@ -777,6 +863,9 @@ export const All = () => { + + + ); }; diff --git a/packages/web-visualization/src/chart/line/DottedLine.tsx b/packages/web-visualization/src/chart/line/DottedLine.tsx index 404720344c..93c1a8bfba 100644 --- a/packages/web-visualization/src/chart/line/DottedLine.tsx +++ b/packages/web-visualization/src/chart/line/DottedLine.tsx @@ -40,6 +40,7 @@ export const DottedLine = memo( gradient, yAxisId, animate, + transitions, transition, d, ...props @@ -54,7 +55,7 @@ export const DottedLine = memo( animate={animate} gradient={gradient} id={gradientId} - transition={transition} + transition={transitions?.update ?? transition} yAxisId={yAxisId} /> @@ -71,6 +72,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..ebd43e1ae0 100644 --- a/packages/web-visualization/src/chart/line/Line.tsx +++ b/packages/web-visualization/src/chart/line/Line.tsx @@ -1,15 +1,12 @@ 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, getGradientConfig, @@ -109,25 +106,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 +130,7 @@ export type LineComponentProps = Pick< | 'strokeWidth' | 'gradient' | 'animate' + | 'transitions' | 'transition' | 'style' | 'className' @@ -170,6 +165,7 @@ export const Line = memo( opacity = 1, points, connectNulls, + transitions, transition, gradient: gradientProp, ...props @@ -264,6 +260,7 @@ export const Line = memo( gradient={gradient} seriesId={seriesId} transition={transition} + transitions={transitions} type={areaType} /> )} @@ -273,26 +270,12 @@ export const Line = memo( stroke={stroke} strokeOpacity={strokeOpacity ?? opacity} transition={transition} + transitions={transitions} yAxisId={matchedSeries?.yAxisId} {...props} /> {points && ( - + {chartData.map((value: number | null, index: number) => { if (value === null) return; @@ -333,6 +316,7 @@ export const Line = memo( key={`${seriesId}-${index}`} onClick={onPointClick} transition={transition} + transitions={transitions} {...defaults} /> ); @@ -350,12 +334,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 c14c98df45..162e00e003 100644 --- a/packages/web-visualization/src/chart/line/LineChart.tsx +++ b/packages/web-visualization/src/chart/line/LineChart.tsx @@ -28,6 +28,7 @@ export type LineSeries = Series & | 'opacity' | 'points' | 'connectNulls' + | 'transitions' | 'transition' | 'onPointClick' > @@ -46,6 +47,7 @@ export type LineChartBaseProps = Omit diff --git a/packages/web-visualization/src/chart/line/SolidLine.tsx b/packages/web-visualization/src/chart/line/SolidLine.tsx index 743312f12b..01d5b1108d 100644 --- a/packages/web-visualization/src/chart/line/SolidLine.tsx +++ b/packages/web-visualization/src/chart/line/SolidLine.tsx @@ -37,6 +37,7 @@ export const SolidLine = memo( gradient, yAxisId, animate, + transitions, transition, d, ...props @@ -51,7 +52,7 @@ export const SolidLine = memo( animate={animate} gradient={gradient} id={gradientId} - transition={transition} + transition={transitions?.update ?? transition} yAxisId={yAxisId} /> @@ -67,6 +68,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 62dbd66905..d5a648b89e 100644 --- a/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx +++ b/packages/web-visualization/src/chart/line/__stories__/LineChart.stories.tsx @@ -22,7 +22,6 @@ import { Text } from '@coinbase/cds-web/typography'; import { m } from 'framer-motion'; import { - type AxisBounds, DefaultScrubberBeacon, defaultTransition, PeriodSelector, @@ -1751,139 +1750,6 @@ export const All = () => { ); }; -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 ( - - - - - - ); - } - - return ; -}; function DataCardWithLineChart() { const exampleThumbnail = ( ( labelFont, testID, animate: animateProp, + transitions, transition, ...svgProps }) => { @@ -255,6 +278,21 @@ export const Point = memo( } = useCartesianChartContext(); const animate = animateProp ?? animationEnabled; + 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(); const yScale = getYScale(yAxisId); @@ -347,7 +385,6 @@ export const Point = memo( cx={pixelCoordinate.x} cy={pixelCoordinate.y} fill={fill} - initial={false} onClick={ onClick ? (event: any) => @@ -361,7 +398,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 +425,7 @@ export const Point = memo( pixelCoordinate.x, pixelCoordinate.y, accessibilityLabel, - transition, + updateTransition, ]); if (!xScale || !yScale) { @@ -394,28 +434,34 @@ export const Point = memo( return ( - - {innerPoint} - - {label && ( - - {label} - - )} + {innerPoint} + + {label && ( + + {label} + + )} + ); }, diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeacon.tsx index aeeb9f89e4..649c3b5a74 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,7 +8,7 @@ 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'; @@ -81,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], @@ -169,8 +174,17 @@ export const DefaultScrubberBeacon = memo( /> ); - const beaconCircle = - isIdle && animate ? ( + return ( + + {isIdle && ( + + {pulseCircle} + + )} - ) : ( - - ); - - return ( - - {isIdle && - (animate ? ( - - {pulseCircle} - - ) : ( - - {pulseCircle} - - ))} - {beaconCircle} ); }, 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/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx index c25607a9df..d9a366979d 100644 --- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx +++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx @@ -10,11 +10,11 @@ import { } from '../line'; 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. */ @@ -74,27 +74,6 @@ export type ScrubberBeaconProps = SharedProps & { * @default to ChartContext's animate value */ animate?: boolean; - /** - * Transition configuration for beacon animations. - */ - 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; - }; /** * Opacity of the beacon. * @default 1 @@ -105,16 +84,46 @@ export type ScrubberBeaconProps = SharedProps & { * @default 'var(--color-bg)' */ stroke?: string; - /** - * Custom className for styling. - */ - className?: string; - /** - * Custom inline styles. - */ - style?: React.CSSProperties; }; +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 } >; @@ -132,6 +141,11 @@ export type ScrubberBeaconLabelProps = Pick & * 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; @@ -196,7 +210,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; /** @@ -207,10 +221,6 @@ export type ScrubberBaseProps = SharedProps & * Stroke color for the scrubber line. */ lineStroke?: ReferenceLineBaseProps['stroke']; - /** - * Transition configuration for the scrubber beacon. - */ - beaconTransitions?: ScrubberBeaconProps['transitions']; /** * Stroke color of the scrubber beacon circle. * @default 'var(--color-bg)' @@ -219,6 +229,16 @@ export type ScrubberBaseProps = SharedProps & }; 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. + */ + 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. @@ -279,6 +299,7 @@ export const Scrubber = memo( testID, idlePulse, beaconTransitions, + transitions = beaconTransitions, beaconStroke, styles, classNames, @@ -353,6 +374,11 @@ export const Scrubber = memo( [series, filteredSeriesIds], ); + const groupEnterTransition = useMemo( + () => getTransition(transitions?.enter, animate, defaultAccessoryEnterTransition), + [transitions?.enter, animate], + ); + // Check if we have at least the default X scale const defaultXScale = getXScale(); if (!defaultXScale) return null; @@ -372,12 +398,8 @@ export const Scrubber = memo( ? { animate: { opacity: 1, - transition: { - duration: accessoryFadeTransitionDuration, - delay: accessoryFadeTransitionDelay, - }, + transition: groupEnterTransition, }, - exit: { opacity: 0, transition: { duration: accessoryFadeTransitionDuration } }, initial: { opacity: 0 }, } : {})} @@ -417,7 +439,7 @@ export const Scrubber = memo( stroke={beaconStroke} style={styles?.beacon} testID={testID} - transitions={beaconTransitions} + transitions={transitions} /> {!hideBeaconLabels && beaconLabels.length > 0 && ( )} diff --git a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx index b751f17ccc..94561ef38d 100644 --- a/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx +++ b/packages/web-visualization/src/chart/scrubber/ScrubberBeaconLabelGroup.tsx @@ -1,9 +1,17 @@ import { memo, useCallback, useMemo, useState } from 'react'; +import { usePreviousValue } from '@coinbase/cds-common/hooks/usePreviousValue'; import type { SharedProps } from '@coinbase/cds-common/types'; +import type { Transition } from 'framer-motion'; import { useCartesianChartContext } from '../ChartProvider'; import type { ChartTextChildren, ChartTextProps } from '../text'; -import { getPointOnScale, useScrubberContext } from '../utils'; +import { + defaultTransition, + getPointOnScale, + getTransition, + instantTransition, + useScrubberContext, +} from '../utils'; import { calculateLabelYPositions, getLabelPosition, @@ -13,7 +21,11 @@ import { } from '../utils/scrubber'; import { DefaultScrubberBeaconLabel } from './DefaultScrubberBeaconLabel'; -import type { ScrubberBeaconLabelComponent, ScrubberBeaconLabelProps } from './Scrubber'; +import type { + ScrubberBeaconLabelComponent, + ScrubberBeaconLabelProps, + ScrubberBeaconProps, +} from './Scrubber'; const PositionedLabel = memo<{ index: number; @@ -26,6 +38,7 @@ const PositionedLabel = memo<{ BeaconLabelComponent: ScrubberBeaconLabelComponent; labelHorizontalOffset: number; labelFont?: ChartTextProps['font']; + updateTransition: Transition; }>( ({ index, @@ -38,6 +51,7 @@ const PositionedLabel = memo<{ BeaconLabelComponent, labelHorizontalOffset, labelFont, + updateTransition, }) => { const pos = positions[index]; @@ -60,6 +74,7 @@ const PositionedLabel = memo<{ label={label} onDimensionsChange={(d) => onDimensionsChange(seriesId, d)} seriesId={seriesId} + transition={updateTransition} x={x} y={y} /> @@ -100,6 +115,10 @@ export type ScrubberBeaconLabelGroupProps = ScrubberBeaconLabelGroupBaseProps & * @default DefaultScrubberBeaconLabel */ BeaconLabelComponent?: ScrubberBeaconLabelComponent; + /** + * Transition configuration for beacon label animations. + */ + transitions?: ScrubberBeaconProps['transitions']; }; export const ScrubberBeaconLabelGroup = memo( @@ -110,11 +129,31 @@ export const ScrubberBeaconLabelGroup = memo( labelFont, labelPreferredSide = 'right', BeaconLabelComponent = DefaultScrubberBeaconLabel, + transitions, }) => { - 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) => { @@ -275,6 +314,7 @@ export const ScrubberBeaconLabelGroup = memo( position={currentPosition} positions={allLabelPositions} seriesId={info.seriesId} + updateTransition={updateTransition} /> ); }); 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..d0c4890988 100644 --- a/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts +++ b/packages/web-visualization/src/chart/utils/__tests__/transition.test.ts @@ -25,16 +25,11 @@ 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; - }), - 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(); } @@ -167,7 +162,7 @@ describe('usePathTransition', () => { const { result } = renderHook(() => usePathTransition({ currentPath, - transition, + transitions: { update: transition }, }), ); @@ -178,7 +173,7 @@ describe('usePathTransition', () => { ({ path }) => usePathTransition({ currentPath: path, - transition, + transitions: { update: transition }, }), { initialProps: { path: currentPath }, @@ -236,12 +231,12 @@ describe('usePathTransition', () => { expect(interpolatePath).toHaveBeenCalled(); }); - it('should cancel ongoing animation when path changes', () => { + 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 +252,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', () => { @@ -293,10 +288,10 @@ describe('usePathTransition', () => { 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,10 +307,10 @@ 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', () => { @@ -348,8 +343,10 @@ describe('usePathTransition', () => { 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(), diff --git a/packages/web-visualization/src/chart/utils/bar.ts b/packages/web-visualization/src/chart/utils/bar.ts index c30de9154e..28a437d264 100644 --- a/packages/web-visualization/src/chart/utils/bar.ts +++ b/packages/web-visualization/src/chart/utils/bar.ts @@ -1,3 +1,53 @@ +import type { Transition } from 'framer-motion'; + +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 horizontal position (leftmost starts first, rightmost last). + * + * @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; +}; + +/** + * Strips `staggerDelay` from a transition and computes a positional delay. + * + * @param transition - The transition config (may include staggerDelay) + * @param normalizedX - The bar's normalized x position (0 = left edge, 1 = right edge) + * @returns A standard Transition with computed delay + */ +export const withStaggerDelayTransition = ( + transition: BarTransition, + normalizedX: number, +): Transition => { + const { staggerDelay, ...baseTransition } = transition; + if (!staggerDelay) return transition; + return { + ...baseTransition, + delay: (baseTransition?.delay ?? 0) + normalizedX * 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, +}; + /** * 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 diff --git a/packages/web-visualization/src/chart/utils/path.ts b/packages/web-visualization/src/chart/utils/path.ts index 9932df4d9e..927e8819f2 100644 --- a/packages/web-visualization/src/chart/utils/path.ts +++ b/packages/web-visualization/src/chart/utils/path.ts @@ -11,10 +11,20 @@ import { curveStepBefore, line as d3Line, } from 'd3-shape'; +import type { Transition } from 'framer-motion'; import { 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' diff --git a/packages/web-visualization/src/chart/utils/transition.ts b/packages/web-visualization/src/chart/utils/transition.ts index f4fa560082..2e61eecc2e 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 => { + if (!animate || value === null) return instantTransition; + 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,77 @@ 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; + /** + * Transition for subsequent data update animations. + * @default defaultTransition + */ + update?: Transition; + }; + /** + * Transition for updates. + * @deprecated Use `transitions.update` instead. */ transition?: Transition; }): MotionValue => { - const isInitialRender = useRef(true); + const updateTransition = transitions?.update ?? transition; + const enterTransition = transitions?.enter; + const previousPathRef = useRef(initialPath ?? currentPath); - const targetPathRef = useRef(currentPath); + const targetPathRef = useRef(initialPath ?? currentPath); const animationRef = useRef(null); - const progress = useMotionValue(0); + const isFirstAnimation = useRef(!!initialPath); - // Derive the interpolated path from progress using useTransform - const interpolatedPath = useTransform(progress, (latest) => { - const pathInterpolator = interpolatePath(previousPathRef.current, targetPathRef.current); - return pathInterpolator(latest); - }); + const animatedPath = useMotionValue(initialPath ?? currentPath); 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; + const currentVisualPath = animatedPath.get(); + if (animationRef.current) { - animationRef.current.cancel(); + animationRef.current.stop(); animationRef.current = null; + previousPathRef.current = currentVisualPath; } - const currentInterpolatedPath = interpolatedPath.get(); + targetPathRef.current = currentPath; - // 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; + const activeTransition = + isFirstAnimation.current && enterTransition !== undefined + ? enterTransition + : updateTransition; - if (wasAnimating && isInterpolatedPosition) { - previousPathRef.current = currentInterpolatedPath; - } + isFirstAnimation.current = false; - targetPathRef.current = currentPath; + const pathInterpolator = interpolatePath(previousPathRef.current, currentPath); - progress.set(0); - animationRef.current = animate(progress, 1, { - ...(transition as ValueAnimationTransition), + animationRef.current = animate(0, 1, { + ...(activeTransition as ValueAnimationTransition), + onUpdate: (latest) => { + animatedPath.set(pathInterpolator(latest)); + }, onComplete: () => { + animatedPath.set(currentPath); previousPathRef.current = currentPath; + animationRef.current = null; }, }); - - isInitialRender.current = false; } return () => { if (animationRef.current) { - animationRef.current.cancel(); + animationRef.current.stop(); } }; - }, [currentPath, transition, progress, interpolatedPath]); + }, [currentPath, updateTransition, enterTransition, animatedPath]); - return interpolatedPath; + return animatedPath; }; From 9ba7cb213bd0400153242f8b22346b88bee73e2f Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 25 Feb 2026 11:20:31 -0500 Subject: [PATCH 06/12] feat: add range bar chart examples (#435) * feat: add range bar chart examples * Fix lint * Fix lint warning * Cleanup web as well --- .../graphs/BarChart/_mobileExamples.mdx | 40 ++++++++++++++++++- .../graphs/BarChart/_webExamples.mdx | 40 ++++++++++++++++++- .../bar/__stories__/BarChart.stories.tsx | 36 +++++++++++++++++ .../bar/__stories__/BarChart.stories.tsx | 35 ++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) diff --git a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx index 6b545298e8..516358cd59 100644 --- a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx @@ -352,7 +352,9 @@ You can also round the baseline of the bars by setting the `roundBaseline` prop /> ``` -## Negative Data +## Data + +### Negative ```tsx function PositiveAndNegativeCashFlow() { @@ -392,7 +394,7 @@ function PositiveAndNegativeCashFlow() { } ``` -## Missing Bars +### Null You can pass in `null` or `0` values to not render a bar for that data point. @@ -490,6 +492,40 @@ function MonthlyRewards() { } ``` +### 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 diff --git a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx index f88f9ca3d5..7d82a11d14 100644 --- a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx +++ b/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx @@ -352,7 +352,9 @@ You can also round the baseline of the bars by setting the `roundBaseline` prop /> ``` -## Negative Data +## Data + +### Negative ```jsx live function PositiveAndNegativeCashFlow() { @@ -392,7 +394,7 @@ function PositiveAndNegativeCashFlow() { } ``` -## Missing Bars +### Null You can pass in `null` or `0` values to not render a bar for that data point. @@ -490,6 +492,40 @@ function MonthlyRewards() { } ``` +### 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.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) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +} +``` + ## Customization ### Bar Spacing 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 7232e669b4..43be6aff35 100644 --- a/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/mobile-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,5 +1,6 @@ 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'; @@ -932,6 +933,37 @@ const SunlightChart = () => { ); }; +const PriceRange = () => { + const candles = btcCandles.slice(0, 180).reverse(); + 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 ( + + ); +}; + type ExampleItem = { title: string; component: React.ReactNode; @@ -1013,6 +1045,10 @@ function ExampleNavigator() { title: 'Monthly Sunlight', component: , }, + { + title: 'Price Range', + component: , + }, ], [], ); 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 6858fc3883..6e94b84ebb 100644 --- a/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx +++ b/packages/web-visualization/src/chart/bar/__stories__/BarChart.stories.tsx @@ -1,4 +1,5 @@ import React, { memo, 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'; @@ -490,6 +491,37 @@ const MonthlySunlight = () => { ); }; +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 ( + + ); +}; + export const All = () => { return ( @@ -866,6 +898,9 @@ export const All = () => { + + + ); }; From 3e768fab05c80542078c1d9eac50c1db78da3b7a Mon Sep 17 00:00:00 2001 From: Maximo Macchi <232606069+maximo-macchi-cb@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:06:19 -0600 Subject: [PATCH 07/12] fix: Truncate multi-select chip labels mid-word (#412) * Add web implementation * Add story to show longer labels for multi-select chips * Update changelogs --- packages/common/CHANGELOG.md | 4 +++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 +++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 4 +++ packages/mobile/package.json | 2 +- .../__stories__/AlphaSelect.stories.tsx | 28 +++++++++++++++++++ packages/web/CHANGELOG.md | 6 ++++ packages/web/package.json | 2 +- .../src/alpha/select/DefaultSelectControl.tsx | 14 +++++++++- .../__stories__/MultiSelect.stories.tsx | 24 ++++++++++++++++ 11 files changed, 87 insertions(+), 5 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 688831f9d8..43efbb15d4 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index 15aea5200d..fe0d49f35e 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.48.0", + "version": "8.48.1", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 0eee55b028..38e14279f3 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index dacc5ca564..3be2c9e89d 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.48.0", + "version": "8.48.1", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 97acdf5dae..83a10d6c97 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 4b0eba778a..cf8170ca33 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.48.0", + "version": "8.48.1", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/alpha/select/__stories__/AlphaSelect.stories.tsx b/packages/mobile/src/alpha/select/__stories__/AlphaSelect.stories.tsx index 408c152b44..9220000b13 100644 --- a/packages/mobile/src/alpha/select/__stories__/AlphaSelect.stories.tsx +++ b/packages/mobile/src/alpha/select/__stories__/AlphaSelect.stories.tsx @@ -57,6 +57,14 @@ const exampleOptionsWithNoLabelOrDescription = [ { value: '4' }, ]; +const exampleOptionsWithLongLabels = [ + { 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 exampleOptionsWithSomeDisabled = [ { value: null, label: 'Remove selection' }, { value: '1', label: 'Option 1', disabled: true }, @@ -886,6 +894,23 @@ const MultiSelectCustomSelectAllOptionExample = () => { ); }; +const MultiSelectLongLabelOptionsExample = () => { + const { value, onChange } = useMultiSelect({ + initialValue: ['1'], + }); + + return ( + + ); +}; + export const Disabled = () => { const exampleOptions = [ { value: null, label: 'Remove selection' }, From b64aea4a848268ef9f9b443a0fdfb06570f91492 Mon Sep 17 00:00:00 2001 From: Hunter Copp Date: Wed, 25 Feb 2026 17:16:25 -0500 Subject: [PATCH 08/12] chore: rename charts docs (#440) * chore: rename graphs to charts in docs * Rename remaining docs * Ran dedupe and update changelog * Set docusaurus plugin version --- .cursor/commands/component-docs.md | 2 +- .../cards/DataCard/mobileMetadata.json | 2 +- .../cards/DataCard/webMetadata.json | 2 +- .../AreaChart/_mobileExamples.mdx | 2 +- .../AreaChart/_mobilePropsTable.mdx | 0 .../AreaChart/_webExamples.mdx | 2 +- .../AreaChart/_webPropsTable.mdx | 0 .../{graphs => charts}/AreaChart/index.mdx | 0 .../AreaChart/mobileMetadata.json | 10 +- .../AreaChart/webMetadata.json | 10 +- .../BarChart/_mobileExamples.mdx | 0 .../BarChart/_mobilePropsTable.mdx | 0 .../BarChart/_webExamples.mdx | 0 .../BarChart/_webPropsTable.mdx | 0 .../{graphs => charts}/BarChart/index.mdx | 0 .../BarChart/mobileMetadata.json | 6 +- .../BarChart/webMetadata.json | 6 +- .../CartesianChart/_mobileExamples.mdx | 4 +- .../CartesianChart/_mobilePropsTable.mdx | 0 .../CartesianChart/_webExamples.mdx | 4 +- .../CartesianChart/_webPropsTable.mdx | 0 .../CartesianChart/index.mdx | 0 .../CartesianChart/mobileMetadata.json | 10 +- .../CartesianChart/webMetadata.json | 10 +- .../Legend/_mobileExamples.mdx | 0 .../Legend/_mobilePropsTable.mdx | 0 .../Legend/_webExamples.mdx | 0 .../Legend/_webPropsTable.mdx | 0 .../{graphs => charts}/Legend/index.mdx | 0 .../Legend/mobileMetadata.json | 6 +- .../Legend/webMetadata.json | 6 +- .../LineChart/_mobileExamples.mdx | 24 +- .../LineChart/_mobilePropsTable.mdx | 0 .../LineChart/_webExamples.mdx | 20 +- .../LineChart/_webPropsTable.mdx | 0 .../{graphs => charts}/LineChart/index.mdx | 0 .../LineChart/mobileMetadata.json | 12 +- .../LineChart/webMetadata.json | 12 +- .../PeriodSelector/_mobileExamples.mdx | 0 .../PeriodSelector/_mobilePropsTable.mdx | 0 .../PeriodSelector/_webExamples.mdx | 0 .../PeriodSelector/_webPropsTable.mdx | 0 .../PeriodSelector/index.mdx | 0 .../PeriodSelector/mobileMetadata.json | 2 +- .../PeriodSelector/webMetadata.json | 2 +- .../Point/_mobileExamples.mdx | 2 +- .../Point/_mobilePropsTable.mdx | 0 .../{graphs => charts}/Point/_webExamples.mdx | 2 +- .../Point/_webPropsTable.mdx | 0 .../{graphs => charts}/Point/index.mdx | 0 .../Point/mobileMetadata.json | 4 +- .../{graphs => charts}/Point/webMetadata.json | 4 +- .../ReferenceLine/_mobileExamples.mdx | 0 .../ReferenceLine/_mobilePropsTable.mdx | 0 .../ReferenceLine/_webExamples.mdx | 0 .../ReferenceLine/_webPropsTable.mdx | 0 .../ReferenceLine/index.mdx | 0 .../ReferenceLine/mobileMetadata.json | 2 +- .../ReferenceLine/webMetadata.json | 2 +- .../Scrubber/_mobileExamples.mdx | 0 .../Scrubber/_mobilePropsTable.mdx | 0 .../Scrubber/_webExamples.mdx | 0 .../Scrubber/_webPropsTable.mdx | 0 .../Scrubber/_webStyles.mdx | 0 .../{graphs => charts}/Scrubber/index.mdx | 0 .../Scrubber/mobileMetadata.json | 4 +- .../Scrubber/webMetadata.json | 4 +- .../Sparkline/_mobileExamples.mdx | 0 .../Sparkline/_mobilePropsTable.mdx | 0 .../Sparkline/_webExamples.mdx | 0 .../Sparkline/_webPropsTable.mdx | 0 .../{graphs => charts}/Sparkline/index.mdx | 0 .../Sparkline/mobileMetadata.json | 4 +- .../Sparkline/webMetadata.json | 4 +- .../SparklineGradient/_mobileExamples.mdx | 2 +- .../SparklineGradient/_mobilePropsTable.mdx | 0 .../SparklineGradient/_webExamples.mdx | 2 +- .../SparklineGradient/_webPropsTable.mdx | 0 .../SparklineGradient/index.mdx | 0 .../SparklineGradient/mobileMetadata.json | 4 +- .../SparklineGradient/webMetadata.json | 4 +- .../SparklineInteractive/_mobileExamples.mdx | 0 .../_mobilePropsTable.mdx | 0 .../SparklineInteractive/_webExamples.mdx | 0 .../SparklineInteractive/_webPropsTable.mdx | 0 .../SparklineInteractive/index.mdx | 0 .../SparklineInteractive/mobileMetadata.json | 4 +- .../SparklineInteractive/webMetadata.json | 4 +- .../_mobileExamples.mdx | 0 .../_mobilePropsTable.mdx | 0 .../_webExamples.mdx | 0 .../_webPropsTable.mdx | 0 .../SparklineInteractiveHeader/index.mdx | 0 .../mobileMetadata.json | 4 +- .../webMetadata.json | 4 +- .../XAxis/_mobileExamples.mdx | 0 .../XAxis/_mobilePropsTable.mdx | 0 .../{graphs => charts}/XAxis/_webExamples.mdx | 0 .../XAxis/_webPropsTable.mdx | 0 .../{graphs => charts}/XAxis/index.mdx | 0 .../XAxis/mobileMetadata.json | 4 +- .../{graphs => charts}/XAxis/webMetadata.json | 4 +- .../YAxis/_mobileExamples.mdx | 0 .../YAxis/_mobilePropsTable.mdx | 0 .../{graphs => charts}/YAxis/_webExamples.mdx | 0 .../YAxis/_webPropsTable.mdx | 0 .../{graphs => charts}/YAxis/index.mdx | 0 .../YAxis/mobileMetadata.json | 4 +- .../{graphs => charts}/YAxis/webMetadata.json | 4 +- apps/docs/docusaurus.config.ts | 11 + apps/docs/package.json | 37 +- apps/docs/project.json | 9 + apps/docs/sidebars.ts | 32 +- .../{graphs_dark.svg => charts_dark.svg} | 0 ...s_dark_hover.svg => charts_dark_hover.svg} | 0 .../{graphs_light.svg => charts_light.svg} | 0 ...light_hover.svg => charts_light_hover.svg} | 0 libs/docusaurus-plugin-docgen/package.json | 6 +- libs/docusaurus-plugin-kbar/package.json | 6 +- .../package.json | 2 +- packages/mobile-visualization/CHANGELOG.md | 6 + .../src/chart/ChartProvider.tsx | 2 +- .../src/chart/bar/BarStackGroup.tsx | 2 +- .../src/chart/utils/axis.ts | 4 +- packages/web-visualization/CHANGELOG.md | 6 + .../src/chart/ChartProvider.tsx | 2 +- .../src/chart/bar/BarStackGroup.tsx | 2 +- .../web-visualization/src/chart/utils/axis.ts | 4 +- yarn.lock | 466 ++++++++++-------- 129 files changed, 447 insertions(+), 380 deletions(-) rename apps/docs/docs/components/{graphs => charts}/AreaChart/_mobileExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/_webExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/mobileMetadata.json (79%) rename apps/docs/docs/components/{graphs => charts}/AreaChart/webMetadata.json (77%) rename apps/docs/docs/components/{graphs => charts}/BarChart/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/BarChart/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/BarChart/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/BarChart/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/BarChart/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/BarChart/mobileMetadata.json (85%) rename apps/docs/docs/components/{graphs => charts}/BarChart/webMetadata.json (84%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/_mobileExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/_webExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/mobileMetadata.json (80%) rename apps/docs/docs/components/{graphs => charts}/CartesianChart/webMetadata.json (79%) rename apps/docs/docs/components/{graphs => charts}/Legend/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Legend/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Legend/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Legend/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Legend/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Legend/mobileMetadata.json (84%) rename apps/docs/docs/components/{graphs => charts}/Legend/webMetadata.json (80%) rename apps/docs/docs/components/{graphs => charts}/LineChart/_mobileExamples.mdx (98%) rename apps/docs/docs/components/{graphs => charts}/LineChart/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/LineChart/_webExamples.mdx (98%) rename apps/docs/docs/components/{graphs => charts}/LineChart/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/LineChart/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/LineChart/mobileMetadata.json (77%) rename apps/docs/docs/components/{graphs => charts}/LineChart/webMetadata.json (75%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/mobileMetadata.json (91%) rename apps/docs/docs/components/{graphs => charts}/PeriodSelector/webMetadata.json (92%) rename apps/docs/docs/components/{graphs => charts}/Point/_mobileExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/Point/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Point/_webExamples.mdx (99%) rename apps/docs/docs/components/{graphs => charts}/Point/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Point/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Point/mobileMetadata.json (88%) rename apps/docs/docs/components/{graphs => charts}/Point/webMetadata.json (85%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/mobileMetadata.json (93%) rename apps/docs/docs/components/{graphs => charts}/ReferenceLine/webMetadata.json (92%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/_webStyles.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/mobileMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/Scrubber/webMetadata.json (88%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/mobileMetadata.json (87%) rename apps/docs/docs/components/{graphs => charts}/Sparkline/webMetadata.json (88%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/_mobileExamples.mdx (96%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/_webExamples.mdx (96%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/mobileMetadata.json (88%) rename apps/docs/docs/components/{graphs => charts}/SparklineGradient/webMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/mobileMetadata.json (88%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractive/webMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/mobileMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/SparklineInteractiveHeader/webMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/XAxis/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/XAxis/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/XAxis/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/XAxis/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/XAxis/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/XAxis/mobileMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/XAxis/webMetadata.json (85%) rename apps/docs/docs/components/{graphs => charts}/YAxis/_mobileExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/YAxis/_mobilePropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/YAxis/_webExamples.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/YAxis/_webPropsTable.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/YAxis/index.mdx (100%) rename apps/docs/docs/components/{graphs => charts}/YAxis/mobileMetadata.json (89%) rename apps/docs/docs/components/{graphs => charts}/YAxis/webMetadata.json (86%) rename apps/docs/static/img/componentCardBanners/{graphs_dark.svg => charts_dark.svg} (100%) rename apps/docs/static/img/componentCardBanners/{graphs_dark_hover.svg => charts_dark_hover.svg} (100%) rename apps/docs/static/img/componentCardBanners/{graphs_light.svg => charts_light.svg} (100%) rename apps/docs/static/img/componentCardBanners/{graphs_light_hover.svg => charts_light_hover.svg} (100%) diff --git a/.cursor/commands/component-docs.md b/.cursor/commands/component-docs.md index b8188bbc2a..957f7f79e0 100644 --- a/.cursor/commands/component-docs.md +++ b/.cursor/commands/component-docs.md @@ -29,7 +29,7 @@ For updates, focus on the specific areas that need improvement rather than rewri 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 +- **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 diff --git a/apps/docs/docs/components/cards/DataCard/mobileMetadata.json b/apps/docs/docs/components/cards/DataCard/mobileMetadata.json index 2ab1988e74..28a52d3e47 100644 --- a/apps/docs/docs/components/cards/DataCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/DataCard/mobileMetadata.json @@ -26,7 +26,7 @@ }, { "label": "LineChart", - "url": "/components/graphs/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 index 783542c147..1eaa7b98ae 100644 --- a/apps/docs/docs/components/cards/DataCard/webMetadata.json +++ b/apps/docs/docs/components/cards/DataCard/webMetadata.json @@ -27,7 +27,7 @@ }, { "label": "LineChart", - "url": "/components/graphs/LineChart/" + "url": "/components/charts/LineChart/" } ], "dependencies": [] diff --git a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx similarity index 99% rename from apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx rename to apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx index 4f1766b4bf..b3cf17b575 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx @@ -40,7 +40,7 @@ AreaChart is a cartesian chart variant that allows for easy visualization of sta ## 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. +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() { 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/graphs/AreaChart/_webExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_webExamples.mdx similarity index 99% rename from apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx rename to apps/docs/docs/components/charts/AreaChart/_webExamples.mdx index 21eeb92c3f..c57793762d 100644 --- a/apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx +++ b/apps/docs/docs/components/charts/AreaChart/_webExamples.mdx @@ -41,7 +41,7 @@ AreaChart is a cartesian chart variant that allows for easy visualization of sta ## 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. +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 live @@ -277,7 +277,7 @@ You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` ``` -For more info, learn about [XAxis](/components/graphs/XAxis/#axis-config) and [YAxis](/components/graphs/YAxis/#axis-config) configuration. +For more info, learn about [XAxis](/components/charts/XAxis/#axis-config) and [YAxis](/components/charts/YAxis/#axis-config) configuration. ## Inset 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/graphs/CartesianChart/_webExamples.mdx b/apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx similarity index 99% rename from apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx rename to apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx index 713998db21..ca6be6d7f2 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx +++ b/apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx @@ -2,7 +2,7 @@ CartesianChart is a customizable, SVG based component that can be used to displa ## 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. +[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 @@ -244,7 +244,7 @@ You can configure your x and y axes with the `xAxis` and `yAxis` props. `xAxis` ``` -For more info, learn about [XAxis](/components/graphs/XAxis/#axis-config) and [YAxis](/components/graphs/YAxis/#axis-config) configuration. +For more info, learn about [XAxis](/components/charts/XAxis/#axis-config) and [YAxis](/components/charts/YAxis/#axis-config) configuration. ## Inset 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/graphs/CartesianChart/mobileMetadata.json b/apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json similarity index 80% rename from apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json rename to apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json index 109b74d3bd..abaf43dac8 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/mobileMetadata.json +++ b/apps/docs/docs/components/charts/CartesianChart/mobileMetadata.json @@ -5,23 +5,23 @@ "relatedComponents": [ { "label": "Point", - "url": "/components/graphs/Point/" + "url": "/components/charts/Point/" }, { "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" + "url": "/components/charts/ReferenceLine/" }, { "label": "Scrubber", - "url": "/components/graphs/Scrubber/" + "url": "/components/charts/Scrubber/" }, { "label": "XAxis", - "url": "/components/graphs/XAxis/" + "url": "/components/charts/XAxis/" }, { "label": "YAxis", - "url": "/components/graphs/YAxis/" + "url": "/components/charts/YAxis/" } ], "dependencies": [ diff --git a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json b/apps/docs/docs/components/charts/CartesianChart/webMetadata.json similarity index 79% rename from apps/docs/docs/components/graphs/CartesianChart/webMetadata.json rename to apps/docs/docs/components/charts/CartesianChart/webMetadata.json index 8f55e33f81..165cd1d21e 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json +++ b/apps/docs/docs/components/charts/CartesianChart/webMetadata.json @@ -6,23 +6,23 @@ "relatedComponents": [ { "label": "Point", - "url": "/components/graphs/Point/" + "url": "/components/charts/Point/" }, { "label": "ReferenceLine", - "url": "/components/graphs/ReferenceLine/" + "url": "/components/charts/ReferenceLine/" }, { "label": "Scrubber", - "url": "/components/graphs/Scrubber/" + "url": "/components/charts/Scrubber/" }, { "label": "XAxis", - "url": "/components/graphs/XAxis/" + "url": "/components/charts/XAxis/" }, { "label": "YAxis", - "url": "/components/graphs/YAxis/" + "url": "/components/charts/YAxis/" } ], "dependencies": [ diff --git a/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx b/apps/docs/docs/components/charts/Legend/_mobileExamples.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx rename to apps/docs/docs/components/charts/Legend/_mobileExamples.mdx diff --git a/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/Legend/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/Legend/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx b/apps/docs/docs/components/charts/Legend/_webExamples.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Legend/_webExamples.mdx rename to apps/docs/docs/components/charts/Legend/_webExamples.mdx diff --git a/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx b/apps/docs/docs/components/charts/Legend/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx rename to apps/docs/docs/components/charts/Legend/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/Legend/index.mdx b/apps/docs/docs/components/charts/Legend/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/Legend/index.mdx rename to apps/docs/docs/components/charts/Legend/index.mdx diff --git a/apps/docs/docs/components/graphs/Legend/mobileMetadata.json b/apps/docs/docs/components/charts/Legend/mobileMetadata.json similarity index 84% rename from apps/docs/docs/components/graphs/Legend/mobileMetadata.json rename to apps/docs/docs/components/charts/Legend/mobileMetadata.json index caf6999301..e481b159fb 100644 --- a/apps/docs/docs/components/graphs/Legend/mobileMetadata.json +++ b/apps/docs/docs/components/charts/Legend/mobileMetadata.json @@ -5,15 +5,15 @@ "relatedComponents": [ { "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" + "url": "/components/charts/CartesianChart/" }, { "label": "LineChart", - "url": "/components/graphs/LineChart/" + "url": "/components/charts/LineChart/" }, { "label": "BarChart", - "url": "/components/graphs/BarChart/" + "url": "/components/charts/BarChart/" } ], "dependencies": [ diff --git a/apps/docs/docs/components/graphs/Legend/webMetadata.json b/apps/docs/docs/components/charts/Legend/webMetadata.json similarity index 80% rename from apps/docs/docs/components/graphs/Legend/webMetadata.json rename to apps/docs/docs/components/charts/Legend/webMetadata.json index 056ff17267..0eeb90dbae 100644 --- a/apps/docs/docs/components/graphs/Legend/webMetadata.json +++ b/apps/docs/docs/components/charts/Legend/webMetadata.json @@ -5,15 +5,15 @@ "relatedComponents": [ { "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" + "url": "/components/charts/CartesianChart/" }, { "label": "LineChart", - "url": "/components/graphs/LineChart/" + "url": "/components/charts/LineChart/" }, { "label": "BarChart", - "url": "/components/graphs/BarChart/" + "url": "/components/charts/BarChart/" } ], "dependencies": [ diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx similarity index 98% rename from apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx rename to apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx index 2ba91024b0..d030257e30 100644 --- a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx @@ -1,8 +1,8 @@ -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`. +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/graphs/CartesianChart/#setup) for details. +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 @@ -249,7 +249,7 @@ function EmptyState() { ### 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. +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 ``` -You can also add instances of [ReferenceLine](/components/graphs/ReferenceLine) to your LineChart to highlight a specific x or y value. +You can also add instances of [ReferenceLine](/components/charts/ReferenceLine) to your LineChart to highlight a specific x or y value. ```jsx ``` -You can also add instances of [ReferenceLine](/components/graphs/ReferenceLine) to your LineChart to highlight a specific x or y value. +You can also add instances of [ReferenceLine](/components/charts/ReferenceLine) to your LineChart to highlight a specific x or y value. ```jsx live +## Unreleased + +#### 📘 Misc + +- Update outdated doc links. [[#440](https://github.com/coinbase/cds/pull/440)] + ## 3.4.0-beta.19 (2/20/2026 PST) #### 🚀 Updates diff --git a/packages/mobile-visualization/src/chart/ChartProvider.tsx b/packages/mobile-visualization/src/chart/ChartProvider.tsx index 3704494738..7491d0989a 100644 --- a/packages/mobile-visualization/src/chart/ChartProvider.tsx +++ b/packages/mobile-visualization/src/chart/ChartProvider.tsx @@ -10,7 +10,7 @@ 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/bar/BarStackGroup.tsx b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx index 064a1e5763..74a1787182 100644 --- a/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/mobile-visualization/src/chart/bar/BarStackGroup.tsx @@ -89,7 +89,7 @@ export const BarStackGroup = memo( if (xScale && !isCategoricalScale(xScale)) { 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 x-axis. See https://cds.coinbase.com/components/charts/XAxis/#scale-type', ); } diff --git a/packages/mobile-visualization/src/chart/utils/axis.ts b/packages/mobile-visualization/src/chart/utils/axis.ts index 1abdf96a59..bda9239f0e 100644 --- a/packages/mobile-visualization/src/chart/utils/axis.ts +++ b/packages/mobile-visualization/src/chart/utils/axis.ts @@ -162,7 +162,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') { @@ -211,7 +211,7 @@ 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 axes, each must have a unique id. See https://cds.coinbase.com/components/charts/YAxis/#multiple-y-axes.', ); } diff --git a/packages/web-visualization/CHANGELOG.md b/packages/web-visualization/CHANGELOG.md index 56b3563cb8..082ee7158b 100644 --- a/packages/web-visualization/CHANGELOG.md +++ b/packages/web-visualization/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Update oudated doc links. [[#440](https://github.com/coinbase/cds/pull/440)] + ## 3.4.0-beta.19 (2/20/2026 PST) #### 🚀 Updates diff --git a/packages/web-visualization/src/chart/ChartProvider.tsx b/packages/web-visualization/src/chart/ChartProvider.tsx index 6cb100f2b1..192421a5d0 100644 --- a/packages/web-visualization/src/chart/ChartProvider.tsx +++ b/packages/web-visualization/src/chart/ChartProvider.tsx @@ -10,7 +10,7 @@ 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/bar/BarStackGroup.tsx b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx index 06ccc18482..40d6dd27c1 100644 --- a/packages/web-visualization/src/chart/bar/BarStackGroup.tsx +++ b/packages/web-visualization/src/chart/bar/BarStackGroup.tsx @@ -91,7 +91,7 @@ export const BarStackGroup = memo( if (xScale && !isCategoricalScale(xScale)) { 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 x-axis. See https://cds.coinbase.com/components/charts/XAxis/#scale-type', ); } diff --git a/packages/web-visualization/src/chart/utils/axis.ts b/packages/web-visualization/src/chart/utils/axis.ts index 73fac3a3d2..1b0a7c954f 100644 --- a/packages/web-visualization/src/chart/utils/axis.ts +++ b/packages/web-visualization/src/chart/utils/axis.ts @@ -162,7 +162,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') { @@ -211,7 +211,7 @@ 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 axes, each must have a unique id. See https://cds.coinbase.com/components/charts/YAxis/#multiple-y-axes.', ); } diff --git a/yarn.lock b/yarn.lock index ff908da91a..b0dd638992 100644 --- a/yarn.lock +++ b/yarn.lock @@ -24,6 +24,18 @@ __metadata: languageName: node linkType: hard +"@algolia/abtesting@npm:1.15.1": + version: 1.15.1 + resolution: "@algolia/abtesting@npm:1.15.1" + dependencies: + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/97004c81724f02981815ef3c7d97e457f42180b517387502b3258cfe21be5f481524e066d1c8ea8e40891bc17fb2b8c901f0518152f5cf00096547f2dce038ea + languageName: node + linkType: hard + "@algolia/autocomplete-core@npm:1.17.9": version: 1.17.9 resolution: "@algolia/autocomplete-core@npm:1.17.9" @@ -67,82 +79,82 @@ __metadata: languageName: node linkType: hard -"@algolia/client-abtesting@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-abtesting@npm:5.20.0" +"@algolia/client-abtesting@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-abtesting@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/9c374efbb79d9ec322f92618d70183aad90f1e386e8df2f82c776af7011f2ddc0feafdb1639edfd40a4a12394e44f442016bca2e125a20d52e6227d7fbb23646 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/ffb14177e0cebaa66ac0a334c61b97b005446ac3f2dd44eba1fcff6d2852555a57ccb274f898fed7213e49ffe23aa41ad370888c4a2c7d6fe5feb9cd625a7cac languageName: node linkType: hard -"@algolia/client-analytics@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-analytics@npm:5.20.0" +"@algolia/client-analytics@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-analytics@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/c3cc9b0eea8af6f22a4598decd1be9d3df3f4aabc7301abed38e7f3dec078827b69de38893e93c0cc2c1d0d07af03d536577c967270cb5328aeb9af2ee8eb807 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/f004d4ac201ee83bbe4009445b180d773043f1265fc7ebf349c24b35751be90e8cb41b0f3bdf2e7a84af15c80e89ea2fc51c5d31f12d20d0ea44bcc65f3b3dc0 languageName: node linkType: hard -"@algolia/client-common@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-common@npm:5.20.0" - checksum: 10c0/c1288c7a3f3366c48b31a4810223d9ca17878a9da656f89dda5e8348e3ec5dc82d538bfd6ad8c203e1aa28d191ef93b10cdad90ad3a96dddd7772ffc4f26ad4e +"@algolia/client-common@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-common@npm:5.49.1" + checksum: 10c0/dadb91cc29152629675227d028443f6f5a3542e26bc86d69e014c87930900a9afcf1ab3ce97943bd3ec7bf6272b854176501de6c878485f17f98591023004e07 languageName: node linkType: hard -"@algolia/client-insights@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-insights@npm:5.20.0" +"@algolia/client-insights@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-insights@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/79a4353464ce1480b446a704c2bf95db33911fce1c6975dea26bfd2cf68ca50dfaf6e5643fc11dfda8b2d3f4a7e921a615372ce61b4b781fff8c961b96a0f992 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/d7c38abe71fe973654f4843450a25684d27bb32786a0b14b9203217b61ba51bfa689f7826111ae35b283bc52dea15bf193d4d386e4d1cd7c9e4074fe16c34286 languageName: node linkType: hard -"@algolia/client-personalization@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-personalization@npm:5.20.0" +"@algolia/client-personalization@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-personalization@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/c7fbea1e3f7023c8687f21da25421187478440a16816ffaf3c0191b922ebfba23122d145cc270860f5e5a2f90157db8f0579330c2652a41280e907cd1c50c016 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/e3ba4eb278624c8e0c6911221a6fb8e0d6ae8d103b5fd0382f992623d13d5e7ab9464c6658f3ff414ebe5b209e9b4196d65bf1206eaeca5fbc94af3a5533a403 languageName: node linkType: hard -"@algolia/client-query-suggestions@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-query-suggestions@npm:5.20.0" +"@algolia/client-query-suggestions@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-query-suggestions@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/ffaadf1b1df25fe2006daafd4d5cef97897b17a944d4263df8ff892195f5ba9fb4cf51c33f6672c41d1fe593e2ed032fa28f586dc6a14abcec64c77ce3f38b63 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/72220c68caf8d41e4af4cbf058b908c061347634b6654e36b091e86b1e3f97201e8652d27c20d9397054d8784c7172921fdcf32e3f32cd76b9f412672266d7e1 languageName: node linkType: hard -"@algolia/client-search@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/client-search@npm:5.20.0" +"@algolia/client-search@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/client-search@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/2d62718f3b054a3dbee6f4b07a51eef5102c41b336e7d7768afe26889dc1852b92c0f9c747d1b44a9b921eb8daef7dfe2b2087f44a3177d21fe7d7080c83f9fe + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/2efc425aff032b3d0f037cd9c07b43a6c53e8e2bb2efc23478a15df85c89eda017dd8c32b695092c2d6be16565951046891b808b0e0d25b82cc61425d0a4a322 languageName: node linkType: hard @@ -153,66 +165,66 @@ __metadata: languageName: node linkType: hard -"@algolia/ingestion@npm:1.20.0": - version: 1.20.0 - resolution: "@algolia/ingestion@npm:1.20.0" +"@algolia/ingestion@npm:1.49.1": + version: 1.49.1 + resolution: "@algolia/ingestion@npm:1.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/be77d56c378e9196c817b66afd922a4a812d4cb0fa0f8b7c09c8eca219f1262212e02f948d54e5ae460aea2a08dcc67f1968a1fcfdf18a1f0fd5267e8b1881d9 + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/7bc3d4ef918db32975e855a744f68fe4316aa98209af7c84f4a2a808a17e802cc4ee38d6b6ad708d8001fd20d9eaf3e5408669a5035ab455bdc605ea4146764d languageName: node linkType: hard -"@algolia/monitoring@npm:1.20.0": - version: 1.20.0 - resolution: "@algolia/monitoring@npm:1.20.0" +"@algolia/monitoring@npm:1.49.1": + version: 1.49.1 + resolution: "@algolia/monitoring@npm:1.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/0b2f9d899e2662fe0e6eb0c45fb3cc46c546951603f1ea52f9adc8d2dd4296f7010e93b2b2e0b94c1f51a2e1edc887eeb054db76c6b6f417fa123d4f1c674bdd + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/5d48226ef571a286daf45de0038bbdabceba339ec261f508e4e2e53f7b5822c0f598b5f2f39122787aab282850397099043de10023e2379b02185e24e8b230a6 languageName: node linkType: hard -"@algolia/recommend@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/recommend@npm:5.20.0" +"@algolia/recommend@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/recommend@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/ce62228b630864ed0faf78c0f3b5fbca5ef38e9c07ec6e492d7d36b948418ec87b82869d78740c980f5d0bbfbff37f15f394bfffd0571fdfb8a0973915b200cb + "@algolia/client-common": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/70db57b9bd7f132b4076e3b159f2eadd5d9b9265e349cfe1822c91994b96abba11dde8a12d2bd5442ee623e00bf371ce8f53f1af90b1b875de63d27d873e4e38 languageName: node linkType: hard -"@algolia/requester-browser-xhr@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/requester-browser-xhr@npm:5.20.0" +"@algolia/requester-browser-xhr@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/requester-browser-xhr@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - checksum: 10c0/80ae38016d682404468c8c8f3765fef468dc9f83095366f8531f48982400c1e2d7c55f95b331c23d44563cbf38afcf71c29a59c65dee5ca503a6b2a8386b2eea + "@algolia/client-common": "npm:5.49.1" + checksum: 10c0/81eaa2f8798b9ccdf993214bb86dbd42cca2a30f5e8a408e53d91ebc2990d987ceb7a54d93ff496af527bacf508485710b7133e05b7b1c054bcff714ed54db94 languageName: node linkType: hard -"@algolia/requester-fetch@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/requester-fetch@npm:5.20.0" +"@algolia/requester-fetch@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/requester-fetch@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - checksum: 10c0/8d9118088a39be10ba362fd37963c41a62dfe480ef42dfa17a32438c1278041074be12d2c459de0c0a1575452f64edb64856e8f47a4bba9b732cf1fe60ad0f92 + "@algolia/client-common": "npm:5.49.1" + checksum: 10c0/24987ac0f6d58993c661d8507a83e4b050fd4ba0261f0e074670cf492225b0b30a427e705145c184d703861651aede7451cedc88580063a64a7959763157d1bb languageName: node linkType: hard -"@algolia/requester-node-http@npm:5.20.0": - version: 5.20.0 - resolution: "@algolia/requester-node-http@npm:5.20.0" +"@algolia/requester-node-http@npm:5.49.1": + version: 5.49.1 + resolution: "@algolia/requester-node-http@npm:5.49.1" dependencies: - "@algolia/client-common": "npm:5.20.0" - checksum: 10c0/f1e2277c675d866e143ddb4c5b2eae69cd8af62194489e802cae25152854afdad03d2ce59d354b6a57952857b460962a65909ed5dfd4164db833690dbedcf7c7 + "@algolia/client-common": "npm:5.49.1" + checksum: 10c0/a75f152697ac282a31dbcbeabcc6b23e649955f6f70e64f38cc96482f3b0506afce7c46f0a5c7fbf87468c9a766015f534a8bf63777fe1386722802f9f231479 languageName: node linkType: hard @@ -2662,9 +2674,9 @@ __metadata: "@babel/preset-env": "npm:^7.28.0" "@babel/preset-react": "npm:^7.27.1" "@babel/preset-typescript": "npm:^7.27.1" - "@docusaurus/logger": "npm:^3.7.0" - "@docusaurus/types": "npm:^3.7.0" - "@docusaurus/utils": "npm:^3.7.0" + "@docusaurus/logger": "npm:~3.7.0" + "@docusaurus/types": "npm:~3.7.0" + "@docusaurus/utils": "npm:~3.7.0" "@types/ejs": "npm:^3.1.0" "@types/lodash": "npm:^4.14.178" ejs: "npm:^3.1.7" @@ -2686,9 +2698,9 @@ __metadata: "@babel/preset-react": "npm:^7.27.1" "@babel/preset-typescript": "npm:^7.27.1" "@coinbase/cds-common": "workspace:^" - "@docusaurus/logger": "npm:^3.7.0" - "@docusaurus/plugin-content-docs": "npm:^3.7.0" - "@docusaurus/types": "npm:^3.7.0" + "@docusaurus/logger": "npm:~3.7.0" + "@docusaurus/plugin-content-docs": "npm:~3.7.0" + "@docusaurus/types": "npm:~3.7.0" kbar: "npm:^0.1.0-beta.45" lodash: "npm:^4.17.21" type-fest: "npm:^2.19.0" @@ -2703,7 +2715,7 @@ __metadata: "@babel/preset-env": "npm:^7.28.0" "@babel/preset-react": "npm:^7.27.1" "@babel/preset-typescript": "npm:^7.27.1" - "@docusaurus/types": "npm:^3.7.0" + "@docusaurus/types": "npm:~3.7.0" "@types/express": "npm:^4.17.21" languageName: unknown linkType: soft @@ -3376,25 +3388,25 @@ __metadata: languageName: node linkType: hard -"@docsearch/css@npm:3.8.3": - version: 3.8.3 - resolution: "@docsearch/css@npm:3.8.3" - checksum: 10c0/76f09878ccc1db0f83bb3608b1717733486f9043e0f642f79e7d0c0cb492f1e84a827eeffa2a6e4285c23e3c7b668dae46a307a90dc97958c1b0e5f9275bcc10 +"@docsearch/css@npm:3.9.0": + version: 3.9.0 + resolution: "@docsearch/css@npm:3.9.0" + checksum: 10c0/6300551e1cab7a5487063ec3581ae78ddaee3d93ec799556b451054448559b3ba849751b825fbd8b678367ef944bd82b3f11bc1d9e74e08e3cc48db40487b396 languageName: node linkType: hard "@docsearch/react@npm:^3.8.1": - version: 3.8.3 - resolution: "@docsearch/react@npm:3.8.3" + version: 3.9.0 + resolution: "@docsearch/react@npm:3.9.0" dependencies: "@algolia/autocomplete-core": "npm:1.17.9" "@algolia/autocomplete-preset-algolia": "npm:1.17.9" - "@docsearch/css": "npm:3.8.3" + "@docsearch/css": "npm:3.9.0" algoliasearch: "npm:^5.14.2" peerDependencies: - "@types/react": ">= 16.8.0 < 19.0.0" - react: ">= 16.8.0 < 19.0.0" - react-dom: ">= 16.8.0 < 19.0.0" + "@types/react": ">= 16.8.0 < 20.0.0" + react: ">= 16.8.0 < 20.0.0" + react-dom: ">= 16.8.0 < 20.0.0" search-insights: ">= 1 < 3" peerDependenciesMeta: "@types/react": @@ -3405,7 +3417,7 @@ __metadata: optional: true search-insights: optional: true - checksum: 10c0/e64c38ebd2beaf84cfc68ede509caff1a4a779863322e14ec68a13136501388753986e7caa0c65080ec562cf3b5529923557974fa62844a17697671724ea8f69 + checksum: 10c0/5e737a5d9ef1daae1cd93e89870214c1ab0c36a3a2193e898db044bcc5d9de59f85228b2360ec0e8f10cdac7fd2fe3c6ec8a05d943ee7e17d6c1cef2e6e9ff2d languageName: node linkType: hard @@ -3470,7 +3482,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/core@npm:3.7.0, @docusaurus/core@npm:^3.7.0": +"@docusaurus/core@npm:3.7.0, @docusaurus/core@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/core@npm:3.7.0" dependencies: @@ -3538,7 +3550,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/faster@npm:^3.7.0": +"@docusaurus/faster@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/faster@npm:3.7.0" dependencies: @@ -3567,7 +3579,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/logger@npm:3.7.0, @docusaurus/logger@npm:^3.7.0": +"@docusaurus/logger@npm:3.7.0, @docusaurus/logger@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/logger@npm:3.7.0" dependencies: @@ -3577,7 +3589,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/mdx-loader@npm:3.7.0, @docusaurus/mdx-loader@npm:^3.7.0": +"@docusaurus/mdx-loader@npm:3.7.0, @docusaurus/mdx-loader@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/mdx-loader@npm:3.7.0" dependencies: @@ -3612,7 +3624,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/module-type-aliases@npm:3.7.0, @docusaurus/module-type-aliases@npm:^3.7.0": +"@docusaurus/module-type-aliases@npm:3.7.0, @docusaurus/module-type-aliases@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/module-type-aliases@npm:3.7.0" dependencies: @@ -3630,7 +3642,27 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-content-blog@npm:3.7.0, @docusaurus/plugin-content-blog@npm:^3.7.0": +"@docusaurus/plugin-client-redirects@npm:~3.7.0": + version: 3.7.0 + resolution: "@docusaurus/plugin-client-redirects@npm:3.7.0" + dependencies: + "@docusaurus/core": "npm:3.7.0" + "@docusaurus/logger": "npm:3.7.0" + "@docusaurus/utils": "npm:3.7.0" + "@docusaurus/utils-common": "npm:3.7.0" + "@docusaurus/utils-validation": "npm:3.7.0" + eta: "npm:^2.2.0" + fs-extra: "npm:^11.1.1" + lodash: "npm:^4.17.21" + tslib: "npm:^2.6.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + checksum: 10c0/ecdd5061a683541125f14b0f1e5e1afcecefc358bf16e1b71c8e4c66ae8f70f03fd18f00fcbb3525229c8692f8976158eaee1791a68baa7451047d521d619b95 + languageName: node + linkType: hard + +"@docusaurus/plugin-content-blog@npm:3.7.0, @docusaurus/plugin-content-blog@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-content-blog@npm:3.7.0" dependencies: @@ -3660,7 +3692,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-content-docs@npm:3.7.0, @docusaurus/plugin-content-docs@npm:^3.7.0": +"@docusaurus/plugin-content-docs@npm:3.7.0, @docusaurus/plugin-content-docs@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-content-docs@npm:3.7.0" dependencies: @@ -3688,7 +3720,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-content-pages@npm:3.7.0, @docusaurus/plugin-content-pages@npm:^3.7.0": +"@docusaurus/plugin-content-pages@npm:3.7.0, @docusaurus/plugin-content-pages@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-content-pages@npm:3.7.0" dependencies: @@ -3707,7 +3739,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-debug@npm:3.7.0, @docusaurus/plugin-debug@npm:^3.7.0": +"@docusaurus/plugin-debug@npm:3.7.0, @docusaurus/plugin-debug@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-debug@npm:3.7.0" dependencies: @@ -3739,7 +3771,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-google-gtag@npm:3.7.0, @docusaurus/plugin-google-gtag@npm:^3.7.0": +"@docusaurus/plugin-google-gtag@npm:3.7.0, @docusaurus/plugin-google-gtag@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-google-gtag@npm:3.7.0" dependencies: @@ -3755,7 +3787,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-google-tag-manager@npm:3.7.0, @docusaurus/plugin-google-tag-manager@npm:^3.7.0": +"@docusaurus/plugin-google-tag-manager@npm:3.7.0, @docusaurus/plugin-google-tag-manager@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-google-tag-manager@npm:3.7.0" dependencies: @@ -3770,7 +3802,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-sitemap@npm:3.7.0, @docusaurus/plugin-sitemap@npm:^3.7.0": +"@docusaurus/plugin-sitemap@npm:3.7.0, @docusaurus/plugin-sitemap@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/plugin-sitemap@npm:3.7.0" dependencies: @@ -3809,7 +3841,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/preset-classic@npm:^3.7.0": +"@docusaurus/preset-classic@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/preset-classic@npm:3.7.0" dependencies: @@ -3834,7 +3866,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-classic@npm:3.7.0, @docusaurus/theme-classic@npm:^3.7.0": +"@docusaurus/theme-classic@npm:3.7.0, @docusaurus/theme-classic@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/theme-classic@npm:3.7.0" dependencies: @@ -3871,7 +3903,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-common@npm:3.7.0, @docusaurus/theme-common@npm:^3.7.0": +"@docusaurus/theme-common@npm:3.7.0, @docusaurus/theme-common@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/theme-common@npm:3.7.0" dependencies: @@ -3895,7 +3927,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-live-codeblock@npm:^3.7.0": +"@docusaurus/theme-live-codeblock@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/theme-live-codeblock@npm:3.7.0" dependencies: @@ -3915,7 +3947,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-search-algolia@npm:3.7.0, @docusaurus/theme-search-algolia@npm:^3.7.0": +"@docusaurus/theme-search-algolia@npm:3.7.0, @docusaurus/theme-search-algolia@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/theme-search-algolia@npm:3.7.0" dependencies: @@ -3952,14 +3984,14 @@ __metadata: languageName: node linkType: hard -"@docusaurus/tsconfig@npm:^3.7.0": +"@docusaurus/tsconfig@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/tsconfig@npm:3.7.0" checksum: 10c0/22a076fa3cf6da25a76f87fbe5b37c09997f5a8729fdc1a69c0c7955dff9f9850f16dc1de8c6d5096d258a95c428fb8839b252b9dbaa648acb7de8a0e5889dea languageName: node linkType: hard -"@docusaurus/types@npm:3.7.0, @docusaurus/types@npm:^3.7.0": +"@docusaurus/types@npm:3.7.0, @docusaurus/types@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/types@npm:3.7.0" dependencies: @@ -4005,7 +4037,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/utils@npm:3.7.0, @docusaurus/utils@npm:^3.7.0": +"@docusaurus/utils@npm:3.7.0, @docusaurus/utils@npm:~3.7.0": version: 3.7.0 resolution: "@docusaurus/utils@npm:3.7.0" dependencies: @@ -10005,91 +10037,91 @@ __metadata: languageName: node linkType: hard -"@swc/html-darwin-arm64@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-darwin-arm64@npm:1.10.12" +"@swc/html-darwin-arm64@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-darwin-arm64@npm:1.15.13" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@swc/html-darwin-x64@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-darwin-x64@npm:1.10.12" +"@swc/html-darwin-x64@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-darwin-x64@npm:1.15.13" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@swc/html-linux-arm-gnueabihf@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-linux-arm-gnueabihf@npm:1.10.12" +"@swc/html-linux-arm-gnueabihf@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-linux-arm-gnueabihf@npm:1.15.13" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@swc/html-linux-arm64-gnu@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-linux-arm64-gnu@npm:1.10.12" +"@swc/html-linux-arm64-gnu@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-linux-arm64-gnu@npm:1.15.13" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@swc/html-linux-arm64-musl@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-linux-arm64-musl@npm:1.10.12" +"@swc/html-linux-arm64-musl@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-linux-arm64-musl@npm:1.15.13" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@swc/html-linux-x64-gnu@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-linux-x64-gnu@npm:1.10.12" +"@swc/html-linux-x64-gnu@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-linux-x64-gnu@npm:1.15.13" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@swc/html-linux-x64-musl@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-linux-x64-musl@npm:1.10.12" +"@swc/html-linux-x64-musl@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-linux-x64-musl@npm:1.15.13" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@swc/html-win32-arm64-msvc@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-win32-arm64-msvc@npm:1.10.12" +"@swc/html-win32-arm64-msvc@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-win32-arm64-msvc@npm:1.15.13" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@swc/html-win32-ia32-msvc@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-win32-ia32-msvc@npm:1.10.12" +"@swc/html-win32-ia32-msvc@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-win32-ia32-msvc@npm:1.15.13" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@swc/html-win32-x64-msvc@npm:1.10.12": - version: 1.10.12 - resolution: "@swc/html-win32-x64-msvc@npm:1.10.12" +"@swc/html-win32-x64-msvc@npm:1.15.13": + version: 1.15.13 + resolution: "@swc/html-win32-x64-msvc@npm:1.15.13" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "@swc/html@npm:^1.7.39": - version: 1.10.12 - resolution: "@swc/html@npm:1.10.12" + version: 1.15.13 + resolution: "@swc/html@npm:1.15.13" dependencies: "@swc/counter": "npm:^0.1.3" - "@swc/html-darwin-arm64": "npm:1.10.12" - "@swc/html-darwin-x64": "npm:1.10.12" - "@swc/html-linux-arm-gnueabihf": "npm:1.10.12" - "@swc/html-linux-arm64-gnu": "npm:1.10.12" - "@swc/html-linux-arm64-musl": "npm:1.10.12" - "@swc/html-linux-x64-gnu": "npm:1.10.12" - "@swc/html-linux-x64-musl": "npm:1.10.12" - "@swc/html-win32-arm64-msvc": "npm:1.10.12" - "@swc/html-win32-ia32-msvc": "npm:1.10.12" - "@swc/html-win32-x64-msvc": "npm:1.10.12" + "@swc/html-darwin-arm64": "npm:1.15.13" + "@swc/html-darwin-x64": "npm:1.15.13" + "@swc/html-linux-arm-gnueabihf": "npm:1.15.13" + "@swc/html-linux-arm64-gnu": "npm:1.15.13" + "@swc/html-linux-arm64-musl": "npm:1.15.13" + "@swc/html-linux-x64-gnu": "npm:1.15.13" + "@swc/html-linux-x64-musl": "npm:1.15.13" + "@swc/html-win32-arm64-msvc": "npm:1.15.13" + "@swc/html-win32-ia32-msvc": "npm:1.15.13" + "@swc/html-win32-x64-msvc": "npm:1.15.13" dependenciesMeta: "@swc/html-darwin-arm64": optional: true @@ -10111,7 +10143,7 @@ __metadata: optional: true "@swc/html-win32-x64-msvc": optional: true - checksum: 10c0/b7a1f9b932adf4c3f3c9208c9a9d9044d1cccfea4c4b3c6d1a0431210f9fd028a404ed490e447849c3c2cf36a09657d24b981457ba4ba4c436d9ddf3abd63c33 + checksum: 10c0/d312a2ce41c89c42cd54544d89edce0355257b3febe9b164c684da46545f4c1beddb093dd20cc74aab99ee122d36d384ca97f06a973d82f2a501752949a67153 languageName: node linkType: hard @@ -12957,34 +12989,35 @@ __metadata: linkType: hard "algoliasearch-helper@npm:^3.22.6": - version: 3.24.1 - resolution: "algoliasearch-helper@npm:3.24.1" + version: 3.28.0 + resolution: "algoliasearch-helper@npm:3.28.0" dependencies: "@algolia/events": "npm:^4.0.1" peerDependencies: algoliasearch: ">= 3.1 < 6" - checksum: 10c0/b6065ef5404e25f3cb65430f92b7926a7e597be34855eff86a616ae75bfb6d5f524fe8e34dcccde5df617a1eec1c01c20706f53a778d0006337ca40451e773d0 + checksum: 10c0/a354f6a5074dd5c548ef112f5aec51720cdb0f28ad6144f4d6d9c8829f934722b55f1aa34dfb261a7247330fd7a0de1b6028846a08419b6ca4090456ab0d57fb languageName: node linkType: hard "algoliasearch@npm:^5.14.2, algoliasearch@npm:^5.17.1": - version: 5.20.0 - resolution: "algoliasearch@npm:5.20.0" - dependencies: - "@algolia/client-abtesting": "npm:5.20.0" - "@algolia/client-analytics": "npm:5.20.0" - "@algolia/client-common": "npm:5.20.0" - "@algolia/client-insights": "npm:5.20.0" - "@algolia/client-personalization": "npm:5.20.0" - "@algolia/client-query-suggestions": "npm:5.20.0" - "@algolia/client-search": "npm:5.20.0" - "@algolia/ingestion": "npm:1.20.0" - "@algolia/monitoring": "npm:1.20.0" - "@algolia/recommend": "npm:5.20.0" - "@algolia/requester-browser-xhr": "npm:5.20.0" - "@algolia/requester-fetch": "npm:5.20.0" - "@algolia/requester-node-http": "npm:5.20.0" - checksum: 10c0/34bbe5ea83b62ea7604fd50ef61d9225cfa1bf5b1bf064500c46dddbebad922d38dfb7fd7c531591ada113879ed81c3896912a561012b9e1c1b1ae3ec68b6edf + version: 5.49.1 + resolution: "algoliasearch@npm:5.49.1" + dependencies: + "@algolia/abtesting": "npm:1.15.1" + "@algolia/client-abtesting": "npm:5.49.1" + "@algolia/client-analytics": "npm:5.49.1" + "@algolia/client-common": "npm:5.49.1" + "@algolia/client-insights": "npm:5.49.1" + "@algolia/client-personalization": "npm:5.49.1" + "@algolia/client-query-suggestions": "npm:5.49.1" + "@algolia/client-search": "npm:5.49.1" + "@algolia/ingestion": "npm:1.49.1" + "@algolia/monitoring": "npm:1.49.1" + "@algolia/recommend": "npm:5.49.1" + "@algolia/requester-browser-xhr": "npm:5.49.1" + "@algolia/requester-fetch": "npm:5.49.1" + "@algolia/requester-node-http": "npm:5.49.1" + checksum: 10c0/8bc2ee9f321b4c758427342bddd9706d9944128644ebe806a8a79cfe924ba2ce3be90ad4d9d234eedf3ef3e33710eda8f9c7b38290e95182a0aa7323d0003e59 languageName: node linkType: hard @@ -15154,9 +15187,9 @@ __metadata: linkType: hard "caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001616, caniuse-lite@npm:^1.0.30001646, caniuse-lite@npm:^1.0.30001669, caniuse-lite@npm:^1.0.30001726": - version: 1.0.30001762 - resolution: "caniuse-lite@npm:1.0.30001762" - checksum: 10c0/93707eac5b0240af3f2ce6e2d7ab504a6fefcf9c2f9cd8fb9d488e496a333c61e557dab0472c1b00c17bc386a5dbb792aa4c778cda2d768e17f986617d7aec53 + version: 1.0.30001774 + resolution: "caniuse-lite@npm:1.0.30001774" + checksum: 10c0/cc6a340a5421b9a67d8fa80889065ee27b2839ad62993571dded5296e18f02bbf685ce7094e93fe908cddc9fefdfad35d6c010b724cc3d22a6479b0d0b679f8c languageName: node linkType: hard @@ -16217,9 +16250,9 @@ __metadata: linkType: hard "copy-text-to-clipboard@npm:^3.2.0": - version: 3.2.0 - resolution: "copy-text-to-clipboard@npm:3.2.0" - checksum: 10c0/d60fdadc59d526e19d56ad23cec2b292d33c771a5091621bd322d138804edd3c10eb2367d46ec71b39f5f7f7116a2910b332281aeb36a5b679199d746a8a5381 + version: 3.2.2 + resolution: "copy-text-to-clipboard@npm:3.2.2" + checksum: 10c0/451796734a380f7da7b0af27c4d94e449719d3a2f2170e99c7e46eeee54cf3c4b4fdeabc185f6d6e8cbdf932e350831d05e8387c4bae8dcedb7fb961c600ddd4 languageName: node linkType: hard @@ -18005,24 +18038,25 @@ __metadata: "@coinbase/docusaurus-plugin-docgen": "workspace:^" "@coinbase/docusaurus-plugin-kbar": "workspace:^" "@coinbase/docusaurus-plugin-llm-dev-server": "workspace:^" - "@docusaurus/core": "npm:^3.7.0" - "@docusaurus/faster": "npm:^3.7.0" - "@docusaurus/mdx-loader": "npm:^3.7.0" - "@docusaurus/module-type-aliases": "npm:^3.7.0" - "@docusaurus/plugin-content-blog": "npm:^3.7.0" - "@docusaurus/plugin-content-docs": "npm:^3.7.0" - "@docusaurus/plugin-content-pages": "npm:^3.7.0" - "@docusaurus/plugin-debug": "npm:^3.7.0" - "@docusaurus/plugin-google-gtag": "npm:^3.7.0" - "@docusaurus/plugin-google-tag-manager": "npm:^3.7.0" - "@docusaurus/plugin-sitemap": "npm:^3.7.0" - "@docusaurus/preset-classic": "npm:^3.7.0" - "@docusaurus/theme-classic": "npm:^3.7.0" - "@docusaurus/theme-common": "npm:^3.7.0" - "@docusaurus/theme-live-codeblock": "npm:^3.7.0" - "@docusaurus/theme-search-algolia": "npm:^3.7.0" - "@docusaurus/tsconfig": "npm:^3.7.0" - "@docusaurus/types": "npm:^3.7.0" + "@docusaurus/core": "npm:~3.7.0" + "@docusaurus/faster": "npm:~3.7.0" + "@docusaurus/mdx-loader": "npm:~3.7.0" + "@docusaurus/module-type-aliases": "npm:~3.7.0" + "@docusaurus/plugin-client-redirects": "npm:~3.7.0" + "@docusaurus/plugin-content-blog": "npm:~3.7.0" + "@docusaurus/plugin-content-docs": "npm:~3.7.0" + "@docusaurus/plugin-content-pages": "npm:~3.7.0" + "@docusaurus/plugin-debug": "npm:~3.7.0" + "@docusaurus/plugin-google-gtag": "npm:~3.7.0" + "@docusaurus/plugin-google-tag-manager": "npm:~3.7.0" + "@docusaurus/plugin-sitemap": "npm:~3.7.0" + "@docusaurus/preset-classic": "npm:~3.7.0" + "@docusaurus/theme-classic": "npm:~3.7.0" + "@docusaurus/theme-common": "npm:~3.7.0" + "@docusaurus/theme-live-codeblock": "npm:~3.7.0" + "@docusaurus/theme-search-algolia": "npm:~3.7.0" + "@docusaurus/tsconfig": "npm:~3.7.0" + "@docusaurus/types": "npm:~3.7.0" "@linaria/babel-preset": "npm:^3.0.0-beta.22" "@linaria/core": "npm:^3.0.0-beta.22" "@linaria/webpack-loader": "npm:^3.0.0-beta.22" From 71b36359453ce5579242cbfb4c4bb4bd36cf0a58 Mon Sep 17 00:00:00 2001 From: Stacy <147009016+stacysun-cb@users.noreply.github.com> Date: Wed, 25 Feb 2026 16:59:58 -0800 Subject: [PATCH 09/12] chore: deprecate useStatusBarHeight hook (#445) * deprecate useStatusBarHeight hook * release --- packages/common/CHANGELOG.md | 4 ++++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 ++++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 6 ++++++ packages/mobile/package.json | 2 +- packages/mobile/src/hooks/useStatusBarHeight.ts | 14 ++++++++++++-- packages/web/CHANGELOG.md | 4 ++++ packages/web/package.json | 2 +- 9 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 43efbb15d4..0d5c1483b0 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index fe0d49f35e..60dff58e55 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.48.1", + "version": "8.48.2", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index 38e14279f3..f655fe47a0 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 3be2c9e89d..3910f010eb 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.48.1", + "version": "8.48.2", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 83a10d6c97..5d76b14517 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index cf8170ca33..658735f01e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.48.1", + "version": "8.48.2", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/mobile/src/hooks/useStatusBarHeight.ts b/packages/mobile/src/hooks/useStatusBarHeight.ts index 0b47f88dfa..ac690b95be 100644 --- a/packages/mobile/src/hooks/useStatusBarHeight.ts +++ b/packages/mobile/src/hooks/useStatusBarHeight.ts @@ -9,8 +9,18 @@ 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 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/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 8d5aa7743a..4ada9b6333 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/web/package.json b/packages/web/package.json index d1ceea96f9..37e25c209f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.48.1", + "version": "8.48.2", "description": "Coinbase Design System - Web", "repository": { "type": "git", From d02fa3591557ddb9871a51120039fe37b780b503 Mon Sep 17 00:00:00 2001 From: Maximo Macchi <232606069+maximo-macchi-cb@users.noreply.github.com> Date: Thu, 26 Feb 2026 08:01:59 -0600 Subject: [PATCH 10/12] chore: Bump packages for security request (#422) * Bump package * Revert "Bump package" This reverts commit 4a733d60e4ff574b623baf4b07c42552455b72da. * Use resolution instead * Add resolutions to other packages * Add resolutionComments * Revert to ajv v6 to support eslint * Update dependencies to bump eslint and only resolve ajv V6 * Lower to next patch version * Remove eslint package bump and only resolve ajv * Dedupe dependencies --- package.json | 10 ++- yarn.lock | 196 +++++++++++++++++++++++++++------------------------ 2 files changed, 111 insertions(+), 95 deletions(-) diff --git a/package.json b/package.json index c998c73387..c794838cd9 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,10 @@ "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" }, "resolutionComments": { "@testing-library/user-event@^14.0.4": "Create subpath export for types.", @@ -87,7 +90,10 @@ "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" }, "workspaces": [ "actions/*", diff --git a/yarn.lock b/yarn.lock index b0dd638992..d02ca98e00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4505,89 +4505,91 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0": - version: 4.7.0 - resolution: "@eslint-community/eslint-utils@npm:4.7.0" +"@eslint-community/eslint-utils@npm:^4.4.0, @eslint-community/eslint-utils@npm:^4.7.0, @eslint-community/eslint-utils@npm:^4.8.0": + version: 4.9.1 + resolution: "@eslint-community/eslint-utils@npm:4.9.1" dependencies: eslint-visitor-keys: "npm:^3.4.3" peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/c0f4f2bd73b7b7a9de74b716a664873d08ab71ab439e51befe77d61915af41a81ecec93b408778b3a7856185244c34c2c8ee28912072ec14def84ba2dec70adf + checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 languageName: node linkType: hard "@eslint-community/regexpp@npm:^4.10.0, @eslint-community/regexpp@npm:^4.12.1": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 + version: 4.12.2 + resolution: "@eslint-community/regexpp@npm:4.12.2" + checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.2": - version: 0.19.2 - resolution: "@eslint/config-array@npm:0.19.2" +"@eslint/config-array@npm:^0.21.1": + version: 0.21.1 + resolution: "@eslint/config-array@npm:0.21.1" dependencies: - "@eslint/object-schema": "npm:^2.1.6" + "@eslint/object-schema": "npm:^2.1.7" debug: "npm:^4.3.1" minimatch: "npm:^3.1.2" - checksum: 10c0/dd68da9abb32d336233ac4fe0db1e15a0a8d794b6e69abb9e57545d746a97f6f542496ff9db0d7e27fab1438546250d810d90b1904ac67677215b8d8e7573f3d + checksum: 10c0/2f657d4edd6ddcb920579b72e7a5b127865d4c3fb4dda24f11d5c4f445a93ca481aebdbd6bf3291c536f5d034458dbcbb298ee3b698bc6c9dd02900fe87eec3c languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.1.0": - version: 0.1.0 - resolution: "@eslint/config-helpers@npm:0.1.0" - checksum: 10c0/3562b5325f42740fc83b0b92b7d13a61b383f8db064915143eec36184f09a09fad73eca6c2955ab6c248b0d04fa03c140f9af2f2c4c06770781a6b79f300a01e +"@eslint/config-helpers@npm:^0.4.2": + version: 0.4.2 + resolution: "@eslint/config-helpers@npm:0.4.2" + dependencies: + "@eslint/core": "npm:^0.17.0" + checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 languageName: node linkType: hard -"@eslint/core@npm:^0.12.0": - version: 0.12.0 - resolution: "@eslint/core@npm:0.12.0" +"@eslint/core@npm:^0.17.0": + version: 0.17.0 + resolution: "@eslint/core@npm:0.17.0" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/d032af81195bb28dd800c2b9617548c6c2a09b9490da3c5537fd2a1201501666d06492278bb92cfccac1f7ac249e58601dd87f813ec0d6a423ef0880434fa0c3 + checksum: 10c0/9a580f2246633bc752298e7440dd942ec421860d1946d0801f0423830e67887e4aeba10ab9a23d281727a978eb93d053d1922a587d502942a713607f40ed704e languageName: node linkType: hard -"@eslint/eslintrc@npm:^3.3.0": - version: 3.3.0 - resolution: "@eslint/eslintrc@npm:3.3.0" +"@eslint/eslintrc@npm:^3.3.1": + version: 3.3.4 + resolution: "@eslint/eslintrc@npm:3.3.4" dependencies: - ajv: "npm:^6.12.4" + ajv: "npm:^6.14.0" debug: "npm:^4.3.2" espree: "npm:^10.0.1" globals: "npm:^14.0.0" ignore: "npm:^5.2.0" import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.0" - minimatch: "npm:^3.1.2" + js-yaml: "npm:^4.1.1" + minimatch: "npm:^3.1.3" strip-json-comments: "npm:^3.1.1" - checksum: 10c0/215de990231b31e2fe6458f225d8cea0f5c781d3ecb0b7920703501f8cd21b3101fc5ef2f0d4f9a38865d36647b983e0e8ce8bf12fd2bcdd227fc48a5b1a43be + checksum: 10c0/1fe481a6af03c09be8d92d67e2bbf693b7522b0591934bfb44bd13e297649b13e4ec5e3fc70b02e4497a17c1afbfa22f5bf5efa4fc06a24abace8e5d097fec8c languageName: node linkType: hard -"@eslint/js@npm:9.22.0": - version: 9.22.0 - resolution: "@eslint/js@npm:9.22.0" - checksum: 10c0/5bcd009bb579dc6c6ed760703bdd741e08a48cd9decd677aa2cf67fe66236658cb09a00185a0369f3904e5cffba9e6e0f2ff4d9ba4fdf598fcd81d34c49213a5 +"@eslint/js@npm:9.39.3": + version: 9.39.3 + resolution: "@eslint/js@npm:9.39.3" + checksum: 10c0/df1c70d6681c8daf4a3c86dfac159fcd98a73c4620c4fbe2be6caab1f30a34c7de0ad88ab0e81162376d2cde1a2eed1c32eff5f917ca369870930a51f8e818f1 languageName: node linkType: hard -"@eslint/object-schema@npm:^2.1.6": - version: 2.1.6 - resolution: "@eslint/object-schema@npm:2.1.6" - checksum: 10c0/b8cdb7edea5bc5f6a96173f8d768d3554a628327af536da2fc6967a93b040f2557114d98dbcdbf389d5a7b290985ad6a9ce5babc547f36fc1fde42e674d11a56 +"@eslint/object-schema@npm:^2.1.7": + version: 2.1.7 + resolution: "@eslint/object-schema@npm:2.1.7" + checksum: 10c0/936b6e499853d1335803f556d526c86f5fe2259ed241bc665000e1d6353828edd913feed43120d150adb75570cae162cf000b5b0dfc9596726761c36b82f4e87 languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.7": - version: 0.2.7 - resolution: "@eslint/plugin-kit@npm:0.2.7" +"@eslint/plugin-kit@npm:^0.4.1": + version: 0.4.1 + resolution: "@eslint/plugin-kit@npm:0.4.1" dependencies: - "@eslint/core": "npm:^0.12.0" + "@eslint/core": "npm:^0.17.0" levn: "npm:^0.4.1" - checksum: 10c0/0a1aff1ad63e72aca923217e556c6dfd67d7cd121870eb7686355d7d1475d569773528a8b2111b9176f3d91d2ea81f7413c34600e8e5b73d59e005d70780b633 + checksum: 10c0/51600f78b798f172a9915dffb295e2ffb44840d583427bc732baf12ecb963eb841b253300e657da91d890f4b323d10a1bd12934bf293e3018d8bb66fdce5217b languageName: node linkType: hard @@ -12964,27 +12966,27 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.1.0, ajv@npm:^6.10.2, ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.12.6": - version: 6.12.6 - resolution: "ajv@npm:6.12.6" +"ajv@npm:^6.1.0, ajv@npm:^6.10.2, ajv@npm:^6.12.2, ajv@npm:^6.12.4, ajv@npm:^6.12.5, ajv@npm:^6.12.6, ajv@npm:^6.14.0": + version: 6.14.0 + resolution: "ajv@npm:6.14.0" dependencies: fast-deep-equal: "npm:^3.1.1" fast-json-stable-stringify: "npm:^2.0.0" json-schema-traverse: "npm:^0.4.1" uri-js: "npm:^4.2.2" - checksum: 10c0/41e23642cbe545889245b9d2a45854ebba51cda6c778ebced9649420d9205f2efb39cb43dbc41e358409223b1ea43303ae4839db682c848b891e4811da1a5a71 + checksum: 10c0/a2bc39b0555dc9802c899f86990eb8eed6e366cddbf65be43d5aa7e4f3c4e1a199d5460fd7ca4fb3d864000dbbc049253b72faa83b3b30e641ca52cb29a68c22 languageName: node linkType: hard "ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.11.0, ajv@npm:^8.6.2, ajv@npm:^8.6.3, ajv@npm:^8.9.0": - version: 8.17.1 - resolution: "ajv@npm:8.17.1" + version: 8.18.0 + resolution: "ajv@npm:8.18.0" dependencies: fast-deep-equal: "npm:^3.1.3" fast-uri: "npm:^3.0.1" json-schema-traverse: "npm:^1.0.0" require-from-string: "npm:^2.0.2" - checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + checksum: 10c0/e7517c426173513a07391be951879932bdf3348feaebd2199f5b901c20f99d60db8cd1591502d4d551dc82f594e82a05c4fe1c70139b15b8937f7afeaed9532f languageName: node linkType: hard @@ -18436,9 +18438,9 @@ __metadata: languageName: node linkType: hard -"elliptic@npm:^6.5.3": - version: 6.5.4 - resolution: "elliptic@npm:6.5.4" +"elliptic@npm:^6.6.0": + version: 6.6.1 + resolution: "elliptic@npm:6.6.1" dependencies: bn.js: "npm:^4.11.9" brorand: "npm:^1.1.0" @@ -18447,7 +18449,7 @@ __metadata: inherits: "npm:^2.0.4" minimalistic-assert: "npm:^1.0.1" minimalistic-crypto-utils: "npm:^1.0.1" - checksum: 10c0/5f361270292c3b27cf0843e84526d11dec31652f03c2763c6c2b8178548175ff5eba95341dd62baff92b2265d1af076526915d8af6cc9cb7559c44a62f8ca6e2 + checksum: 10c0/8b24ef782eec8b472053793ea1e91ae6bee41afffdfcb78a81c0a53b191e715cbe1292aa07165958a9bbe675bd0955142560b1a007ffce7d6c765bcaf951a867 languageName: node linkType: hard @@ -19488,13 +19490,13 @@ __metadata: languageName: node linkType: hard -"eslint-scope@npm:^8.3.0": - version: 8.3.0 - resolution: "eslint-scope@npm:8.3.0" +"eslint-scope@npm:^8.4.0": + version: 8.4.0 + resolution: "eslint-scope@npm:8.4.0" dependencies: esrecurse: "npm:^4.3.0" estraverse: "npm:^5.2.0" - checksum: 10c0/23bf54345573201fdf06d29efa345ab508b355492f6c6cc9e2b9f6d02b896f369b6dd5315205be94b8853809776c4d13353b85c6b531997b164ff6c3328ecf5b + checksum: 10c0/407f6c600204d0f3705bd557f81bd0189e69cd7996f408f8971ab5779c0af733d1af2f1412066b40ee1588b085874fc37a2333986c6521669cdbdd36ca5058e0 languageName: node linkType: hard @@ -19531,30 +19533,29 @@ __metadata: linkType: hard "eslint@npm:^9.22.0": - version: 9.22.0 - resolution: "eslint@npm:9.22.0" + version: 9.39.3 + resolution: "eslint@npm:9.39.3" dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" + "@eslint-community/eslint-utils": "npm:^4.8.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.2" - "@eslint/config-helpers": "npm:^0.1.0" - "@eslint/core": "npm:^0.12.0" - "@eslint/eslintrc": "npm:^3.3.0" - "@eslint/js": "npm:9.22.0" - "@eslint/plugin-kit": "npm:^0.2.7" + "@eslint/config-array": "npm:^0.21.1" + "@eslint/config-helpers": "npm:^0.4.2" + "@eslint/core": "npm:^0.17.0" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:9.39.3" + "@eslint/plugin-kit": "npm:^0.4.1" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" "@types/estree": "npm:^1.0.6" - "@types/json-schema": "npm:^7.0.15" ajv: "npm:^6.12.4" chalk: "npm:^4.0.0" cross-spawn: "npm:^7.0.6" debug: "npm:^4.3.2" escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.3.0" - eslint-visitor-keys: "npm:^4.2.0" - espree: "npm:^10.3.0" + eslint-scope: "npm:^8.4.0" + eslint-visitor-keys: "npm:^4.2.1" + espree: "npm:^10.4.0" esquery: "npm:^1.5.0" esutils: "npm:^2.0.2" fast-deep-equal: "npm:^3.1.3" @@ -19576,18 +19577,18 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/7b5ab6f2365971c16efe97349565f75d8343347562fb23f12734c6ab2cd5e35301373a0d51e194789ddcfdfca21db7b62ff481b03d524b8169896c305b65ff48 + checksum: 10c0/5e5dbf84d4f604f5d2d7a58c5c3fcdde30a01b8973ff3caeca8b2bacc16066717cedb4385ce52db1a2746d0b621770d4d4227cc7f44982b0b03818be2c31538d languageName: node linkType: hard -"espree@npm:^10.0.1, espree@npm:^10.3.0": - version: 10.3.0 - resolution: "espree@npm:10.3.0" +"espree@npm:^10.0.1, espree@npm:^10.4.0": + version: 10.4.0 + resolution: "espree@npm:10.4.0" dependencies: - acorn: "npm:^8.14.0" + acorn: "npm:^8.15.0" acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/272beeaca70d0a1a047d61baff64db04664a33d7cfb5d144f84bc8a5c6194c6c8ebe9cc594093ca53add88baa23e59b01e69e8a0160ab32eac570482e165c462 + eslint-visitor-keys: "npm:^4.2.1" + checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b languageName: node linkType: hard @@ -19602,11 +19603,11 @@ __metadata: linkType: hard "esquery@npm:^1.4.0, esquery@npm:^1.5.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" + version: 1.7.0 + resolution: "esquery@npm:1.7.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 + checksum: 10c0/77d5173db450b66f3bc685d11af4c90cffeedb340f34a39af96d43509a335ce39c894fd79233df32d38f5e4e219fa0f7076f6ec90bae8320170ba082c0db4793 languageName: node linkType: hard @@ -20450,9 +20451,9 @@ __metadata: linkType: hard "fast-uri@npm:^3.0.1": - version: 3.0.1 - resolution: "fast-uri@npm:3.0.1" - checksum: 10c0/3cd46d6006083b14ca61ffe9a05b8eef75ef87e9574b6f68f2e17ecf4daa7aaadeff44e3f0f7a0ef4e0f7e7c20fc07beec49ff14dc72d0b500f00386592f2d10 + version: 3.1.0 + resolution: "fast-uri@npm:3.1.0" + checksum: 10c0/44364adca566f70f40d1e9b772c923138d47efeac2ae9732a872baafd77061f26b097ba2f68f0892885ad177becd065520412b8ffeec34b16c99433c5b9e2de7 languageName: node linkType: hard @@ -23396,10 +23397,10 @@ __metadata: languageName: node linkType: hard -"ip@npm:^2.0.0": - version: 2.0.0 - resolution: "ip@npm:2.0.0" - checksum: 10c0/8d186cc5585f57372847ae29b6eba258c68862055e18a75cc4933327232cb5c107f89800ce29715d542eef2c254fbb68b382e780a7414f9ee7caf60b7a473958 +"ip@npm:^2.0.1": + version: 2.0.1 + resolution: "ip@npm:2.0.1" + checksum: 10c0/cab8eb3e88d0abe23e4724829621ec4c4c5cb41a7f936a2e626c947128c1be16ed543448d42af7cca95379f9892bfcacc1ccd8d09bc7e8bea0e86d492ce33616 languageName: node linkType: hard @@ -25232,14 +25233,14 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.0": - version: 4.1.0 - resolution: "js-yaml@npm:4.1.0" +"js-yaml@npm:^4.1.0, js-yaml@npm:^4.1.1": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" dependencies: argparse: "npm:^2.0.1" bin: js-yaml: bin/js-yaml.js - checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 languageName: node linkType: hard @@ -28363,12 +28364,12 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:2 || 3, minimatch@npm:3.1.2, minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": - version: 3.1.2 - resolution: "minimatch@npm:3.1.2" +"minimatch@npm:2 || 3, minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2, minimatch@npm:^3.1.3": + version: 3.1.5 + resolution: "minimatch@npm:3.1.5" dependencies: brace-expansion: "npm:^1.1.7" - checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 languageName: node linkType: hard @@ -28381,6 +28382,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:3.1.2": + version: 3.1.2 + resolution: "minimatch@npm:3.1.2" + dependencies: + brace-expansion: "npm:^1.1.7" + checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311 + languageName: node + linkType: hard + "minimatch@npm:9.0.3": version: 9.0.3 resolution: "minimatch@npm:9.0.3" From 6f276e62522fe57548795eea2682059353703b56 Mon Sep 17 00:00:00 2001 From: Brian Kasper Date: Thu, 26 Feb 2026 10:33:08 -0500 Subject: [PATCH 11/12] fix: allow arrow up/down keys within focus trapped text area (#417) * fix: allow arrow up/down keys within focus trapped text area * chore: bump version + update changelog * Bump changelogs * Fix merge conflict --------- Co-authored-by: Maximo Macchi <232606069+maximo-macchi-cb@users.noreply.github.com> Co-authored-by: Harry --- packages/common/CHANGELOG.md | 4 +++ packages/common/package.json | 2 +- packages/mcp-server/CHANGELOG.md | 4 +++ packages/mcp-server/package.json | 2 +- packages/mobile/CHANGELOG.md | 4 +++ packages/mobile/package.json | 2 +- packages/web/CHANGELOG.md | 6 ++++ packages/web/package.json | 2 +- packages/web/src/overlays/FocusTrap.tsx | 7 +++++ .../__stories__/FocusTrap.stories.tsx | 17 +++++++++++ .../src/overlays/__tests__/FocusTrap.test.tsx | 28 +++++++++++++++++++ 11 files changed, 74 insertions(+), 4 deletions(-) diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 0d5c1483b0..7c4dc49d2b 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/common/package.json b/packages/common/package.json index 60dff58e55..3f6b9cbd29 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-common", - "version": "8.48.2", + "version": "8.48.3", "description": "Coinbase Design System - Common", "repository": { "type": "git", diff --git a/packages/mcp-server/CHANGELOG.md b/packages/mcp-server/CHANGELOG.md index f655fe47a0..75c8b42529 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 3910f010eb..e5b4bb2d64 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mcp-server", - "version": "8.48.2", + "version": "8.48.3", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index 5d76b14517..28d5b3cac4 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,10 @@ All notable changes to this project will be documented in this file. +## 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 diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 658735f01e..b5aee4161c 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-mobile", - "version": "8.48.2", + "version": "8.48.3", "description": "Coinbase Design System - Mobile", "repository": { "type": "git", diff --git a/packages/web/CHANGELOG.md b/packages/web/CHANGELOG.md index 4ada9b6333..c61c26ad28 100644 --- a/packages/web/CHANGELOG.md +++ b/packages/web/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to this project will be documented in this file. +## 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. diff --git a/packages/web/package.json b/packages/web/package.json index 37e25c209f..3c30154334 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@coinbase/cds-web", - "version": "8.48.2", + "version": "8.48.3", "description": "Coinbase Design System - Web", "repository": { "type": "git", diff --git a/packages/web/src/overlays/FocusTrap.tsx b/packages/web/src/overlays/FocusTrap.tsx index 1f15334eef..079a5edf64 100644 --- a/packages/web/src/overlays/FocusTrap.tsx +++ b/packages/web/src/overlays/FocusTrap.tsx @@ -140,6 +140,13 @@ export const FocusTrap = memo(function FocusTrap({ if (!element || !document) return; + const textAreas = element.querySelectorAll('textarea'); + const activeElementIsTextArea = + activeElement && Array.from(textAreas).includes(activeElement as HTMLTextAreaElement); + if (activeElementIsTextArea && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { + return; + } + let focusableElements = Array.from( element.querySelectorAll( focusTabIndexElements ? FOCUSABLE_ELEMENTS_INCLUDING_TABINDEX : FOCUSABLE_ELEMENTS, diff --git a/packages/web/src/overlays/__stories__/FocusTrap.stories.tsx b/packages/web/src/overlays/__stories__/FocusTrap.stories.tsx index 9bcf937130..f47d61a66d 100644 --- a/packages/web/src/overlays/__stories__/FocusTrap.stories.tsx +++ b/packages/web/src/overlays/__stories__/FocusTrap.stories.tsx @@ -1,4 +1,5 @@ import { Button } from '../../buttons'; +import { NativeTextArea } from '../../controls/NativeTextArea'; import { TextInput } from '../../controls/TextInput'; import { VStack } from '../../layout/VStack'; import { Text } from '../../typography/Text'; @@ -46,3 +47,19 @@ export const SingleFocusableChild = () => { ); }; + +export const TextArea = () => { + return ( + + + + Up/Down arrow keys should work + + + + + + + + ); +}; diff --git a/packages/web/src/overlays/__tests__/FocusTrap.test.tsx b/packages/web/src/overlays/__tests__/FocusTrap.test.tsx index b709ffe426..3dbac8ed5c 100644 --- a/packages/web/src/overlays/__tests__/FocusTrap.test.tsx +++ b/packages/web/src/overlays/__tests__/FocusTrap.test.tsx @@ -183,4 +183,32 @@ describe('FocusTrap', () => { fireEvent.keyDown(screen.getByTestId('first'), { key: 'Tab', code: 'Tab', shiftKey: true }); expect(trigger).toHaveFocus(); }); + + it('allows up/down arrow key navigation in textareas', async () => { + const user = userEvent.setup(); + render( + + +
+