feat(ds): Replace lottie colors in Spinner (#18001)

This commit is contained in:
Jan Václavík
2025-04-01 08:46:49 +02:00
committed by GitHub
parent 6c4b68b537
commit 8131c8d039
6 changed files with 165 additions and 31 deletions

View File

@@ -0,0 +1,79 @@
import { hexToRgbaArray } from '@trezor/utils';
// @ts-expect-error
type JsonValue = string | number | boolean | null | Record<string, JsonValue> | JsonValue[];
type LottieAnimation = JsonValue;
const areColorsClose = (a: number[], b: number[], tolerance = 0.01) =>
a.length === 4 && b.length === 4 && a.every((v, i) => Math.abs(v - b[i]) < tolerance);
export const recolorLottieAnimation = (
data: LottieAnimation,
replacements: { from: string; to: string }[] | null,
): LottieAnimation => {
if (replacements === null || replacements.length === 0) {
return data;
}
const colorPairs = replacements.map(({ from, to }) => ({
from: hexToRgbaArray(from),
to: hexToRgbaArray(to),
}));
const cloned = JSON.parse(JSON.stringify(data));
const findReplacement = (color: number[]): number[] | null => {
for (const { from, to } of colorPairs) {
if (areColorsClose(color, from)) {
return to;
}
}
return null;
};
const walk = (node: any) => {
if (Array.isArray(node)) {
// Find RGBA quartets in row (e.g. in gradients)
for (let i = 0; i <= node.length - 4; i++) {
const segment = node.slice(i, i + 4);
if (segment.every(n => typeof n === 'number')) {
const replacement = findReplacement(segment);
if (replacement) {
node.splice(i, 4, ...replacement);
i += 3;
}
}
}
node.forEach(walk);
} else if (typeof node === 'object' && node !== null) {
for (const key in node) {
// eslint-disable-next-line no-prototype-builtins
if (node.hasOwnProperty(key)) {
const value = node[key];
// Simple rgba color
if (
Array.isArray(value) &&
value.length === 4 &&
value.every(n => typeof n === 'number')
) {
const replacement = findReplacement(value);
if (replacement) {
node[key] = replacement;
continue;
}
}
walk(value);
}
}
}
};
walk(cloned);
return cloned;
};

View File

@@ -1,4 +1,4 @@
import { Meta, StoryObj } from '@storybook/react';
import { ArgTypes, Meta, StoryObj } from '@storybook/react';
import { Spinner as SpinnerComponent, SpinnerProps, allowedSpinnerFrameProps } from './Spinner';
import { getFramePropsStory } from '../../../utils/frameProps';
@@ -9,45 +9,54 @@ const meta: Meta = {
} as Meta;
export default meta;
export const Default: StoryObj<SpinnerProps> = {
args: {
size: 50,
...getFramePropsStory(allowedSpinnerFrameProps).args,
const args: Partial<SpinnerProps> | undefined = {
size: 50,
bodyColor: undefined,
warningBackgroundColor: undefined,
warningForegroundColor: undefined,
isGrey: false,
...getFramePropsStory(allowedSpinnerFrameProps).args,
};
const argTypes: Partial<ArgTypes<SpinnerProps>> | undefined = {
bodyColor: {
control: { type: 'color' },
},
argTypes: {
className: {
control: false,
warningBackgroundColor: {
control: { type: 'color' },
},
warningForegroundColor: {
control: { type: 'color' },
},
className: {
control: false,
},
isGrey: {
control: {
type: 'boolean',
},
...getFramePropsStory(allowedSpinnerFrameProps).argTypes,
},
...getFramePropsStory(allowedSpinnerFrameProps).argTypes,
};
export const Default: StoryObj<SpinnerProps> = {
args,
argTypes,
};
export const Success: StoryObj<SpinnerProps> = {
args: {
size: 50,
...args,
hasFinished: true,
hasStartAnimation: true,
...getFramePropsStory(allowedSpinnerFrameProps).args,
},
argTypes: {
className: {
control: false,
},
...getFramePropsStory(allowedSpinnerFrameProps).argTypes,
},
argTypes,
};
export const Error: StoryObj<SpinnerProps> = {
args: {
size: 50,
...args,
hasError: true,
hasStartAnimation: true,
...getFramePropsStory(allowedSpinnerFrameProps).args,
},
argTypes: {
className: {
control: false,
},
...getFramePropsStory(allowedSpinnerFrameProps).argTypes,
},
argTypes,
};

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import Lottie from 'lottie-react';
import styled from 'styled-components';
import styled, { useTheme } from 'styled-components';
import animationEnd from './animationData/refresh-spinner-end-success.json';
import animationWarn from './animationData/refresh-spinner-end-warning.json';
@@ -14,6 +14,7 @@ import {
withFrameProps,
} from '../../../utils/frameProps';
import { TransientProps } from '../../../utils/transientProps';
import { recolorLottieAnimation } from '../../animations/recolorLottieAnimation';
export const allowedSpinnerFrameProps = ['margin'] as const satisfies FramePropsKeys[];
type AllowedFrameProps = Pick<FrameProps, (typeof allowedSpinnerFrameProps)[number]>;
@@ -31,6 +32,12 @@ const StyledLottie = styled(Lottie)<
${withFrameProps}
`;
const ORIGIN_COLORS_IN_ANIMATION = {
BODY: '#00854DFF',
WARNING_BACKGROUND: '#f7bf2f',
WARNING_FOREGROUND: '#ffffff',
};
export type SpinnerProps = AllowedFrameProps & {
size?: number;
isGrey?: boolean;
@@ -39,6 +46,9 @@ export type SpinnerProps = AllowedFrameProps & {
hasError?: boolean;
className?: string;
'data-testid'?: string;
bodyColor?: string | null;
warningBackgroundColor?: string | null;
warningForegroundColor?: string | null;
};
export const Spinner = ({
@@ -49,8 +59,16 @@ export const Spinner = ({
hasError,
className,
'data-testid': dataTest,
bodyColor,
warningBackgroundColor,
warningForegroundColor,
...rest
}: SpinnerProps) => {
const theme = useTheme();
const defaultBodyColor = theme.baseContentBrand;
const defaultWarningColor = theme.baseContentWarning;
const defaultWarningForegroundColor = theme.baseContentReversePrimary;
const frameProps = pickAndPrepareFrameProps(rest, allowedSpinnerFrameProps);
const [hasStarted, setHasStarted] = useState(false);
@@ -60,30 +78,43 @@ export const Spinner = ({
setHasFinishedRotation(true);
};
// base/content/reverse-primary
const colorsReplace = [
{ from: ORIGIN_COLORS_IN_ANIMATION.BODY, to: bodyColor ?? defaultBodyColor },
{
from: ORIGIN_COLORS_IN_ANIMATION.WARNING_BACKGROUND,
to: warningBackgroundColor ?? defaultWarningColor,
},
{
from: ORIGIN_COLORS_IN_ANIMATION.WARNING_FOREGROUND,
to: warningForegroundColor ?? defaultWarningForegroundColor,
},
];
const getProps = () => {
if (hasFinished && hasFinishedRotation) {
return {
animationData: animationEnd,
animationData: recolorLottieAnimation(animationEnd, colorsReplace),
loop: false,
};
}
if (hasError && hasFinishedRotation) {
return {
animationData: animationWarn,
animationData: recolorLottieAnimation(animationWarn, colorsReplace),
loop: false,
};
}
if (hasStarted || !hasStartAnimation) {
return {
animationData: animationMiddle,
animationData: recolorLottieAnimation(animationMiddle, colorsReplace),
onLoopComplete,
};
}
return {
animationData: animationStart,
animationData: recolorLottieAnimation(animationStart, colorsReplace),
onComplete: () => setHasStarted(true),
loop: false,
};

View File

@@ -5,6 +5,9 @@ export { motionAnimation, motionEasing } from './config/motion';
export { Checkbox, type CheckboxProps } from './components/form/Checkbox/Checkbox';
export * from './components/animations/DeviceAnimation';
export * from './components/animations/LottieAnimation';
export { recolorLottieAnimation } from './components/animations/recolorLottieAnimation';
export { hexToRgbaArray } from '../../utils/src/hexToRgbaArray';
export { hexToRgba } from '../../utils/src/hexToRgba';
export { AssetLogo, type AssetLogoProps } from './components/AssetLogo/AssetLogo';
export * from './components/Flag/Flag';
export * from './components/AutoScalingInput/AutoScalingInput';

View File

@@ -0,0 +1,11 @@
export const hexToRgbaArray = (hex: string): number[] => {
const norm = hex.replace('#', '');
const full = norm.length === 6 ? norm + 'FF' : norm;
return [
parseInt(full.slice(0, 2), 16) / 255,
parseInt(full.slice(2, 4), 16) / 255,
parseInt(full.slice(4, 6), 16) / 255,
parseInt(full.slice(6, 8), 16) / 255,
];
};

View File

@@ -30,7 +30,8 @@ export * from './getWeakRandomId';
export * from './getWeakRandomInt';
export * from './getWeakRandomNumberInRange';
export * from './hasUppercaseLetter';
export * from './hexToRgba';
export { hexToRgba } from './hexToRgba';
export { hexToRgbaArray } from './hexToRgbaArray';
export * from './isArrayMember';
export * from './isFullPath';
export * from './isHex';