diff --git a/src/components/Loader/AnimatedPath.tsx b/src/components/Loader/AnimatedProgress.tsx similarity index 91% rename from src/components/Loader/AnimatedPath.tsx rename to src/components/Loader/AnimatedProgress.tsx index d7641586..00fd1760 100644 --- a/src/components/Loader/AnimatedPath.tsx +++ b/src/components/Loader/AnimatedProgress.tsx @@ -20,7 +20,7 @@ const animationBar = keyframes` } `; -export const AnimatedPath = styled(tet.path)<{ +export const AnimatedProgress = styled(tet.path)<{ shape: string; }>` aspect-ratio: 1; diff --git a/src/components/Loader/Loader.props.ts b/src/components/Loader/Loader.props.ts index 5a279fdd..b8b7432c 100644 --- a/src/components/Loader/Loader.props.ts +++ b/src/components/Loader/Loader.props.ts @@ -1,12 +1,10 @@ -import { LoaderConfig } from './Loader.styles'; +import type { LoaderConfig } from './Loader.styles'; import type { LoaderAppearance, LoaderShape, LoaderSize } from './types'; -import { DeepPartial } from '@/utility-types/DeepPartial'; - export type LoaderProps = { appearance?: LoaderAppearance; size?: LoaderSize; progress?: number; shape: LoaderShape; - custom?: DeepPartial; + custom?: LoaderConfig; }; diff --git a/src/components/Loader/Loader.styles.ts b/src/components/Loader/Loader.styles.ts index 02826f0f..708c7bcd 100644 --- a/src/components/Loader/Loader.styles.ts +++ b/src/components/Loader/Loader.styles.ts @@ -1,100 +1,130 @@ -import { SystemProps } from '@xstyled/styled-components'; -import { SvgProperties } from 'csstype'; +import { ThemeColor } from '@xstyled/styled-components'; +import type { Property } from 'csstype'; import type { LoaderAppearance, LoaderShape, LoaderSize } from './types'; +import { Theme } from '@/theme'; import { BaseProps } from '@/types/BaseProps'; +export type SVGProps = Omit< + BaseProps, + | 'opacity' + | 'display' + | 'order' + | 'cursor' + | 'pointerEvents' + | 'overflow' + | 'visibility' + | 'fill' + | 'transform' + | 'rotate' + | 'scale' + | 'stroke' + | 'fontFamily' + | 'fontSize' + | 'fontStyle' + | 'fontStyle' + | 'fontVariant' + | 'fontWeight' + | 'letterSpacing' + | 'textDecoration' +> & { + fill?: ThemeColor | Property.Fill; + strokeWidth?: Property.StrokeWidth; + strokeLinecap?: 'inherit' | 'round' | 'butt' | 'square'; +}; + export type LoaderConfig = { - size: Record< - LoaderShape, - Record> - >; - appearance: Record< - LoaderAppearance, - Record<'base' | 'progress', SystemProps> + shape?: Partial< + Record> }> >; - svg: SystemProps; - progress: SystemProps & Pick; + innerElements?: { + base?: { + appearance?: Partial>; + } & SVGProps; + progress?: { + appearance?: Partial>; + } & SVGProps; + }; } & BaseProps; export const defaultConfig = { - size: { + fill: 'none', + borderRadius: 'large', + shape: { circle: { - large: { - w: 48, - h: 48, - strokeWidth: '2', - }, - medium: { - w: 32, - h: 32, - strokeWidth: '2', - }, - small: { - w: 20, - h: 20, - strokeWidth: '2', + size: { + large: { + w: 48, + h: 48, + strokeWidth: '2', + }, + medium: { + w: 32, + h: 32, + strokeWidth: '2', + }, + small: { + w: 20, + h: 20, + strokeWidth: '2', + }, }, }, bar: { - large: { - w: 128, - h: 8, - strokeWidth: '8', - }, - medium: { - w: 128, - h: 6, - strokeWidth: '6', - }, - small: { - w: 128, - h: 4, - strokeWidth: '4', + size: { + large: { + w: 128, + h: 8, + strokeWidth: '8', + }, + medium: { + w: 128, + h: 6, + strokeWidth: '6', + }, + small: { + w: 128, + h: 4, + strokeWidth: '4', + }, }, }, }, - appearance: { - primary: { - base: { - stroke: 'interaction-neutral-subtle-normal', - }, - progress: { - stroke: 'interaction-default-normal', - }, - }, - inverted: { - base: { - stroke: 'interaction-inverted-normal', - }, - progress: { - stroke: 'interaction-default-normal', - }, - }, - white: { - base: { - stroke: 'interaction-inverted-normal', - opacity: 0.4, - }, - progress: { - stroke: 'interaction-inverted-normal', + innerElements: { + base: { + appearance: { + primary: { + stroke: 'interaction-neutral-subtle-normal', + }, + inverted: { + stroke: 'interaction-inverted-normal', + }, + white: { + stroke: 'interaction-inverted-normal', + opacity: 0.4, + }, + greyscale: { + stroke: 'interaction-neutral-subtle-normal', + }, }, }, - greyscale: { - base: { - stroke: 'interaction-neutral-subtle-normal', - }, - progress: { - stroke: 'interaction-neutral-normal', + progress: { + strokeLinecap: 'round', + appearance: { + primary: { + stroke: 'interaction-default-normal', + }, + inverted: { + stroke: 'interaction-default-normal', + }, + white: { + stroke: 'interaction-inverted-normal', + }, + greyscale: { + stroke: 'interaction-neutral-normal', + }, }, }, }, - svg: { - fill: 'none', - borderRadius: 'large', - }, - progress: { - strokeLinecap: 'round', - }, } satisfies LoaderConfig; diff --git a/src/components/Loader/Loader.test.tsx b/src/components/Loader/Loader.test.tsx index c16b0a28..8b22eac0 100644 --- a/src/components/Loader/Loader.test.tsx +++ b/src/components/Loader/Loader.test.tsx @@ -1,6 +1,8 @@ import { Loader } from './Loader'; import { render } from '../../tests/render'; +import { customPropTester } from '@/tests/customPropTester'; + const getLoader = (jsx: JSX.Element) => { const { queryByTestId } = render(jsx); @@ -12,6 +14,20 @@ const getLoader = (jsx: JSX.Element) => { }; describe('Loader', () => { + customPropTester(, { + containerId: 'loader', + props: { + size: ['small', 'medium', 'big'], + appearance: ['primary', 'white', 'inverted', 'greyscale'], + shape: ['circle', 'bar'], + }, + innerElements: { + _: [['shape', 'size']], + base: ['appearance'], + progress: ['appearance'], + }, + }); + it('should render the loader', () => { const { loader } = getLoader(); expect(loader).toBeInTheDocument(); diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx index eca55cd8..94049644 100644 --- a/src/components/Loader/Loader.tsx +++ b/src/components/Loader/Loader.tsx @@ -1,20 +1,21 @@ import { MarginProps } from '@xstyled/styled-components'; -import { useMemo } from 'react'; +import { FC, useMemo } from 'react'; -import { AnimatedPath } from './AnimatedPath'; +import { AnimatedProgress } from './AnimatedProgress'; import { LoaderProps } from './Loader.props'; import { stylesBuilder } from './stylesBuilder'; import { tet } from '@/tetrisly'; -export const Loader = ({ +export const Loader: FC = ({ appearance = 'primary', progress, shape, size = 'medium', custom, -}: LoaderProps & MarginProps) => { - const { svgStyles, baseStyles, progressStyles } = useMemo( + ...restProps +}) => { + const styles = useMemo( () => stylesBuilder({ appearance, @@ -25,21 +26,23 @@ export const Loader = ({ }), [appearance, progress, shape, size, custom], ); + return ( - + {progress === undefined ? ( - ) : ( - + )} ); diff --git a/src/components/Loader/stylesBuilder/stylesBuilder.ts b/src/components/Loader/stylesBuilder/stylesBuilder.ts index 0c08fda6..a27d5cae 100644 --- a/src/components/Loader/stylesBuilder/stylesBuilder.ts +++ b/src/components/Loader/stylesBuilder/stylesBuilder.ts @@ -1,5 +1,5 @@ import { LoaderProps } from '../Loader.props'; -import { defaultConfig } from '../Loader.styles'; +import { defaultConfig, SVGProps } from '../Loader.styles'; import { mergeConfigWithCustom } from '@/services'; @@ -8,27 +8,35 @@ type StylesBuilderProps = Omit, 'custom' | 'progress'> & { progress: LoaderProps['progress']; }; -function polarToCartesian( +type LoaderStylesBuilder = { + container: SVGProps; + base: SVGProps; + progress: SVGProps; +}; + +const ANIMATED_PROGRESS_VALUE = 0.4; + +const polarToCartesian = ( centerX: number, centerY: number, radius: number, angleInDegrees: number, -) { +) => { const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; return { x: centerX + radius * Math.cos(angleInRadians), y: centerY + radius * Math.sin(angleInRadians), }; -} +}; -function describeArc( +const describeArc = ( x: number, y: number, radius: number, startAngle: number, endAngle: number, -) { +) => { const start = polarToCartesian(x, y, radius, endAngle); const end = polarToCartesian(x, y, radius, startAngle); const largeArcFlag = endAngle - startAngle <= 180 ? '0' : '1'; @@ -48,52 +56,67 @@ function describeArc( ].join(' '); return d; -} +}; -export const stylesBuilder = ({ custom, ...props }: StylesBuilderProps) => { +export const stylesBuilder = ({ + custom, + ...props +}: StylesBuilderProps): LoaderStylesBuilder => { const config = mergeConfigWithCustom({ defaultConfig, custom }); - const size = config.size[props.shape][props.size]; - const { w, h, ...restSizeStyles } = size; - const svgSizeStyles = { - ...size, + const { + shape, + innerElements: { base, progress }, + ...restContainerStyles + } = config; + + const sizeStyles = shape[props.shape].size[props.size]; + const { w, h, ...restSizeStyles } = sizeStyles; + const containerSizeStyles = { + ...sizeStyles, viewBox: `0 0 ${w} ${h}`, }; - const progress = Math.min(Math.max(props.progress ?? 0.4, 0), 1); + const progressValue = Math.min( + Math.max(props.progress ?? ANIMATED_PROGRESS_VALUE, 0), + 1, + ); - const baseSizeStyles = { + const basePathStyles = { d: props.shape === 'circle' ? describeArc(w / 2, w / 2, w / 2 - 3, 0, 359.99) : `M 0 ${h / 2} H ${w}`, }; - const progressSizeStyles = { + const progressPathStyles = { d: props.shape === 'circle' - ? describeArc(w / 2, w / 2, w / 2 - 3, 0, 359.99 * progress) - : `M 0 ${h / 2} H ${progress * w}`, + ? describeArc(w / 2, w / 2, w / 2 - 3, 0, 359.99 * progressValue) + : `M 0 ${h / 2} H ${progressValue * w}`, }; - const svgStyles = { - ...svgSizeStyles, - ...config.svg, - }; + const { appearance: baseAppearanceStyles, ...restBaseStyles } = base; - const baseStyles = { - ...baseSizeStyles, - ...restSizeStyles, - ...config.appearance[props.appearance].base, - ...config.progress, - }; + const { appearance: progressAppearanceStyles, ...restProgressStyles } = + progress; - const progressStyles = { - ...progressSizeStyles, - ...restSizeStyles, - ...config.appearance[props.appearance].progress, - ...config.progress, + return { + container: { + ...containerSizeStyles, + ...restContainerStyles, + }, + base: { + ...basePathStyles, + ...restSizeStyles, + ...baseAppearanceStyles[props.appearance], + ...restBaseStyles, + }, + progress: { + ...progressPathStyles, + ...restSizeStyles, + ...progressAppearanceStyles[props.appearance], + ...restProgressStyles, + }, }; - - return { svgStyles, baseStyles, progressStyles }; };