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/.gitignore b/.gitignore index 941e9c7306..7677621ca0 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,8 @@ cds-biweekly-update.md missing-files.json barrel-files.md base-props.json +component-peer-dependencies.md +component-peer-dependencies.json # temp directory created when running podium scripts. this gets deleted after the script is done, but for the sake of your sanity, let's not track it **/.podium/ diff --git a/.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/babel.config.cjs b/apps/docs/babel.config.cjs index 453d0e9668..ff2ed3a4ef 100644 --- a/apps/docs/babel.config.cjs +++ b/apps/docs/babel.config.cjs @@ -1,5 +1,9 @@ const docusaurusPreset = require('@docusaurus/core/lib/babel/preset'); +const isTestEnv = process.env.NODE_ENV === 'test'; + module.exports = { - presets: [docusaurusPreset], + presets: isTestEnv + ? [['@babel/preset-env', { modules: 'commonjs' }], '@babel/preset-typescript'] + : [docusaurusPreset], }; diff --git a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json index 7db05149a3..1ba88778e5 100644 --- a/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json +++ b/apps/docs/docs/components/animation/LottieStatusAnimation/mobileMetadata.json @@ -8,5 +8,10 @@ "url": "/components/animation/Lottie" } ], - "dependencies": [] + "dependencies": [ + { + "name": "lottie-react-native", + "version": "^6.7.0" + } + ] } diff --git a/apps/docs/docs/components/cards/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/cards/NudgeCard/mobileMetadata.json b/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json index 5115a3e343..ee33ef4ad1 100644 --- a/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json +++ b/apps/docs/docs/components/cards/NudgeCard/mobileMetadata.json @@ -18,5 +18,10 @@ "url": "/components/cards/ContentCard/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx new file mode 100644 index 0000000000..b3cf17b575 --- /dev/null +++ b/apps/docs/docs/components/charts/AreaChart/_mobileExamples.mdx @@ -0,0 +1,605 @@ +AreaChart is a cartesian chart variant that allows for easy visualization of stacked data. + +## Basic Example + +```jsx + +``` + +## Simple + +```jsx + +``` + +## Stacking + +You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/charts/CartesianChart/#series-stacks) for more details. + +```jsx +function StackingExample() { + const theme = useTheme(); + return ( + } + type="dotted" + /> + ); +} +``` + +## Negative Values + +When an area chart contains negative values, the baseline automatically adjusts to zero instead of the bottom of the chart. The area fills from the data line to the zero baseline, properly showing both positive and negative regions. + +```jsx + } + showYAxis + yAxis={{ + showGrid: true, + }} +/> +``` + +## Area Styles + +You can have different area styles for each series. + +```jsx + +``` + +## 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/_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 97% rename from apps/docs/docs/components/graphs/AreaChart/_webExamples.mdx rename to apps/docs/docs/components/charts/AreaChart/_webExamples.mdx index 2eb5751cb3..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 +``` + +## Multiple Series + +You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. + +```tsx +function MonthlyGainsByAsset() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount), + [], + ); + + return ( + + ); +} +``` + +## Series Stacking + +You can also configure stacking for your chart using the `stacked` prop. + +```tsx +function MonthlyGainsByAsset() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount), + [], + ); + + return ( + + ); +} +``` + +You can also configure multiple stacks by setting the `stackId` prop on each series. + +```tsx +function MonthlyGainsMultipleStacks() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount), + [], + ); + + return ( + + ); +} +``` + +### Stack Gap + +```tsx +function MonthlyGainsByAsset() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const tickFormatter = useCallback( + (amount) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount), + [], + ); + + return ( + + ); +} +``` + +## Border Radius + +Bars have a default border radius of `100`. You can change this by setting the `borderRadius` prop on the chart. + +Stacks will only round the top corners of touching bars. + +```jsx + { + if (value === 'D') { + return {value}; + } + return value; + }, + }} + style={{ margin: '0 auto' }} +/> +``` + +### Round Baseline + +You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. + +```jsx + { + if (value === 'D') { + return {value}; + } + return value; + }, + }} + style={{ margin: '0 auto' }} +/> +``` + +## Data + +### Negative + +```tsx +function PositiveAndNegativeCashFlow() { + const ThinSolidLine = memo((props: SolidLineProps) => ); + + const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); + const gains = [ + 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, + 0, 0, 0, + ]; + + const losses = [ + -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, + 0, 0, 0, -12, -10, + ]; + const series = [ + { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, + { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, + ]; + + return ( + `$${value}M`, + }} + /> + ); +} +``` + +### Null + +You can pass in `null` or `0` values to not render a bar for that data point. + +```jsx + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} +/> +``` + +You can also use the `BarStackComponent` prop to render an empty circle for zero values. + +```tsx +function MonthlyRewards() { + const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + const currentMonth = 7; + const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; + const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; + const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; + const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; + + const series = [ + { id: 'purple', data: purple, color: '#b399ff' }, + { id: 'blue', data: blue, color: '#4f7cff' }, + { id: 'cyan', data: cyan, color: '#00c2df' }, + { id: 'green', data: green, color: '#33c481' }, + ]; + + const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { + if (props.height === 0) { + const diameter = props.width; + return ( + + ); + } + + return {children}; + }; + + return ( + { + if (index == currentMonth) { + return {months[index]}; + } + return months[index]; + }, + categoryPadding: 0.27, + }} + /> + ); +} +``` + +### Range + +You can pass in `[min, max]` tuples as data points to render bars that span a range of values. + +```tsx +function PriceRange() { + const candles = btcCandles.slice(0, 180).reverse(); + const data = candles.map((candle) => [parseFloat(candle.low), parseFloat(candle.high)]); + + const min = Math.min(...data.map(([low]) => low)); + const max = Math.max(...data.map(([, high]) => high)); + + const tickFormatter = useCallback( + (value: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + notation: 'compact', + maximumFractionDigits: 0, + }).format(value), + [], + ); + + return ( + + ); +} +``` + +## Customization + +### Bar Spacing + +There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. + +```jsx + +``` + +### Minimum Size + +To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. +It is recommended to only use `stackMinSize` for stacked charts and `barMinSize` for non-stacked charts. + +#### Minimum Stack Size + +You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. + +```jsx + +``` + +#### Minimum Bar Size + +You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. + +```jsx + `$${value}k`, + showGrid: true, + showTickMarks: true, + showLine: true, + tickMarkSize: 1.5, + domain: { max: 50 }, + }} + barMinSize={4} +/> +``` + +### Multiple Y Axes + +You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stacked. + +```jsx +function MultipleYAxes() { + const theme = useTheme(); + + return ( + + + `$${value}k`} + /> + `${value}%`} + /> + + + ); +} +``` + +## 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/_mobilePropsTable.mdx b/apps/docs/docs/components/charts/BarChart/_mobilePropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/_mobilePropsTable.mdx rename to apps/docs/docs/components/charts/BarChart/_mobilePropsTable.mdx diff --git a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx b/apps/docs/docs/components/charts/BarChart/_webExamples.mdx similarity index 70% rename from apps/docs/docs/components/graphs/BarChart/_webExamples.mdx rename to apps/docs/docs/components/charts/BarChart/_webExamples.mdx index 253361b837..7d82a11d14 100644 --- a/apps/docs/docs/components/graphs/BarChart/_webExamples.mdx +++ b/apps/docs/docs/components/charts/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 @@ -658,9 +694,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 `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); -You can set the `BarComponent` prop to render a custom component for bars. + 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 +1013,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 +1049,10 @@ function Candlesticks() { const bodyY = openY < closeY ? openY : closeY; return ( - + - + ); }); @@ -795,7 +1126,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 +1160,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/BarChart/_webPropsTable.mdx b/apps/docs/docs/components/charts/BarChart/_webPropsTable.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/_webPropsTable.mdx rename to apps/docs/docs/components/charts/BarChart/_webPropsTable.mdx diff --git a/apps/docs/docs/components/graphs/BarChart/index.mdx b/apps/docs/docs/components/charts/BarChart/index.mdx similarity index 100% rename from apps/docs/docs/components/graphs/BarChart/index.mdx rename to apps/docs/docs/components/charts/BarChart/index.mdx diff --git a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json b/apps/docs/docs/components/charts/BarChart/mobileMetadata.json similarity index 85% rename from apps/docs/docs/components/graphs/BarChart/mobileMetadata.json rename to apps/docs/docs/components/charts/BarChart/mobileMetadata.json index d2037bf2bb..f46f2c24da 100644 --- a/apps/docs/docs/components/graphs/BarChart/mobileMetadata.json +++ b/apps/docs/docs/components/charts/BarChart/mobileMetadata.json @@ -5,15 +5,15 @@ "relatedComponents": [ { "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" + "url": "/components/charts/CartesianChart/" }, { "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/BarChart/webMetadata.json b/apps/docs/docs/components/charts/BarChart/webMetadata.json similarity index 84% rename from apps/docs/docs/components/graphs/BarChart/webMetadata.json rename to apps/docs/docs/components/charts/BarChart/webMetadata.json index 52774ec1d3..633c6ff959 100644 --- a/apps/docs/docs/components/graphs/BarChart/webMetadata.json +++ b/apps/docs/docs/components/charts/BarChart/webMetadata.json @@ -6,15 +6,15 @@ "relatedComponents": [ { "label": "CartesianChart", - "url": "/components/graphs/CartesianChart/" + "url": "/components/charts/CartesianChart/" }, { "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/_mobileExamples.mdx b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx similarity index 74% rename from apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx rename to apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx index b56d6d7833..b84132a4b5 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/_mobileExamples.mdx +++ b/apps/docs/docs/components/charts/CartesianChart/_mobileExamples.mdx @@ -2,7 +2,7 @@ CartesianChart is a customizable, SVG based component that can be used to displa ## Basics -[AreaChart](/components/graphs/AreaChart/), [BarChart](/components/graphs/BarChart/), and [LineChart](/components/graphs/LineChart/) are built on top of CartesianChart and have default functionality for your chart. +[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 @@ -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 @@ -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/_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 72% rename from apps/docs/docs/components/graphs/CartesianChart/_webExamples.mdx rename to apps/docs/docs/components/charts/CartesianChart/_webExamples.mdx index a5343805e7..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 @@ -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/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 70% rename from apps/docs/docs/components/graphs/CartesianChart/webMetadata.json rename to apps/docs/docs/components/charts/CartesianChart/webMetadata.json index 8f55e33f81..3b029ade16 100644 --- a/apps/docs/docs/components/graphs/CartesianChart/webMetadata.json +++ b/apps/docs/docs/components/charts/CartesianChart/webMetadata.json @@ -6,29 +6,24 @@ "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": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] + "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 66% rename from apps/docs/docs/components/graphs/Legend/mobileMetadata.json rename to apps/docs/docs/components/charts/Legend/mobileMetadata.json index caf6999301..7ce6bbd017 100644 --- a/apps/docs/docs/components/graphs/Legend/mobileMetadata.json +++ b/apps/docs/docs/components/charts/Legend/mobileMetadata.json @@ -5,29 +5,21 @@ "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": [ { "name": "@shopify/react-native-skia", "version": "^1.12.4 || ^2.0.0" - }, - { - "name": "react-native-gesture-handler", - "version": "^2.16.2" - }, - { - "name": "react-native-reanimated", - "version": "^3.14.0" } ] } diff --git a/apps/docs/docs/components/graphs/Legend/webMetadata.json b/apps/docs/docs/components/charts/Legend/webMetadata.json similarity index 67% rename from apps/docs/docs/components/graphs/Legend/webMetadata.json rename to apps/docs/docs/components/charts/Legend/webMetadata.json index 056ff17267..10d2b3ad05 100644 --- a/apps/docs/docs/components/graphs/Legend/webMetadata.json +++ b/apps/docs/docs/components/charts/Legend/webMetadata.json @@ -5,21 +5,16 @@ "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": [ - { - "name": "framer-motion", - "version": "^10.18.0" - } - ] + "dependencies": [] } diff --git a/apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx b/apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx similarity index 97% rename from apps/docs/docs/components/graphs/LineChart/_mobileExamples.mdx rename to apps/docs/docs/components/charts/LineChart/_mobileExamples.mdx index 263dbbbb8c..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 ); @@ -739,7 +739,7 @@ function BasicAccessible() { ### Axes -Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis). +Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis). ```tsx ``` -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 ); @@ -627,7 +627,7 @@ function AccessibleWithHeader() { ### Axes -Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/graphs/XAxis) and [YAxis](/components/graphs/YAxis). +Using `showXAxis` and `showYAxis` allows you to display the axes. For more information, such as adjusting domain and range, see [XAxis](/components/charts/XAxis) and [YAxis](/components/charts/YAxis). ```jsx live ``` -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 -``` - -## Simple - -```jsx - -``` - -## Stacking - -You can use the `stacked` prop to stack all areas on top of each other. You can also use the `stackId` prop on a series to create different stack groups. See [CartesianChart](/components/graphs/CartesianChart/#series-stacks) for more details. - -```jsx -function StackingExample() { - const theme = useTheme(); - return ( - } - type="dotted" - /> - ); -} -``` - -## Negative Values - -When an area chart contains negative values, the baseline automatically adjusts to zero instead of the bottom of the chart. The area fills from the data line to the zero baseline, properly showing both positive and negative regions. - -```jsx - } - showYAxis - yAxis={{ - showGrid: true, - }} -/> -``` - -## Area Styles - -You can have different area styles for each series. - -```jsx - -``` diff --git a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx b/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx deleted file mode 100644 index 245f147f70..0000000000 --- a/apps/docs/docs/components/graphs/BarChart/_mobileExamples.mdx +++ /dev/null @@ -1,663 +0,0 @@ -## Basic Example - -Bar charts are a useful component for comparing discrete categories of data. -They are helpful for highlighting trends to users or allowing them to compare proportions at a glance. - -To start, pass in a series of data to the chart. - -```jsx - -``` - -## Multiple Series - -You can also provide multiple series of data to the chart. Series will have their bars for each data point rendered side by side. - -```tsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Series Stacking - -You can also configure stacking for your chart using the `stacked` prop. - -```tsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -You can also configure multiple stacks by setting the `stackId` prop on each series. - -```tsx -function MonthlyGainsMultipleStacks() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -### Stack Gap - -```tsx -function MonthlyGainsByAsset() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const tickFormatter = useCallback( - (amount) => - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: 'USD', - }).format(amount), - [], - ); - - return ( - - ); -} -``` - -## Border Radius - -Bars have a default border radius of `100`. You can change this by setting the `borderRadius` prop on the chart. - -Stacks will only round the top corners of touching bars. - -```jsx - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -### Round Baseline - -You can also round the baseline of the bars by setting the `roundBaseline` prop on the chart. - -```jsx - { - if (value === 'D') { - return {value}; - } - return value; - }, - }} - style={{ margin: '0 auto' }} -/> -``` - -## Negative Data - -```tsx -function PositiveAndNegativeCashFlow() { - const ThinSolidLine = memo((props: SolidLineProps) => ); - - const categories = Array.from({ length: 31 }, (_, i) => `3/${i + 1}`); - const gains = [ - 5, 0, 6, 18, 0, 5, 12, 0, 12, 22, 28, 18, 0, 12, 6, 0, 0, 24, 0, 0, 4, 0, 18, 0, 0, 14, 10, 16, - 0, 0, 0, - ]; - - const losses = [ - -4, 0, -8, -12, -6, 0, 0, 0, -18, 0, -12, 0, -9, -6, 0, 0, 0, 0, -22, -8, 0, 0, -10, -14, 0, 0, - 0, 0, 0, -12, -10, - ]; - const series = [ - { id: 'gains', data: gains, color: 'var(--color-fgPositive)' }, - { id: 'losses', data: losses, color: 'var(--color-fgNegative)' }, - ]; - - return ( - `$${value}M`, - }} - /> - ); -} -``` - -## Missing Bars - -You can pass in `null` or `0` values to not render a bar for that data point. - -```jsx - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} -/> -``` - -You can also use the `BarStackComponent` prop to render an empty circle for zero values. - -```tsx -function MonthlyRewards() { - const months = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; - const currentMonth = 7; - const purple = [null, 6, 8, 10, 7, 6, 6, 8, null, null, null, null]; - const blue = [null, 10, 12, 11, 10, 9, 10, 11, null, null, null, null]; - const cyan = [null, 7, 10, 12, 11, 10, 8, 11, null, null, null, null]; - const green = [10, null, null, null, 1, null, null, 6, null, null, null, null]; - - const series = [ - { id: 'purple', data: purple, color: '#b399ff' }, - { id: 'blue', data: blue, color: '#4f7cff' }, - { id: 'cyan', data: cyan, color: '#00c2df' }, - { id: 'green', data: green, color: '#33c481' }, - ]; - - const CustomBarStackComponent = ({ children, ...props }: BarStackComponentProps) => { - if (props.height === 0) { - const diameter = props.width; - return ( - - ); - } - - return {children}; - }; - - return ( - { - if (index == currentMonth) { - return {months[index]}; - } - return months[index]; - }, - categoryPadding: 0.27, - }} - /> - ); -} -``` - -## Customization - -### Bar Spacing - -There are two ways to control the spacing between bars. You can set the `barPadding` prop to control the spacing between bars within a series. You can also set the `categoryPadding` prop to control the spacing between stacks of bars. - -```jsx - -``` - -### Minimum Size - -To better emphasize small values, you can set the `stackMinSize` or `barMinSize` prop to control the minimum size for entire stacks or individual bar. -It is recommended to only use `stackMinSize` for stacked charts and `barMinSize` for non-stacked charts. - -#### Minimum Stack Size - -You can set the `stackMinSize` prop to control the minimum size for entire stacks. This will only apply to stacks that have a value that is not `null` or `0`. It will proportionally scale the values of each bar in the stack to reach the minimum size. - -```jsx - -``` - -#### Minimum Bar Size - -You can also set the `barMinSize` prop to control the minimum size for individual bars. This will only apply to bars that have a value that is not `null` or `0`. - -```jsx - `$${value}k`, - showGrid: true, - showTickMarks: true, - showLine: true, - tickMarkSize: 1.5, - domain: { max: 50 }, - }} - barMinSize={4} -/> -``` - -### Multiple Y Axes - -You can render bars from separate y axes in one `BarPlot`, however they aren't able to be stacked. - -```jsx -function MultipleYAxes() { - const theme = useTheme(); - - return ( - - - `$${value}k`} - /> - `${value}%`} - /> - - - ); -} -``` diff --git a/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json b/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json index bef7136e94..07fa93254d 100644 --- a/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Checkbox/mobileMetadata.json @@ -24,5 +24,6 @@ "label": "Switch", "url": "/components/inputs/Switch/?platform=mobile" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json b/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json index 3a118b0d5e..8bb9c0ff71 100644 --- a/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxCell/mobileMetadata.json @@ -16,5 +16,6 @@ "label": "RadioCell", "url": "/components/inputs/RadioCell/?platform=mobile" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json b/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json index 2a66b7bfdf..cb8ba03f5d 100644 --- a/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxCell/webMetadata.json @@ -17,5 +17,11 @@ "label": "RadioCell", "url": "/components/inputs/RadioCell/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json index f8f38cff37..e4e1e13c63 100644 --- a/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxGroup/mobileMetadata.json @@ -20,5 +20,6 @@ "label": "RadioGroup", "url": "/components/inputs/RadioGroup" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json b/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json index bf0b47a771..4300e5ebb7 100644 --- a/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/CheckboxGroup/webMetadata.json @@ -21,5 +21,11 @@ "label": "RadioGroup", "url": "/components/inputs/RadioGroup" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json index a506ad520f..77ea5e689d 100644 --- a/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/mobileMetadata.json @@ -12,8 +12,8 @@ ], "dependencies": [ { - "name": "fuse.js", - "version": "^6.6.2" + "name": "react-native-safe-area-context", + "version": "^4.10.5" } ] } diff --git a/apps/docs/docs/components/inputs/Combobox/webMetadata.json b/apps/docs/docs/components/inputs/Combobox/webMetadata.json index fe91c1e3b3..4db33d49b9 100644 --- a/apps/docs/docs/components/inputs/Combobox/webMetadata.json +++ b/apps/docs/docs/components/inputs/Combobox/webMetadata.json @@ -12,8 +12,12 @@ ], "dependencies": [ { - "name": "fuse.js", - "version": "^6.6.2" + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json index 9e020f094f..57b7d81acf 100644 --- a/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/ControlGroup/mobileMetadata.json @@ -31,5 +31,6 @@ "label": "Switch", "url": "/components/inputs/Switch/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json b/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json index 16eec2d6a2..609d2c9e24 100644 --- a/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/ControlGroup/webMetadata.json @@ -32,5 +32,6 @@ "label": "Switch", "url": "/components/inputs/Switch/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json b/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json index 16ae058952..d6c59ebc09 100644 --- a/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/MediaChip/mobileMetadata.json @@ -19,5 +19,6 @@ "label": "TabbedChips", "url": "/components/navigation/TabbedChips/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/MediaChip/webMetadata.json b/apps/docs/docs/components/inputs/MediaChip/webMetadata.json index 1deb01bbdd..a26de154bc 100644 --- a/apps/docs/docs/components/inputs/MediaChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/MediaChip/webMetadata.json @@ -20,5 +20,6 @@ "label": "TabbedChips", "url": "/components/navigation/TabbedChips/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json index 155dc8010b..6ef25ea309 100644 --- a/apps/docs/docs/components/inputs/Radio/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/mobileMetadata.json @@ -27,8 +27,8 @@ ], "dependencies": [ { - "name": "framer-motion", - "version": "^10.18.0" + "name": "react-native-svg", + "version": "^14.1.0" } ] } diff --git a/apps/docs/docs/components/inputs/Radio/webMetadata.json b/apps/docs/docs/components/inputs/Radio/webMetadata.json index 5dd80ea86a..d7c43966c4 100644 --- a/apps/docs/docs/components/inputs/Radio/webMetadata.json +++ b/apps/docs/docs/components/inputs/Radio/webMetadata.json @@ -25,5 +25,11 @@ "label": "Switch", "url": "/components/inputs/Switch" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json index b1caa8484f..685965c943 100644 --- a/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/mobileMetadata.json @@ -16,5 +16,11 @@ "label": "Radio", "url": "/components/inputs/Radio/?platform=mobile" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioCell/webMetadata.json b/apps/docs/docs/components/inputs/RadioCell/webMetadata.json index 4fe91b8b58..1fd5a4a886 100644 --- a/apps/docs/docs/components/inputs/RadioCell/webMetadata.json +++ b/apps/docs/docs/components/inputs/RadioCell/webMetadata.json @@ -17,5 +17,11 @@ "label": "Radio", "url": "/components/inputs/Radio/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json index 66941645bc..c594a0a22a 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/mobileMetadata.json @@ -14,5 +14,11 @@ "url": "/components/inputs/ControlGroup/", "description": "ControlGroup is a component that allows users to group related controls together." } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json b/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json index 9bf256ba21..ad8a650600 100644 --- a/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json +++ b/apps/docs/docs/components/inputs/RadioGroup/webMetadata.json @@ -13,5 +13,11 @@ "label": "ControlGroup", "url": "/components/inputs/ControlGroup/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/inputs/Select/webMetadata.json b/apps/docs/docs/components/inputs/Select/webMetadata.json index a6bac0619e..135c57e756 100644 --- a/apps/docs/docs/components/inputs/Select/webMetadata.json +++ b/apps/docs/docs/components/inputs/Select/webMetadata.json @@ -23,5 +23,14 @@ "url": "/components/inputs/SelectChip/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json index b3746563b4..db9d2c6ec2 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/mobileMetadata.json @@ -5,5 +5,10 @@ "description": "A flexible select component for both single and multi-selection, built for mobile applications with comprehensive accessibility support.", "alpha": true, "relatedComponents": [], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-safe-area-context", + "version": "^4.10.5" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json index bc1cbd1e0c..59cda3ea44 100644 --- a/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectAlpha/webMetadata.json @@ -5,5 +5,14 @@ "description": "A flexible select component for both single and multi-selection, built for web applications with comprehensive accessibility support.", "alpha": true, "relatedComponents": [], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json index cf540a6000..036d3ca9a0 100644 --- a/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/navigation/TabbedChips/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-safe-area-context", + "version": "^4.10.5" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json index bf1af8fede..fc3dc6f6c9 100644 --- a/apps/docs/docs/components/inputs/SelectChip/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChip/webMetadata.json @@ -23,5 +23,14 @@ "url": "/components/navigation/TabbedChips/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json index 8e40b52b48..b8be5679bb 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/mobileMetadata.json @@ -17,5 +17,11 @@ "label": "SelectChip", "url": "/components/inputs/SelectChip/" } + ], + "dependencies": [ + { + "name": "react-native-safe-area-context", + "version": "^4.10.5" + } ] } diff --git a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json index 26cb8d05b8..f7088fafeb 100644 --- a/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json +++ b/apps/docs/docs/components/inputs/SelectChipAlpha/webMetadata.json @@ -18,5 +18,15 @@ "label": "SelectChip", "url": "/components/inputs/SelectChip/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } ] } diff --git a/apps/docs/docs/components/inputs/TileButton/webMetadata.json b/apps/docs/docs/components/inputs/TileButton/webMetadata.json index 6cbc699b4e..b634795cd0 100644 --- a/apps/docs/docs/components/inputs/TileButton/webMetadata.json +++ b/apps/docs/docs/components/inputs/TileButton/webMetadata.json @@ -13,5 +13,6 @@ "label": "IconButton", "url": "/components/inputs/IconButton/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json index 939d020196..8cbed53d2b 100644 --- a/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json +++ b/apps/docs/docs/components/layout/AccordionItem/mobileMetadata.json @@ -9,5 +9,10 @@ "url": "/components/layout/Accordion/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/layout/AccordionItem/webMetadata.json b/apps/docs/docs/components/layout/AccordionItem/webMetadata.json index dc5adf5339..b06e1b1a01 100644 --- a/apps/docs/docs/components/layout/AccordionItem/webMetadata.json +++ b/apps/docs/docs/components/layout/AccordionItem/webMetadata.json @@ -10,5 +10,10 @@ "url": "/components/layout/Accordion/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/layout/Carousel/mobileMetadata.json b/apps/docs/docs/components/layout/Carousel/mobileMetadata.json index 62883cd02e..588489fc1d 100644 --- a/apps/docs/docs/components/layout/Carousel/mobileMetadata.json +++ b/apps/docs/docs/components/layout/Carousel/mobileMetadata.json @@ -2,5 +2,6 @@ "import": "import { Carousel } from '@coinbase/cds-mobile/carousel/Carousel'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/carousel/Carousel.tsx", "figma": "https://www.figma.com/design/XRzGLH42ezSda4UYVfPMMO/Carousel?node-id=26293-2427", - "description": "A flexible carousel component for displaying sequences of content with navigation and pagination options." + "description": "A flexible carousel component for displaying sequences of content with navigation and pagination options.", + "dependencies": [] } diff --git a/apps/docs/docs/components/layout/Carousel/webMetadata.json b/apps/docs/docs/components/layout/Carousel/webMetadata.json index 2f8c9a7d60..934c419038 100644 --- a/apps/docs/docs/components/layout/Carousel/webMetadata.json +++ b/apps/docs/docs/components/layout/Carousel/webMetadata.json @@ -3,5 +3,11 @@ "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/carousel/Carousel.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-carousel--all", "figma": "https://www.figma.com/design/XRzGLH42ezSda4UYVfPMMO/Carousel?node-id=26293-2427", - "description": "A flexible carousel component for displaying sequences of content with navigation and pagination options." + "description": "A flexible carousel component for displaying sequences of content with navigation and pagination options.", + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/layout/Dropdown/webMetadata.json b/apps/docs/docs/components/layout/Dropdown/webMetadata.json index 8f5872c118..d0df1b6cc6 100644 --- a/apps/docs/docs/components/layout/Dropdown/webMetadata.json +++ b/apps/docs/docs/components/layout/Dropdown/webMetadata.json @@ -14,5 +14,14 @@ "url": "/components/inputs/SelectChip/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/media/Avatar/mobileMetadata.json b/apps/docs/docs/components/media/Avatar/mobileMetadata.json index ae31ed58b7..1e9ac28249 100644 --- a/apps/docs/docs/components/media/Avatar/mobileMetadata.json +++ b/apps/docs/docs/components/media/Avatar/mobileMetadata.json @@ -12,5 +12,10 @@ "url": "/components/other/DotCount/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/Avatar/webMetadata.json b/apps/docs/docs/components/media/Avatar/webMetadata.json index 2657c73422..8570ed2307 100644 --- a/apps/docs/docs/components/media/Avatar/webMetadata.json +++ b/apps/docs/docs/components/media/Avatar/webMetadata.json @@ -13,5 +13,10 @@ "url": "/components/other/DotCount/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json index e2b1126f14..3ad2f79f6f 100644 --- a/apps/docs/docs/components/media/CellMedia/mobileMetadata.json +++ b/apps/docs/docs/components/media/CellMedia/mobileMetadata.json @@ -12,5 +12,10 @@ "url": "/components/data-display/ListCell" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json index bea11194e7..f9eb2b2ea7 100644 --- a/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/HeroSquare/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/media/Pictogram/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json index 7ffdea2742..a2eca6c388 100644 --- a/apps/docs/docs/components/media/LogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoMark/mobileMetadata.json @@ -15,5 +15,11 @@ "label": "SubBrandLogoWordMark", "url": "/components/media/SubBrandLogoWordMark/" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/media/LogoMark/webMetadata.json b/apps/docs/docs/components/media/LogoMark/webMetadata.json index 25eb26cfb3..4593b8cd77 100644 --- a/apps/docs/docs/components/media/LogoMark/webMetadata.json +++ b/apps/docs/docs/components/media/LogoMark/webMetadata.json @@ -16,5 +16,6 @@ "label": "SubBrandLogoWordMark", "url": "/components/media/SubBrandLogoWordMark/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json index 566de1074f..a06f647034 100644 --- a/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/LogoWordMark/mobileMetadata.json @@ -15,5 +15,11 @@ "label": "SubBrandLogoWordMark", "url": "/components/media/SubBrandLogoWordMark/" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/media/LogoWordMark/webMetadata.json b/apps/docs/docs/components/media/LogoWordMark/webMetadata.json index 928c6d3150..b69c804473 100644 --- a/apps/docs/docs/components/media/LogoWordMark/webMetadata.json +++ b/apps/docs/docs/components/media/LogoWordMark/webMetadata.json @@ -16,5 +16,6 @@ "label": "SubBrandLogoWordMark", "url": "/components/media/SubBrandLogoWordMark/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json index 193d4e199b..30aedc1c64 100644 --- a/apps/docs/docs/components/media/Pictogram/mobileMetadata.json +++ b/apps/docs/docs/components/media/Pictogram/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/media/SpotRectangle/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json index 95868febe6..ff0b14bfe2 100644 --- a/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImage/mobileMetadata.json @@ -22,10 +22,6 @@ } ], "dependencies": [ - { - "name": "react-native-svg", - "version": "^14.1.0" - }, { "name": "react-native-svg", "version": "^14.1.0" diff --git a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json index 0368c7f144..109cd18957 100644 --- a/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json +++ b/apps/docs/docs/components/media/RemoteImageGroup/mobileMetadata.json @@ -7,5 +7,11 @@ "label": "RemoteImage", "url": "/components/media/RemoteImage" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/media/RemoteImageGroup/webMetadata.json b/apps/docs/docs/components/media/RemoteImageGroup/webMetadata.json index 2437780eb8..74fd9ce1c3 100644 --- a/apps/docs/docs/components/media/RemoteImageGroup/webMetadata.json +++ b/apps/docs/docs/components/media/RemoteImageGroup/webMetadata.json @@ -8,5 +8,6 @@ "label": "RemoteImage", "url": "/components/media/RemoteImage" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json index 4dde7cf0a4..c5697db759 100644 --- a/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotIcon/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/media/Pictogram/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json index 19164301c8..f8817f27ba 100644 --- a/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotRectangle/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/media/Pictogram/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json index 4a0a58b9e6..03c6bc3376 100644 --- a/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json +++ b/apps/docs/docs/components/media/SpotSquare/mobileMetadata.json @@ -22,5 +22,10 @@ "url": "/components/media/Pictogram/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json index 3d8e7d1685..16d4c6ef1a 100644 --- a/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoMark/mobileMetadata.json @@ -15,5 +15,11 @@ "label": "LogoWordMark", "url": "/components/media/LogoWordMark/" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoMark/webMetadata.json b/apps/docs/docs/components/media/SubBrandLogoMark/webMetadata.json index 6acdd99191..daebcf0c2f 100644 --- a/apps/docs/docs/components/media/SubBrandLogoMark/webMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoMark/webMetadata.json @@ -16,5 +16,6 @@ "label": "LogoWordMark", "url": "/components/media/LogoWordMark/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json index c8dc146392..d0c8956cf6 100644 --- a/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoWordMark/mobileMetadata.json @@ -15,5 +15,11 @@ "label": "LogoMark", "url": "/components/media/LogoMark/" } + ], + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } ] } diff --git a/apps/docs/docs/components/media/SubBrandLogoWordMark/webMetadata.json b/apps/docs/docs/components/media/SubBrandLogoWordMark/webMetadata.json index 43dbbe392c..64d8efe087 100644 --- a/apps/docs/docs/components/media/SubBrandLogoWordMark/webMetadata.json +++ b/apps/docs/docs/components/media/SubBrandLogoWordMark/webMetadata.json @@ -16,5 +16,6 @@ "label": "LogoMark", "url": "/components/media/LogoMark/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json index a8f518517a..ecc956fceb 100644 --- a/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/BrowserBar/mobileMetadata.json @@ -8,5 +8,11 @@ "label": "TopNavBar", "url": "/components/navigation/TopNavBar/" } + ], + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } ] } diff --git a/apps/docs/docs/components/navigation/Coachmark/mobileMetadata.json b/apps/docs/docs/components/navigation/Coachmark/mobileMetadata.json index 3f662a998e..3fb20e1696 100644 --- a/apps/docs/docs/components/navigation/Coachmark/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Coachmark/mobileMetadata.json @@ -8,5 +8,6 @@ "label": "Tour", "url": "/components/navigation/Tour/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/Coachmark/webMetadata.json b/apps/docs/docs/components/navigation/Coachmark/webMetadata.json index 016d179215..e8e6d59b67 100644 --- a/apps/docs/docs/components/navigation/Coachmark/webMetadata.json +++ b/apps/docs/docs/components/navigation/Coachmark/webMetadata.json @@ -9,5 +9,6 @@ "label": "Tour", "url": "/components/navigation/Tour/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/NavigationTitle/mobileMetadata.json b/apps/docs/docs/components/navigation/NavigationTitle/mobileMetadata.json index 1d6122fd11..36bce7323e 100644 --- a/apps/docs/docs/components/navigation/NavigationTitle/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitle/mobileMetadata.json @@ -11,5 +11,6 @@ "label": "BrowserBar", "url": "/components/navigation/BrowserBar/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/NavigationTitle/webMetadata.json b/apps/docs/docs/components/navigation/NavigationTitle/webMetadata.json index e26924c031..bc6dd4c579 100644 --- a/apps/docs/docs/components/navigation/NavigationTitle/webMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitle/webMetadata.json @@ -9,5 +9,6 @@ "label": "NavigationBar", "url": "/components/navigation/NavigationBar/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/NavigationTitleSelect/mobileMetadata.json b/apps/docs/docs/components/navigation/NavigationTitleSelect/mobileMetadata.json index 21313e9b78..3388cd780d 100644 --- a/apps/docs/docs/components/navigation/NavigationTitleSelect/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitleSelect/mobileMetadata.json @@ -19,5 +19,6 @@ "label": "SelectOption", "url": "/components/inputs/SelectOption" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json index 2452a9837d..f51f4917f8 100644 --- a/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json +++ b/apps/docs/docs/components/navigation/NavigationTitleSelect/webMetadata.json @@ -16,5 +16,15 @@ "label": "SelectOption", "url": "/components/inputs/SelectOption" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } ] } diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json index 1296db4821..f0bbda8767 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/SegmentedTabs/mobileMetadata.json @@ -13,5 +13,10 @@ "url": "/components/navigation/TabNavigation/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/SegmentedTabs/webMetadata.json b/apps/docs/docs/components/navigation/SegmentedTabs/webMetadata.json index 47364d7966..241626941d 100644 --- a/apps/docs/docs/components/navigation/SegmentedTabs/webMetadata.json +++ b/apps/docs/docs/components/navigation/SegmentedTabs/webMetadata.json @@ -14,5 +14,10 @@ "url": "/components/navigation/TabNavigation/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json index 45003138f8..648e617b7a 100644 --- a/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarItem/webMetadata.json @@ -14,5 +14,10 @@ "url": "/components/navigation/SidebarMoreMenu/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json index 1d6ab21b87..5fcaf9184d 100644 --- a/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json +++ b/apps/docs/docs/components/navigation/SidebarMoreMenu/webMetadata.json @@ -22,5 +22,10 @@ "url": "/components/inputs/SelectOption/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/navigation/Stepper/mobileMetadata.json b/apps/docs/docs/components/navigation/Stepper/mobileMetadata.json index 6ee726c9a7..5d5510f986 100644 --- a/apps/docs/docs/components/navigation/Stepper/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Stepper/mobileMetadata.json @@ -1,5 +1,6 @@ { "import": "import { Stepper } from '@coinbase/cds-mobile/stepper/Stepper'", "source": "https://github.com/coinbase/cds/blob/master/packages/mobile/src/stepper/Stepper.tsx", - "description": "A component that visualizes states within a multi-step process." + "description": "A component that visualizes states within a multi-step process.", + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/Stepper/webMetadata.json b/apps/docs/docs/components/navigation/Stepper/webMetadata.json index 0ec304f38a..d7b28d8e3e 100644 --- a/apps/docs/docs/components/navigation/Stepper/webMetadata.json +++ b/apps/docs/docs/components/navigation/Stepper/webMetadata.json @@ -2,5 +2,6 @@ "import": "import { Stepper } from '@coinbase/cds-web/stepper/Stepper'", "source": "https://github.com/coinbase/cds/blob/master/packages/web/src/stepper/Stepper.tsx", "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-stepper-horizontal--default", - "description": "A component that visualizes states within a multi-step process." + "description": "A component that visualizes states within a multi-step process.", + "dependencies": [] } diff --git a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json index aa37cc1e65..322496548a 100644 --- a/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/mobileMetadata.json @@ -12,5 +12,10 @@ "url": "/components/navigation/TabIndicator/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json index 59805977c9..0cfca5a265 100644 --- a/apps/docs/docs/components/navigation/TabLabel/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabLabel/webMetadata.json @@ -13,5 +13,10 @@ "url": "/components/navigation/TabIndicator/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json index f45ffc076c..4b2b7ef553 100644 --- a/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabNavigation/mobileMetadata.json @@ -18,5 +18,10 @@ "url": "/components/navigation/SegmentedTabs/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabNavigation/webMetadata.json b/apps/docs/docs/components/navigation/TabNavigation/webMetadata.json index 26d3a0c3ae..8a50467c8c 100644 --- a/apps/docs/docs/components/navigation/TabNavigation/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabNavigation/webMetadata.json @@ -19,5 +19,10 @@ "url": "/components/navigation/SegmentedTabs/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json index de97e24713..745a786e7b 100644 --- a/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChips/mobileMetadata.json @@ -10,5 +10,10 @@ "url": "/components/inputs/SelectChip/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabbedChips/webMetadata.json b/apps/docs/docs/components/navigation/TabbedChips/webMetadata.json index a0b040318b..d740465b0b 100644 --- a/apps/docs/docs/components/navigation/TabbedChips/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChips/webMetadata.json @@ -11,5 +11,10 @@ "url": "/components/inputs/SelectChip/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json index c9b5956f0a..98412d5d70 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/mobileMetadata.json @@ -25,5 +25,11 @@ "label": "SelectChip", "url": "/components/inputs/SelectChip/" } + ], + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } ] } diff --git a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json index e6f591c5c8..8ccb13f533 100644 --- a/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json +++ b/apps/docs/docs/components/navigation/TabbedChipsAlpha/webMetadata.json @@ -26,5 +26,11 @@ "label": "SelectChip", "url": "/components/inputs/SelectChip/" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json index 5254b63d15..851ba23c75 100644 --- a/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/mobileMetadata.json @@ -12,5 +12,11 @@ "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs/" } + ], + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } ] } diff --git a/apps/docs/docs/components/navigation/Tabs/webMetadata.json b/apps/docs/docs/components/navigation/Tabs/webMetadata.json index 25e290d848..cefe3817ee 100644 --- a/apps/docs/docs/components/navigation/Tabs/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tabs/webMetadata.json @@ -11,5 +11,11 @@ "label": "SegmentedTabs", "url": "/components/navigation/SegmentedTabs" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } ] } diff --git a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json index a199005ce2..3fd09a8015 100644 --- a/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/TopNavBar/mobileMetadata.json @@ -8,5 +8,11 @@ "label": "BrowserBar", "url": "/components/navigation/BrowserBar/" } + ], + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } ] } diff --git a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json index fcf58439eb..dcb490e8f9 100644 --- a/apps/docs/docs/components/navigation/Tour/mobileMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/mobileMetadata.json @@ -8,5 +8,10 @@ "url": "/components/navigation/Coachmark/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/navigation/Tour/webMetadata.json b/apps/docs/docs/components/navigation/Tour/webMetadata.json index 58ad6838e1..53c33ef98b 100644 --- a/apps/docs/docs/components/navigation/Tour/webMetadata.json +++ b/apps/docs/docs/components/navigation/Tour/webMetadata.json @@ -9,5 +9,10 @@ "url": "/components/navigation/Coachmark/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json index 47af1d7479..989c9eeae0 100644 --- a/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json +++ b/apps/docs/docs/components/numbers/RollingNumber/mobileMetadata.json @@ -4,5 +4,10 @@ "description": "A numeric display that animates value changes with rolling digits.", "figma": "", "relatedComponents": [], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-reanimated", + "version": "^3.14.0" + } + ] } diff --git a/apps/docs/docs/components/numbers/RollingNumber/webMetadata.json b/apps/docs/docs/components/numbers/RollingNumber/webMetadata.json index 2a60e94a21..06f27ff5bd 100644 --- a/apps/docs/docs/components/numbers/RollingNumber/webMetadata.json +++ b/apps/docs/docs/components/numbers/RollingNumber/webMetadata.json @@ -5,5 +5,10 @@ "storybook": "https://cds-storybook.coinbase.com/?path=/story/components-rollingnumber--examples", "figma": "", "relatedComponents": [], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/other/Calendar/webMetadata.json b/apps/docs/docs/components/other/Calendar/webMetadata.json index 4d65d5f999..1ac432167b 100644 --- a/apps/docs/docs/components/other/Calendar/webMetadata.json +++ b/apps/docs/docs/components/other/Calendar/webMetadata.json @@ -8,5 +8,11 @@ "label": "DatePicker", "url": "/components/other/DatePicker" } + ], + "dependencies": [ + { + "name": "react-dom", + "version": "^18.3.1" + } ] } diff --git a/apps/docs/docs/components/other/DateInput/mobileMetadata.json b/apps/docs/docs/components/other/DateInput/mobileMetadata.json index cd5fc1edc3..dc1cbeabe8 100644 --- a/apps/docs/docs/components/other/DateInput/mobileMetadata.json +++ b/apps/docs/docs/components/other/DateInput/mobileMetadata.json @@ -12,5 +12,6 @@ "label": "TextInput", "url": "/components/inputs/TextInput/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/other/DateInput/webMetadata.json b/apps/docs/docs/components/other/DateInput/webMetadata.json index 90512b1f13..82d4c6ffe7 100644 --- a/apps/docs/docs/components/other/DateInput/webMetadata.json +++ b/apps/docs/docs/components/other/DateInput/webMetadata.json @@ -17,5 +17,6 @@ "label": "Calendar", "url": "/components/other/Calendar/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/other/DatePicker/webMetadata.json b/apps/docs/docs/components/other/DatePicker/webMetadata.json index ee1d8e73e8..c785ad8c75 100644 --- a/apps/docs/docs/components/other/DatePicker/webMetadata.json +++ b/apps/docs/docs/components/other/DatePicker/webMetadata.json @@ -26,6 +26,10 @@ { "name": "framer-motion", "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json index 357872c4a0..aed0ba53c3 100644 --- a/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json +++ b/apps/docs/docs/components/other/DotSymbol/mobileMetadata.json @@ -21,5 +21,10 @@ "url": "/components/media/Icon/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-svg", + "version": "^14.1.0" + } + ] } diff --git a/apps/docs/docs/components/other/MediaQueryProvider/webMetadata.json b/apps/docs/docs/components/other/MediaQueryProvider/webMetadata.json index 1b0df5620c..e8b0e1171b 100644 --- a/apps/docs/docs/components/other/MediaQueryProvider/webMetadata.json +++ b/apps/docs/docs/components/other/MediaQueryProvider/webMetadata.json @@ -12,5 +12,6 @@ "label": "useBreakpoints", "url": "/hooks/useBreakpoints/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/other/ThemeProvider/mobileMetadata.json b/apps/docs/docs/components/other/ThemeProvider/mobileMetadata.json index 226cb3b663..e7ad83629f 100644 --- a/apps/docs/docs/components/other/ThemeProvider/mobileMetadata.json +++ b/apps/docs/docs/components/other/ThemeProvider/mobileMetadata.json @@ -7,5 +7,6 @@ "label": "useTheme", "url": "/hooks/useTheme/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/other/ThemeProvider/webMetadata.json b/apps/docs/docs/components/other/ThemeProvider/webMetadata.json index e5e7133f9c..2da20ef255 100644 --- a/apps/docs/docs/components/other/ThemeProvider/webMetadata.json +++ b/apps/docs/docs/components/other/ThemeProvider/webMetadata.json @@ -8,5 +8,6 @@ "label": "useTheme", "url": "/hooks/useTheme/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/overlay/Alert/webMetadata.json b/apps/docs/docs/components/overlay/Alert/webMetadata.json index bb40b63213..c89a519b50 100644 --- a/apps/docs/docs/components/overlay/Alert/webMetadata.json +++ b/apps/docs/docs/components/overlay/Alert/webMetadata.json @@ -10,5 +10,14 @@ "url": "/components/overlay/Modal/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/overlay/FocusTrap/webMetadata.json b/apps/docs/docs/components/overlay/FocusTrap/webMetadata.json index b71c051f80..5d326accb5 100644 --- a/apps/docs/docs/components/overlay/FocusTrap/webMetadata.json +++ b/apps/docs/docs/components/overlay/FocusTrap/webMetadata.json @@ -12,5 +12,6 @@ "label": "Tray", "url": "/components/overlay/Tray/" } - ] + ], + "dependencies": [] } diff --git a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json index 6a4cf18e98..48a8300764 100644 --- a/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenAlert/webMetadata.json @@ -18,6 +18,10 @@ { "name": "framer-motion", "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json index 4509787717..9fb8bfb472 100644 --- a/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModal/webMetadata.json @@ -14,6 +14,10 @@ { "name": "framer-motion", "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json index d5da6ac20f..94605e2da2 100644 --- a/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json +++ b/apps/docs/docs/components/overlay/FullscreenModalLayout/webMetadata.json @@ -13,5 +13,15 @@ "label": "FullscreenModal", "url": "/components/overlay/FullscreenModal" } + ], + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } ] } diff --git a/apps/docs/docs/components/overlay/Modal/webMetadata.json b/apps/docs/docs/components/overlay/Modal/webMetadata.json index e54a73d185..6be86f4db3 100644 --- a/apps/docs/docs/components/overlay/Modal/webMetadata.json +++ b/apps/docs/docs/components/overlay/Modal/webMetadata.json @@ -34,6 +34,10 @@ { "name": "framer-motion", "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/overlay/Overlay/webMetadata.json b/apps/docs/docs/components/overlay/Overlay/webMetadata.json index aaa0b4fcce..cbbf09367b 100644 --- a/apps/docs/docs/components/overlay/Overlay/webMetadata.json +++ b/apps/docs/docs/components/overlay/Overlay/webMetadata.json @@ -20,5 +20,10 @@ "url": "/components/overlay/Alert/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + } + ] } diff --git a/apps/docs/docs/components/overlay/Toast/webMetadata.json b/apps/docs/docs/components/overlay/Toast/webMetadata.json index 8da219d6b5..690f61dd3f 100644 --- a/apps/docs/docs/components/overlay/Toast/webMetadata.json +++ b/apps/docs/docs/components/overlay/Toast/webMetadata.json @@ -22,6 +22,10 @@ { "name": "framer-motion", "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" } ] } diff --git a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json index 72a03417d9..1355f725a8 100644 --- a/apps/docs/docs/components/overlay/Tooltip/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tooltip/webMetadata.json @@ -18,5 +18,14 @@ "url": "/components/overlay/Toast/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } diff --git a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json index ac19077a91..2e1051b2e4 100644 --- a/apps/docs/docs/components/overlay/Tray/mobileMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/mobileMetadata.json @@ -21,5 +21,10 @@ "url": "/components/overlay/Overlay/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-safe-area-context", + "version": "^4.10.5" + } + ] } diff --git a/apps/docs/docs/components/overlay/Tray/webMetadata.json b/apps/docs/docs/components/overlay/Tray/webMetadata.json index d6937f705d..0d3166435d 100644 --- a/apps/docs/docs/components/overlay/Tray/webMetadata.json +++ b/apps/docs/docs/components/overlay/Tray/webMetadata.json @@ -22,5 +22,14 @@ "url": "/components/overlay/Overlay/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "framer-motion", + "version": "^10.18.0" + }, + { + "name": "react-dom", + "version": "^18.3.1" + } + ] } 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} /> diff --git a/apps/docs/docs/components/typography/Link/mobileMetadata.json b/apps/docs/docs/components/typography/Link/mobileMetadata.json index 7c163fc42c..ae155635a9 100644 --- a/apps/docs/docs/components/typography/Link/mobileMetadata.json +++ b/apps/docs/docs/components/typography/Link/mobileMetadata.json @@ -9,5 +9,10 @@ "url": "/components/typography/Text/" } ], - "dependencies": [] + "dependencies": [ + { + "name": "react-native-inappbrowser-reborn", + "version": "^3.7.0" + } + ] } 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/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index 8ee661a4c7..63ce097a96 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -251,6 +251,17 @@ const config: Config = { plugins: [ ['@docusaurus/plugin-sitemap', { id: 'sitemap' }], + [ + '@docusaurus/plugin-client-redirects', + { + redirects: [], + createRedirects(existingPath: string) { + if (existingPath.includes('/components/charts/')) { + return [existingPath.replace('/components/charts/', '/components/graphs/')]; + } + }, + }, + ], [ '@docusaurus/theme-live-codeblock', { diff --git a/apps/docs/jest.config.js b/apps/docs/jest.config.js new file mode 100644 index 0000000000..7cb8e35cb9 --- /dev/null +++ b/apps/docs/jest.config.js @@ -0,0 +1,8 @@ +/** @type {import('jest').Config} */ +const config = { + preset: '../../jest.preset.js', + displayName: 'docs', + testEnvironment: 'node', +}; + +export default config; diff --git a/apps/docs/package.json b/apps/docs/package.json index b783ea8100..76d8e4f2a3 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -27,21 +27,22 @@ "@coinbase/docusaurus-plugin-docgen": "workspace:^", "@coinbase/docusaurus-plugin-kbar": "workspace:^", "@coinbase/docusaurus-plugin-llm-dev-server": "workspace:^", - "@docusaurus/core": "^3.7.0", - "@docusaurus/faster": "^3.7.0", - "@docusaurus/mdx-loader": "^3.7.0", - "@docusaurus/plugin-content-blog": "^3.7.0", - "@docusaurus/plugin-content-docs": "^3.7.0", - "@docusaurus/plugin-content-pages": "^3.7.0", - "@docusaurus/plugin-debug": "^3.7.0", - "@docusaurus/plugin-google-gtag": "^3.7.0", - "@docusaurus/plugin-google-tag-manager": "^3.7.0", - "@docusaurus/plugin-sitemap": "^3.7.0", - "@docusaurus/preset-classic": "^3.7.0", - "@docusaurus/theme-classic": "^3.7.0", - "@docusaurus/theme-common": "^3.7.0", - "@docusaurus/theme-live-codeblock": "^3.7.0", - "@docusaurus/theme-search-algolia": "^3.7.0", + "@docusaurus/core": "~3.7.0", + "@docusaurus/faster": "~3.7.0", + "@docusaurus/mdx-loader": "~3.7.0", + "@docusaurus/plugin-client-redirects": "~3.7.0", + "@docusaurus/plugin-content-blog": "~3.7.0", + "@docusaurus/plugin-content-docs": "~3.7.0", + "@docusaurus/plugin-content-pages": "~3.7.0", + "@docusaurus/plugin-debug": "~3.7.0", + "@docusaurus/plugin-google-gtag": "~3.7.0", + "@docusaurus/plugin-google-tag-manager": "~3.7.0", + "@docusaurus/plugin-sitemap": "~3.7.0", + "@docusaurus/preset-classic": "~3.7.0", + "@docusaurus/theme-classic": "~3.7.0", + "@docusaurus/theme-common": "~3.7.0", + "@docusaurus/theme-live-codeblock": "~3.7.0", + "@docusaurus/theme-search-algolia": "~3.7.0", "@mdx-js/react": "^3.1.0", "@react-spring/three": "^9.7.4", "@react-spring/web": "^9.7.4", @@ -65,9 +66,9 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "@docusaurus/module-type-aliases": "^3.7.0", - "@docusaurus/tsconfig": "^3.7.0", - "@docusaurus/types": "^3.7.0", + "@docusaurus/module-type-aliases": "~3.7.0", + "@docusaurus/tsconfig": "~3.7.0", + "@docusaurus/types": "~3.7.0", "@linaria/babel-preset": "^3.0.0-beta.22", "@linaria/core": "^3.0.0-beta.22", "@linaria/webpack-loader": "^3.0.0-beta.22", diff --git a/apps/docs/project.json b/apps/docs/project.json index 3189b2d8d7..3cbc945cd4 100644 --- a/apps/docs/project.json +++ b/apps/docs/project.json @@ -33,11 +33,21 @@ "cwd": "apps/docs" } }, + "serve": { + "command": "docusaurus serve --dir dist --port 3000", + "dependsOn": [ + "build" + ], + "options": { + "cwd": "apps/docs" + } + }, "build": { "executor": "nx:run-commands", "dependsOn": [ "^build", - "^typecheck" + "^typecheck", + "peer-dependencies-check" ], "options": { "commands": [ @@ -49,6 +59,12 @@ "parallel": false } }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "{projectRoot}/jest.config.js" + } + }, "lint": { "executor": "@nx/eslint:lint" }, @@ -82,6 +98,18 @@ "options": { "cwd": "{workspaceRoot}" } + }, + "peer-dependencies": { + "command": "tsx {projectRoot}/utils/generateComponentPeerDeps.ts", + "options": { + "cwd": "{workspaceRoot}" + } + }, + "peer-dependencies-check": { + "command": "tsx {projectRoot}/utils/generateComponentPeerDeps.ts --fail-on-changes", + "options": { + "cwd": "{workspaceRoot}" + } } } } diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts index 7f6e115b77..f94696e2e8 100644 --- a/apps/docs/sidebars.ts +++ b/apps/docs/sidebars.ts @@ -607,81 +607,81 @@ const sidebars: SidebarsConfig = { }, { type: 'category', - label: 'Graphs', + label: 'Charts', items: [ { type: 'doc', - id: 'components/graphs/AreaChart/areaChart', + id: 'components/charts/AreaChart/areaChart', label: 'AreaChart', }, { type: 'doc', - id: 'components/graphs/BarChart/barChart', + id: 'components/charts/BarChart/barChart', label: 'BarChart', }, { type: 'doc', - id: 'components/graphs/CartesianChart/cartesianChart', + id: 'components/charts/CartesianChart/cartesianChart', label: 'CartesianChart', }, { type: 'doc', - id: 'components/graphs/Legend/legend', + id: 'components/charts/Legend/legend', label: 'Legend', }, { type: 'doc', - id: 'components/graphs/LineChart/lineChart', + id: 'components/charts/LineChart/lineChart', label: 'LineChart', }, { type: 'doc', - id: 'components/graphs/ReferenceLine/referenceLine', + id: 'components/charts/ReferenceLine/referenceLine', label: 'ReferenceLine', }, { type: 'doc', - id: 'components/graphs/PeriodSelector/periodSelector', + id: 'components/charts/PeriodSelector/periodSelector', label: 'PeriodSelector', }, { type: 'doc', - id: 'components/graphs/Point/point', + id: 'components/charts/Point/point', label: 'Point', }, { type: 'doc', - id: 'components/graphs/Scrubber/scrubber', + id: 'components/charts/Scrubber/scrubber', label: 'Scrubber', }, { type: 'doc', - id: 'components/graphs/Sparkline/sparkline', + id: 'components/charts/Sparkline/sparkline', label: 'Sparkline (Deprecated)', }, { type: 'doc', - id: 'components/graphs/SparklineGradient/sparklineGradient', + id: 'components/charts/SparklineGradient/sparklineGradient', label: 'SparklineGradient (Deprecated)', }, { type: 'doc', - id: 'components/graphs/SparklineInteractive/sparklineInteractive', + id: 'components/charts/SparklineInteractive/sparklineInteractive', label: 'SparklineInteractive (Deprecated)', }, { type: 'doc', - id: 'components/graphs/SparklineInteractiveHeader/sparklineInteractiveHeader', + id: 'components/charts/SparklineInteractiveHeader/sparklineInteractiveHeader', label: 'SparklineInteractiveHeader (Deprecated)', }, { type: 'doc', - id: 'components/graphs/XAxis/xAxis', + id: 'components/charts/XAxis/xAxis', label: 'XAxis', }, { type: 'doc', - id: 'components/graphs/YAxis/yAxis', + id: 'components/charts/YAxis/yAxis', label: 'YAxis', }, ], diff --git a/apps/docs/src/utils/__tests__/isTypeAlias.test.ts b/apps/docs/src/utils/__tests__/isTypeAlias.test.ts new file mode 100644 index 0000000000..1c8e01c5ef --- /dev/null +++ b/apps/docs/src/utils/__tests__/isTypeAlias.test.ts @@ -0,0 +1,49 @@ +const isTypeAlias = require('../isTypeAlias'); + +describe('isTypeAlias', () => { + function prop(raw: string, value: unknown[]) { + return { type: { raw, value } }; + } + + it('returns true for uppercase raw name with 2+ values', () => { + expect(isTypeAlias(prop('SpacingScale', [{ value: '0' }, { value: '1' }]))).toBe(true); + }); + + it('returns true for uppercase raw name with many values', () => { + expect( + isTypeAlias(prop('IconName', [{ value: 'add' }, { value: 'remove' }, { value: 'search' }])), + ).toBe(true); + }); + + it('returns false when raw starts with lowercase', () => { + expect(isTypeAlias(prop('spacingScale', [{ value: '0' }, { value: '1' }]))).toBe(false); + }); + + it('returns false when raw contains a pipe (union type literal)', () => { + expect(isTypeAlias(prop('SpacingScale | number', [{ value: '0' }, { value: '1' }]))).toBe( + false, + ); + }); + + it('returns false when value has fewer than 2 items', () => { + expect(isTypeAlias(prop('SpacingScale', [{ value: '0' }]))).toBe(false); + }); + + it('returns false when value is empty', () => { + expect(isTypeAlias(prop('SpacingScale', []))).toBe(false); + }); + + it('returns false when value is not an array', () => { + expect(isTypeAlias({ type: { raw: 'SpacingScale', value: 'string' } })).toBe(false); + }); + + it('returns false for empty raw string', () => { + expect(isTypeAlias(prop('', [{ value: '0' }, { value: '1' }]))).toBe(false); + }); + + it('returns false when raw is undefined', () => { + expect(isTypeAlias({ type: { raw: undefined, value: [{ value: 'a' }, { value: 'b' }] } })).toBe( + false, + ); + }); +}); diff --git a/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts b/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts new file mode 100644 index 0000000000..a74823ebb2 --- /dev/null +++ b/apps/docs/src/utils/__tests__/shouldAddToParentTypes.test.ts @@ -0,0 +1,88 @@ +const shouldAddToParentTypes = require('../shouldAddToParentTypes'); + +function doc(displayName: string) { + return { displayName }; +} + +function prop(name: string, parent: string, required = false) { + return { name, parent, required }; +} + +describe('shouldAddToParentTypes', () => { + describe('required props are always kept in the props table', () => { + it('returns false for required props even with parent type match', () => { + expect(shouldAddToParentTypes(doc('Button'), prop('label', 'HTMLAttributes', true))).toBe( + false, + ); + }); + }); + + describe('always-included prop names', () => { + const alwaysIncluded = ['onChange', 'onPress', 'testID', 'type', 'value']; + + it.each(alwaysIncluded)('keeps %s in props table regardless of parent', (name) => { + expect(shouldAddToParentTypes(doc('Input'), prop(name, 'HTMLAttributes'))).toBe(false); + }); + }); + + describe('layout parent types', () => { + const layoutParents = [ + 'BorderedStyles', + 'BoxBaseProps', + 'DimensionStyles', + 'FlexProps', + 'PositionStyles', + 'SpacingProps', + ]; + + it.each(layoutParents)('moves %s props to parent types for non-Box components', (parent) => { + expect(shouldAddToParentTypes(doc('VStack'), prop('gap', parent))).toBe(true); + }); + + it.each(layoutParents)('keeps %s props in table for Box component', (parent) => { + expect(shouldAddToParentTypes(doc('Box'), prop('gap', parent))).toBe(false); + }); + }); + + describe('pressable parent types', () => { + const pressableParents = ['LinkableProps', 'PressableProps', 'Touchable']; + + it.each(pressableParents)( + 'moves %s props to parent types for non-Pressable components', + (parent) => { + expect(shouldAddToParentTypes(doc('Button'), prop('onPressIn', parent))).toBe(true); + }, + ); + + it.each(pressableParents)('keeps %s props in table for Pressable component', (parent) => { + expect(shouldAddToParentTypes(doc('Pressable'), prop('onPressIn', parent))).toBe(false); + }); + }); + + describe('always-moved parent types', () => { + const alwaysMoved = [ + 'AccessibilityProps', + 'AriaAttributes', + 'ComponentEventHandlerProps', + 'DOMAttributes', + 'GestureResponderHandlers', + 'HTMLAttributes', + 'TVViewProps', + 'ViewProps', + ]; + + it.each(alwaysMoved)('moves %s props to parent types', (parent) => { + expect(shouldAddToParentTypes(doc('Button'), prop('aria-label', parent))).toBe(true); + }); + }); + + describe('custom component props stay in table', () => { + it('returns false for non-matching parent types', () => { + expect(shouldAddToParentTypes(doc('Button'), prop('variant', 'ButtonProps'))).toBe(false); + }); + + it('returns false for component-specific parents', () => { + expect(shouldAddToParentTypes(doc('Avatar'), prop('size', 'AvatarBaseProps'))).toBe(false); + }); + }); +}); diff --git a/apps/docs/static/img/componentCardBanners/graphs_dark.svg b/apps/docs/static/img/componentCardBanners/charts_dark.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_dark.svg rename to apps/docs/static/img/componentCardBanners/charts_dark.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_dark_hover.svg b/apps/docs/static/img/componentCardBanners/charts_dark_hover.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_dark_hover.svg rename to apps/docs/static/img/componentCardBanners/charts_dark_hover.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_light.svg b/apps/docs/static/img/componentCardBanners/charts_light.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_light.svg rename to apps/docs/static/img/componentCardBanners/charts_light.svg diff --git a/apps/docs/static/img/componentCardBanners/graphs_light_hover.svg b/apps/docs/static/img/componentCardBanners/charts_light_hover.svg similarity index 100% rename from apps/docs/static/img/componentCardBanners/graphs_light_hover.svg rename to apps/docs/static/img/componentCardBanners/charts_light_hover.svg diff --git a/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts b/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts new file mode 100644 index 0000000000..c660e7ff64 --- /dev/null +++ b/apps/docs/utils/__tests__/generateComponentPeerDeps.test.ts @@ -0,0 +1,93 @@ +import { loadPeerDependencyVersions, syncDependencyVersions } from '../generateComponentPeerDeps'; + +describe('syncDependencyVersions', () => { + const peerDepVersions = new Map([ + ['react-native-reanimated', '^3.14.0'], + ['react-native-gesture-handler', '^2.16.2'], + ['framer-motion', '^10.18.0'], + ['react-dom', '^18.3.1'], + ]); + + it('updates stale versions to match current peerDependencies', () => { + const deps = [{ name: 'framer-motion', version: '^9.0.0' }]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([{ name: 'framer-motion', version: '^10.18.0' }]); + expect(warnings).toEqual([]); + }); + + it('leaves up-to-date versions unchanged', () => { + const deps = [{ name: 'framer-motion', version: '^10.18.0' }]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual(deps); + }); + + it('syncs multiple dependencies at once', () => { + const deps = [ + { name: 'framer-motion', version: '^9.0.0' }, + { name: 'react-dom', version: '^17.0.0' }, + ]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([ + { name: 'framer-motion', version: '^10.18.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]); + }); + + it('warns and preserves deps not found in peerDependencies', () => { + const deps = [{ name: 'unknown-package', version: '^1.0.0' }]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([{ name: 'unknown-package', version: '^1.0.0' }]); + expect(warnings).toEqual([ + 'unknown-package is not listed in any package.json peerDependencies', + ]); + }); + + it('returns empty array for empty dependencies', () => { + const { synced, warnings } = syncDependencyVersions([], peerDepVersions); + expect(synced).toEqual([]); + expect(warnings).toEqual([]); + }); + + it('handles mix of known and unknown deps', () => { + const deps = [ + { name: 'framer-motion', version: '^9.0.0' }, + { name: 'not-a-real-dep', version: '^1.0.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]; + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + expect(synced).toEqual([ + { name: 'framer-motion', version: '^10.18.0' }, + { name: 'not-a-real-dep', version: '^1.0.0' }, + { name: 'react-dom', version: '^18.3.1' }, + ]); + expect(warnings).toHaveLength(1); + }); + + it('preserves extra fields on dependency objects', () => { + const deps = [{ name: 'framer-motion', version: '^9.0.0', url: 'https://example.com' }]; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + expect(synced[0]).toEqual({ + name: 'framer-motion', + version: '^10.18.0', + url: 'https://example.com', + }); + }); +}); + +describe('loadPeerDependencyVersions', () => { + it('loads versions from real package.json files', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.size).toBeGreaterThan(0); + expect(versions.get('react')).toBeDefined(); + }); + + it('includes mobile-specific peer deps', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.get('react-native')).toBeDefined(); + }); + + it('includes web-specific peer deps', () => { + const versions = loadPeerDependencyVersions(); + expect(versions.get('framer-motion')).toBeDefined(); + }); +}); diff --git a/apps/docs/utils/generateComponentPeerDeps.ts b/apps/docs/utils/generateComponentPeerDeps.ts index 9ceb55e7c8..4a009a0815 100644 --- a/apps/docs/utils/generateComponentPeerDeps.ts +++ b/apps/docs/utils/generateComponentPeerDeps.ts @@ -5,267 +5,185 @@ import path from 'path'; import type { Dependency } from '../src/components/page/Metadata'; -type ComponentPeerDeps = { - [componentName: string]: { - filePath: string; - peerDependencies: Dependency[]; - exportPath: string; - }; +type PackageConfig = { + packageName: string; + packageDir: string; }; -type PackageAnalysis = { - web: ComponentPeerDeps; - mobile: ComponentPeerDeps; -}; - -const PEER_DEPS_TO_IGNORE = ['react', 'react-native']; - -function extractImports(fileContent: string): string[] { - const importRegex = /import[\s\S]*?from\s+['"]([^'"]+)['"]/g; - const imports: string[] = []; - let match; - - while ((match = importRegex.exec(fileContent)) !== null) { - imports.push(match[1]); - } - - return imports; -} - -function getPackageName(importPath: string): string { - if (importPath.startsWith('@')) { - const parts = importPath.split('/'); - return parts.length > 1 ? `${parts[0]}/${parts[1]}` : parts[0]; +const PACKAGES: PackageConfig[] = [ + { packageName: '@coinbase/cds-web', packageDir: 'packages/web' }, + { packageName: '@coinbase/cds-mobile', packageDir: 'packages/mobile' }, + { packageName: '@coinbase/cds-web-visualization', packageDir: 'packages/web-visualization' }, + { + packageName: '@coinbase/cds-mobile-visualization', + packageDir: 'packages/mobile-visualization', + }, +]; + +/** + * Build a combined map of peer dependency versions from all known packages. + * Keys are dependency names, values are the version range from package.json. + */ +function loadPeerDependencyVersions(): Map { + const versions = new Map(); + for (const pkg of PACKAGES) { + try { + const packageJson = JSON.parse(fs.readFileSync(`${pkg.packageDir}/package.json`, 'utf-8')); + const peerDeps: Record = packageJson.peerDependencies ?? {}; + for (const [name, version] of Object.entries(peerDeps)) { + versions.set(name, version); + } + } catch { + // skip if package.json can't be read + } } - return importPath.split('/')[0]; + return versions; } -function isExternalDependency(importPath: string): boolean { - return !importPath.startsWith('.') && !importPath.startsWith('/'); +/** + * Given a metadata object with a `dependencies` array and the current peer + * dependency version map, return a copy with versions synced. Only updates + * versions for deps already listed -- never adds or removes entries. + */ +function syncDependencyVersions( + dependencies: Dependency[], + peerDepVersions: Map, +): { synced: Dependency[]; warnings: string[] } { + const warnings: string[] = []; + const synced = dependencies.map((dep) => { + const currentVersion = peerDepVersions.get(dep.name); + if (!currentVersion) { + warnings.push(`${dep.name} is not listed in any package.json peerDependencies`); + return dep; + } + return { ...dep, version: currentVersion }; + }); + return { synced, warnings }; } -function getExportPath(filePath: string, platform: 'web' | 'mobile'): string { - const srcPath = `packages/${platform}/src/`; - const relativePath = filePath.replace(srcPath, ''); - const dir = path.dirname(relativePath); - return dir === '.' ? '' : `/${dir}`; -} +type MetadataFileResult = { + filePath: string; + updated: boolean; + warnings: string[]; +}; -async function analyzePackageForDocs( - packagePath: string, - platform: 'web' | 'mobile', - packageJson: any, -): Promise { - const packagePeerDependencies = packageJson.peerDependencies; - const componentFiles = await glob(`${packagePath}/src/**/*.tsx`, { - ignore: ['**/__tests__/**', '**/__stories__/**', '**/index.ts'], - }); +async function updateMetadataFiles(peerDepVersions: Map): Promise { + console.log('Syncing peer dependency versions in metadata files...'); - const results: ComponentPeerDeps = {}; + const metadataFiles = await glob('apps/docs/docs/components/**/*Metadata.json'); + const results: MetadataFileResult[] = []; - for (const filePath of componentFiles) { + for (const metadataFile of metadataFiles) { try { - const fileContent = fs.readFileSync(filePath, 'utf-8'); - const imports = extractImports(fileContent); - - const componentName = path.basename(filePath, '.tsx'); - const peerDependencies: Dependency[] = []; - - for (const importPath of imports) { - if (isExternalDependency(importPath)) { - const packageName = getPackageName(importPath); - if ( - Object.keys(packagePeerDependencies).includes(packageName) && - !PEER_DEPS_TO_IGNORE.includes(packageName) - ) { - peerDependencies.push({ - name: packageName, - version: packagePeerDependencies[packageName], - }); - } - } - } + const fileName = path.basename(metadataFile); + if (fileName !== 'webMetadata.json' && fileName !== 'mobileMetadata.json') continue; - // Only include components that are actually exported - const exportPath = getExportPath(filePath, platform); - const hasExport = - packageJson.exports && - (packageJson.exports[`.${exportPath}`] || - packageJson.exports[`.${exportPath}/${componentName}`]); - - if (hasExport || peerDependencies.length > 0) { - results[componentName] = { - filePath, - peerDependencies: peerDependencies.sort((a, b) => a.name.localeCompare(b.name)), - exportPath: exportPath || 'root', - }; + const raw = fs.readFileSync(metadataFile, 'utf-8'); + const metadata = JSON.parse(raw); + const deps: Dependency[] = metadata.dependencies ?? []; + if (deps.length === 0) continue; + + const { synced, warnings } = syncDependencyVersions(deps, peerDepVersions); + const changed = JSON.stringify(deps) !== JSON.stringify(synced); + + if (changed) { + metadata.dependencies = synced; + fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2) + '\n'); } + + results.push({ filePath: metadataFile, updated: changed, warnings }); } catch (error) { - console.error(`Error analyzing ${filePath}:`, error); + console.error(`Error processing ${metadataFile}:`, error); } } - return results; -} + const updatedCount = results.filter((r) => r.updated).length; + const warningResults = results.filter((r) => r.warnings.length > 0); -function generateDocumentationTable(analysis: PackageAnalysis): string { - let documentation = '# Component Peer Dependencies\n\n'; - documentation += - 'This document lists the peer dependencies required for each component when importing individually.\n\n'; - - // Web Components - documentation += '## Web Components (@coinbase/cds-web)\n\n'; - documentation += '| Component | Import Path | Peer Dependencies |\n'; - documentation += '|-----------|-------------|-------------------|\n'; - - const webComponents = Object.entries(analysis.web).sort(([a], [b]) => a.localeCompare(b)); - for (const [componentName, info] of webComponents) { - const importPath = `@coinbase/cds-web${info.exportPath === 'root' ? '' : info.exportPath}`; - const peerDeps = - info.peerDependencies.length > 0 - ? info.peerDependencies.map((d) => `${d.name}@${d.version}`).join(', ') - : 'react'; - documentation += `| ${componentName} | \`${importPath}\` | ${peerDeps} |\n`; - } + console.log(`\nVersion sync complete:`); + console.log(`- Files updated: ${updatedCount}`); - // Mobile Components - documentation += '\n## Mobile Components (@coinbase/cds-mobile)\n\n'; - documentation += '| Component | Import Path | Peer Dependencies |\n'; - documentation += '|-----------|-------------|-------------------|\n'; - - const mobileComponents = Object.entries(analysis.mobile).sort(([a], [b]) => a.localeCompare(b)); - for (const [componentName, info] of mobileComponents) { - const importPath = `@coinbase/cds-mobile${info.exportPath === 'root' ? '' : info.exportPath}`; - const peerDeps = - info.peerDependencies.length > 0 - ? info.peerDependencies.map((d) => `${d.name}@${d.version}`).join(', ') - : 'react'; - documentation += `| ${componentName} | \`${importPath}\` | ${peerDeps} |\n`; + if (warningResults.length > 0) { + console.warn(`\nWarnings:`); + for (const r of warningResults) { + for (const w of r.warnings) { + console.warn(` ${r.filePath}: ${w}`); + } + } } - - return documentation; } -function generateJSONOutput(analysis: PackageAnalysis): string { - return JSON.stringify(analysis, null, 2); -} +async function checkMetadataFiles(peerDepVersions: Map): Promise { + console.log('Checking metadata files for outdated peer dependency versions...'); -async function updateMetadataFiles(analysis: PackageAnalysis): Promise { - console.log('Updating metadata files with peer dependency information...'); - - // Find all metadata files in the docs directory const metadataFiles = await glob('apps/docs/docs/components/**/*Metadata.json'); - - let updatedFiles = 0; - let notFoundComponents = 0; + const outdatedFiles: string[] = []; for (const metadataFile of metadataFiles) { try { - const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8')); const fileName = path.basename(metadataFile); - const isWeb = fileName === 'webMetadata.json'; - const isMobile = fileName === 'mobileMetadata.json'; + if (fileName !== 'webMetadata.json' && fileName !== 'mobileMetadata.json') continue; - if (!isWeb && !isMobile) continue; + const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8')); + const deps: Dependency[] = metadata.dependencies ?? []; + if (deps.length === 0) continue; - // Extract component name from the import statement - const importMatch = metadata.import?.match(/import\s*{\s*([^}]+)\s*}/); - if (!importMatch) { - console.warn(`Could not extract component name from: ${metadataFile}`); - continue; + const { synced } = syncDependencyVersions(deps, peerDepVersions); + if (JSON.stringify(deps) !== JSON.stringify(synced)) { + outdatedFiles.push(metadataFile); } + } catch { + // skip unparseable files + } + } - const componentName = importMatch[1].trim(); - const platform = isWeb ? 'web' : 'mobile'; - const componentData = analysis[platform][componentName]; - - if (componentData) { - // Add peer dependencies to metadata - metadata.dependencies = componentData.peerDependencies; - - // Write back to file with pretty formatting - fs.writeFileSync(metadataFile, JSON.stringify(metadata, null, 2) + '\n'); - updatedFiles++; - } else { - console.warn(`Component ${componentName} not found in ${platform} analysis`); - notFoundComponents++; - } - } catch (error) { - console.error(`Error updating ${metadataFile}:`, error); + if (outdatedFiles.length > 0) { + console.error( + `\n${outdatedFiles.length} metadata file(s) have outdated peer dependency versions:`, + ); + for (const file of outdatedFiles) { + console.error(` - ${file}`); } + console.error('\nRun "yarn nx run docs:peer-dependencies" to update them.'); + return false; } - console.log(`\nMetadata update complete:`); - console.log(`- Files updated: ${updatedFiles}`); - console.log(`- Components not found: ${notFoundComponents}`); + console.log('\nAll metadata files have up-to-date versions.'); + return true; } async function main(): Promise { - const shouldUpdateMetadata = await input({ - message: 'Should update metadata files? (y/n)', - default: 'y', - validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', - }); - const shouldGenerateReportFiles = await input({ - message: 'Should generate report files? (y/n)', - default: 'y', - validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', - }); + const ciMode = process.argv.includes('--ci'); + const checkMode = process.argv.includes('--fail-on-changes'); + + let shouldUpdate = 'y'; - console.log('Analyzing component peer dependencies for documentation...'); + if (!ciMode && !checkMode) { + shouldUpdate = await input({ + message: 'Sync peer dependency versions in metadata files? (y/n)', + default: 'y', + validate: (value: string) => ['y', 'n'].includes(value) || 'Please enter y or n', + }); + } - const webPackageJson = JSON.parse(fs.readFileSync('packages/web/package.json', 'utf-8')); - const mobilePackageJson = JSON.parse(fs.readFileSync('packages/mobile/package.json', 'utf-8')); + const peerDepVersions = loadPeerDependencyVersions(); - // Analyze peer dependencies for each component in both packages - const webAnalysis = await analyzePackageForDocs('packages/web', 'web', webPackageJson); - const mobileAnalysis = await analyzePackageForDocs( - 'packages/mobile', - 'mobile', - mobilePackageJson, + console.log( + `Loaded ${peerDepVersions.size} peer dependency versions from ${PACKAGES.length} packages.`, ); - const analysis: PackageAnalysis = { - web: webAnalysis, - mobile: mobileAnalysis, - }; - - if (shouldGenerateReportFiles === 'y') { - // Generate documentation - const docsContent = generateDocumentationTable(analysis); - fs.writeFileSync('component-peer-dependencies.md', docsContent); - const jsonContent = generateJSONOutput(analysis); - fs.writeFileSync('component-peer-dependencies.json', jsonContent); + if (checkMode) { + const passed = await checkMetadataFiles(peerDepVersions); + process.exit(passed ? 0 : 1); } - if (shouldUpdateMetadata === 'y') { - // Update metadata files - await updateMetadataFiles(analysis); + if (shouldUpdate === 'y') { + await updateMetadataFiles(peerDepVersions); } - - // Print summary - console.log('\nDocumentation generated:'); - console.log('- component-peer-dependencies.md'); - console.log('- component-peer-dependencies.json'); - - console.log(`\nSummary:`); - console.log(`- Web components analyzed: ${Object.keys(webAnalysis).length}`); - console.log(`- Mobile components analyzed: ${Object.keys(mobileAnalysis).length}`); - - const webPeerDeps = new Set( - Object.values(webAnalysis).flatMap((c) => c.peerDependencies.map((d) => d.name)), - ); - const mobilePeerDeps = new Set( - Object.values(mobileAnalysis).flatMap((c) => c.peerDependencies.map((d) => d.name)), - ); - - console.log(`\nUnique peer dependencies:`); - console.log(`- Web: ${Array.from(webPeerDeps).join(', ')}`); - console.log(`- Mobile: ${Array.from(mobilePeerDeps).join(', ')}`); } if (require.main === module) { main().catch(console.error); } -export { analyzePackageForDocs, generateDocumentationTable }; +export { loadPeerDependencyVersions, syncDependencyVersions }; diff --git a/apps/mobile-app/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/libs/docusaurus-plugin-docgen/package.json b/libs/docusaurus-plugin-docgen/package.json index 40a6978902..5e988e810a 100644 --- a/libs/docusaurus-plugin-docgen/package.json +++ b/libs/docusaurus-plugin-docgen/package.json @@ -38,8 +38,8 @@ "type-fest": "^2.19.0" }, "dependencies": { - "@docusaurus/logger": "^3.7.0", - "@docusaurus/utils": "^3.7.0", + "@docusaurus/logger": "~3.7.0", + "@docusaurus/utils": "~3.7.0", "ejs": "^3.1.7", "react-docgen-typescript": "^2.4.0" }, @@ -48,7 +48,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "@docusaurus/types": "^3.7.0", + "@docusaurus/types": "~3.7.0", "@types/ejs": "^3.1.0", "@types/lodash": "^4.14.178" } diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts new file mode 100644 index 0000000000..ca60844dcc --- /dev/null +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.test.ts @@ -0,0 +1,86 @@ +import type { Doc } from '../types'; + +import { formatPropItemType, formatString, getDocExample } from './docgenParser'; + +describe('formatString', () => { + it('removes single and double quotes', () => { + expect(formatString(`"hello" 'world'`)).toBe('hello world'); + }); + + it('replaces newlines with spaces', () => { + expect(formatString('line1\nline2\nline3')).toBe('line1 line2 line3'); + }); + + it('removes backticks', () => { + expect(formatString('`code`')).toBe('code'); + }); + + it('handles all transformations together', () => { + expect(formatString(`"hello"\n'world' \`code\``)).toBe('hello world code'); + }); + + it('returns empty string for empty input', () => { + expect(formatString('')).toBe(''); + }); + + it('leaves normal text unchanged', () => { + expect(formatString('just plain text')).toBe('just plain text'); + }); +}); + +describe('formatPropItemType', () => { + it('simplifies ReactElement type', () => { + expect(formatPropItemType('ReactElement>')).toBe( + 'ReactElement', + ); + }); + + it('simplifies ReactNode union type', () => { + expect( + formatPropItemType( + 'Iterable | ReactElement> | ReactPortal | false | null | number | string | true | {}', + ), + ).toBe('ReactNode'); + }); + + it('simplifies Animated ViewStyle type', () => { + expect( + formatPropItemType( + 'false | RegisteredStyle | Value | AnimatedInterpolation | WithAnimatedObject | WithAnimatedArray<...> | null', + ), + ).toBe('Animated | ViewStyle'); + }); + + it('passes unrecognized types through formatString', () => { + expect(formatPropItemType('string')).toBe('string'); + expect(formatPropItemType('number | undefined')).toBe('number | undefined'); + }); + + it('cleans quotes and backticks from unrecognized types', () => { + expect(formatPropItemType(`"primary" | "secondary"`)).toBe('primary | secondary'); + }); +}); + +describe('getDocExample', () => { + function docWithExample(example?: string): Doc { + return { tags: example !== undefined ? { example } : undefined } as unknown as Doc; + } + + it('returns undefined when no tags exist', () => { + expect(getDocExample({ tags: undefined } as unknown as Doc)).toBeUndefined(); + }); + + it('returns undefined when no example tag exists', () => { + expect(getDocExample(docWithExample(undefined))).toBeUndefined(); + }); + + it('wraps plain code examples in tsx live fences', () => { + const example = ''; + expect(getDocExample(docWithExample(example))).toBe('```tsx live\n\n```'); + }); + + it('replaces tsx with tsx live in pre-fenced examples', () => { + const example = '```tsx\n\n```'; + expect(getDocExample(docWithExample(example))).toBe('```tsx live\n\n```'); + }); +}); diff --git a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts index fd5015050b..55c10d0f38 100644 --- a/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts +++ b/libs/docusaurus-plugin-docgen/src/scripts/docgenParser.ts @@ -493,14 +493,14 @@ function getDocParent({ declarations = [], parent }: PropItem) { return declaration ?? parent?.name ?? ''; } -function getDocExample(doc: Doc) { +export function getDocExample(doc: Doc) { if (!doc.tags?.example) return undefined; return doc.tags.example.includes('tsx') ? doc.tags.example.replaceAll('tsx', 'tsx live') : '```tsx live\n' + doc.tags.example + '\n```'; } -function formatPropItemType(value: string) { +export function formatPropItemType(value: string) { switch (value) { case 'ReactElement>': return 'ReactElement'; diff --git a/libs/docusaurus-plugin-kbar/package.json b/libs/docusaurus-plugin-kbar/package.json index 74914ed67e..6d32eeef4a 100644 --- a/libs/docusaurus-plugin-kbar/package.json +++ b/libs/docusaurus-plugin-kbar/package.json @@ -29,9 +29,9 @@ ], "dependencies": { "@coinbase/cds-common": "workspace:^", - "@docusaurus/logger": "^3.7.0", - "@docusaurus/plugin-content-docs": "^3.7.0", - "@docusaurus/types": "^3.7.0", + "@docusaurus/logger": "~3.7.0", + "@docusaurus/plugin-content-docs": "~3.7.0", + "@docusaurus/types": "~3.7.0", "kbar": "^0.1.0-beta.45", "lodash": "^4.17.21", "type-fest": "^2.19.0" diff --git a/libs/docusaurus-plugin-llm-dev-server/package.json b/libs/docusaurus-plugin-llm-dev-server/package.json index 9e93d62470..a5140a1d2e 100644 --- a/libs/docusaurus-plugin-llm-dev-server/package.json +++ b/libs/docusaurus-plugin-llm-dev-server/package.json @@ -27,7 +27,7 @@ "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", "@babel/preset-typescript": "^7.27.1", - "@docusaurus/types": "^3.7.0", + "@docusaurus/types": "~3.7.0", "@types/express": "^4.17.21" } } diff --git a/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/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index ccade5c2fd..7c4dc49d2b 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -8,6 +8,30 @@ 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. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 ((2/24/2026, 10:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.4 ((2/23/2026, 03:04 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + ## 8.47.2 ((2/19/2026, 03:18 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/common/package.json b/packages/common/package.json index 9a016cc3c6..3f6b9cbd29 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.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 7295b17606..75c8b42529 100644 --- a/packages/mcp-server/CHANGELOG.md +++ b/packages/mcp-server/CHANGELOG.md @@ -8,6 +8,30 @@ 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. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 ((2/24/2026, 10:33 AM PST)) + +This is an artificial version bump with no new change. + +## 8.47.4 ((2/23/2026, 03:04 PM PST)) + +This is an artificial version bump with no new change. + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + ## 8.47.2 ((2/19/2026, 03:18 PM PST)) This is an artificial version bump with no new change. diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 5ea99e2fce..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.47.2", + "version": "8.48.3", "description": "Coinbase Design System - MCP Server", "repository": { "type": "git", diff --git a/packages/mobile-visualization/CHANGELOG.md b/packages/mobile-visualization/CHANGELOG.md index 72f646363a..48842d56f9 100644 --- a/packages/mobile-visualization/CHANGELOG.md +++ b/packages/mobile-visualization/CHANGELOG.md @@ -8,6 +8,18 @@ All notable changes to this project will be documented in this file. +## Unreleased + +#### 📘 Misc + +- Update outdated doc links. [[#440](https://github.com/coinbase/cds/pull/440)] + +## 3.4.0-beta.19 (2/20/2026 PST) + +#### 🚀 Updates + +- Support custom enter transitions [[#400](https://github.com/coinbase/cds/pull/400/)] + ## 3.4.0-beta.18 (2/6/2026 PST) #### 🚀 Updates 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/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/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..74a1787182 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 & { @@ -88,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/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..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,12 +1,21 @@ -import { memo, useEffect, useState } from 'react'; -import { Button } from '@coinbase/cds-mobile/buttons'; -import { Example, ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; +import { memo, useCallback, useEffect, useId, useMemo, useState } from 'react'; +import { useDerivedValue } from 'react-native-reanimated'; +import { assets } from '@coinbase/cds-common/internal/data/assets'; +import { candles as btcCandles } from '@coinbase/cds-common/internal/data/candles'; +import { Button, IconButton } from '@coinbase/cds-mobile/buttons'; +import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen'; import { useTheme } from '@coinbase/cds-mobile/hooks/useTheme'; -import { VStack } from '@coinbase/cds-mobile/layout'; +import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout'; +import { Text } from '@coinbase/cds-mobile/typography'; +import { Line as SkiaLine, Rect } from '@shopify/react-native-skia'; import { XAxis, YAxis } from '../../axis'; -import { CartesianChart } from '../../CartesianChart'; -import { ReferenceLine, SolidLine, type SolidLineProps } from '../../line'; +import { CartesianChart, type CartesianChartProps } from '../../CartesianChart'; +import { useCartesianChartContext } from '../../ChartProvider'; +import { 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 +621,477 @@ 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 ( + + + + ); }; -export default BarChartStories; +const DAY_LENGTH_MINUTES = 1440; + +type SunlightChartData = Array<{ + label: string; + value: number; +}>; + +const sunlightData: SunlightChartData = [ + { label: 'Jan', value: 598 }, + { label: 'Feb', value: 635 }, + { label: 'Mar', value: 688 }, + { label: 'Apr', value: 753 }, + { label: 'May', value: 812 }, + { label: 'Jun', value: 855 }, + { label: 'Jul', value: 861 }, + { label: 'Aug', value: 828 }, + { label: 'Sep', value: 772 }, + { label: 'Oct', value: 710 }, + { label: 'Nov', value: 648 }, + { label: 'Dec', value: 605 }, +]; + +const SunlightChartInner = memo( + ({ + data, + height = 300, + ...props + }: Omit & { data: SunlightChartData }) => { + const theme = useTheme(); + + const SunlightThinSolidLine = memo((props: SolidLineProps) => ( + + )); + + return ( + value), + yAxisId: 'sunlight', + color: `rgb(${theme.spectrum.yellow40})`, + }, + { + id: 'day', + data: data.map(() => DAY_LENGTH_MINUTES), + yAxisId: 'day', + color: `rgb(${theme.spectrum.blue100})`, + }, + ]} + xAxis={{ + ...props.xAxis, + scaleType: 'band', + data: data.map(({ label }) => label), + }} + yAxis={[ + { + id: 'day', + domain: { min: 0, max: DAY_LENGTH_MINUTES }, + domainLimit: 'strict', + }, + { + id: 'sunlight', + domain: { min: 0, max: DAY_LENGTH_MINUTES }, + domainLimit: 'strict', + }, + ]} + > + + + + + + ); + }, +); + +const SunlightChart = () => { + return ( + + + + 2026 Sunlight data for the first day of each month in Atlanta, Georgia, provided by NOAA. + + + ); +}; + +const PriceRange = () => { + const candles = btcCandles.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; +}; + +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: , + }, + { + title: 'Price Range', + 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/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/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/mobile/CHANGELOG.md b/packages/mobile/CHANGELOG.md index d6b320056b..28d5b3cac4 100644 --- a/packages/mobile/CHANGELOG.md +++ b/packages/mobile/CHANGELOG.md @@ -8,6 +8,36 @@ 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 + +- Deprecate useStatusBarHeight hook. + +## 8.48.1 ((2/25/2026, 01:30 PM PST)) + +This is an artificial version bump with no new change. + +## 8.48.0 (2/24/2026 PST) + +#### 🚀 Updates + +- Add start/end icon/node support to Tag. [[#421](https://github.com/coinbase/cds/pull/421)] + +## 8.47.4 (2/23/2026 PST) + +#### 🐞 Fixes + +- Fix: set paddingStart on Input for compact label. [[#423](https://github.com/coinbase/cds/pull/423)] + +## 8.47.3 ((2/20/2026, 09:16 AM PST)) + +This is an artificial version bump with no new change. + ## 8.47.2 (2/19/2026 PST) #### 🐞 Fixes diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 7f4cec0b36..b5aee4161c 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.48.3", "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' }, 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; 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( + + +
+