Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.host {
display: flex;
align-items: center;
justify-content: center;
inline-size: 100%;
block-size: 100%;
color: var(--vkui--color_icon_medium);
will-change: contents;
}

.noColor {
color: currentColor;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { withCartesian } from '@project-tools/storybook-addon-cartesian';
import type { Meta, StoryObj } from '@storybook/react';
import { CanvasFullLayout } from '../../../storybook/constants';
import { createStoryParameters } from '../../../testing/storybook/createStoryParameters';
import { type MaterialSpinnerProps, Spinner } from './Spinner';

const story: Meta<MaterialSpinnerProps> = {
title: 'Blocks/Spinner/Expressive',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
title: 'Blocks/Spinner/Expressive',
title: 'Feedback/Spinner/Expressive',

component: Spinner,
parameters: createStoryParameters('Spinner', CanvasFullLayout),
decorators: [withCartesian],
};

export default story;

type Story = StoryObj<MaterialSpinnerProps>;

export const Playground: Story = {};
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { baselineComponent } from '../../../testing/utils';
import { Spinner } from './Spinner';

describe('Spinner', () => {
baselineComponent((props) => <Spinner disableAnimation {...props} />);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import { classNames, hasReactNode } from '@vkontakte/vkjs';
import { usePlatform } from '../../../hooks/usePlatform';
import * as shapes from '../../../lib/material/shapes/shapes';
import { animationVisibilityDelayStyles } from '../../../styles/animationVisibilityDelay';
import { RootComponent } from '../../RootComponent/RootComponent';
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';
import { Spinner as SimpleSpinner, type SpinnerProps } from '../Spinner';
import { IconMaterial } from './icons';
import styles from './Spinner.module.css';
import stylesDelay from '../../../styles/animationVisibilityDelay.module.css';

const iconSizeMap = {
s: 16,
m: 24,
l: 32,
xl: 44,
} as const;

const defaultShapesList = [
shapes.softBurstParams,
shapes.cookie9Params,
shapes.pentagonParams,
shapes.pillParams,
shapes.sunnyParams,
shapes.cookie4Params,
shapes.ovalParams,
] as const;

export interface MaterialSpinnerProps extends SpinnerProps {
/**
* Последовательность форм между которыми будет происходить анимация.
*/
polygons?: readonly [shapes.ShapeParameters, shapes.ShapeParameters, ...shapes.ShapeParameters[]];
}

function MaterialSpinner({
polygons = defaultShapesList,
size = 'm',
children = 'Загружается...',
disableAnimation = false,
noColor = false,
visibilityDelay,
...restProps
}: MaterialSpinnerProps) {
const iconSize = iconSizeMap[size];

return (
<RootComponent
Component="span"
role="status"
{...restProps}
baseClassName={classNames(
styles.host,
noColor && styles.noColor,
visibilityDelay && stylesDelay.visibilityDelay,
)}
baseStyle={animationVisibilityDelayStyles(visibilityDelay)}
>
<IconMaterial size={iconSize} polygons={polygons} disableAnimation={disableAnimation} />
{hasReactNode(children) && <VisuallyHidden>{children}</VisuallyHidden>}
</RootComponent>
);
}

export function Spinner(props: SpinnerProps) {
const platform = usePlatform();

const Component = platform === 'ios' ? SimpleSpinner : MaterialSpinner;

return <Component {...props} />;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
export const unstable_ExpressiveSpinner = Spinner;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Вроде обычно приставку unstable_ добавляем при экмпорте из пакета. Еще не совсем понял, почему в папке ExpressiveSpinner файлы с именем Spinner. Не будет ли путаницы при поиске?

104 changes: 104 additions & 0 deletions packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
'use client';

import * as React from 'react';
import { useAnimationFrame } from '../../../hooks/useAnimationFrame';
import { useReducedMotion } from '../../../lib/animation';
import * as shapes from '../../../lib/material/shapes/shapes';
import { interpolate } from '../../../lib/svg/path/interpolate';
import { svgPathToString } from '../../../lib/svg/path/path';
import * as operation from '../../../lib/svg/path/transform';
import { SvgIcon } from '../SvgIcon';

interface IconMaterialProps {
/**
* Список форм.
*/
polygons: readonly shapes.ShapeParameters[];
/**
* Размер иконки.
*/
size: number;
/**
* Отключение анимации.
*/
disableAnimation: boolean;
}

export function IconMaterial(props: IconMaterialProps) {
return (
<SvgIcon size={props.size}>
<IconMaterialPath {...props} />
</SvgIcon>
);
}

const globalRotationDuration = 4666;
const morphDuration = 200;
const morphInterval = 650;
const fullRotation = 360;
const quarterRotation = fullRotation / 4;

function calcProgress(startTime: number, time: number, duration: number, delay = 0) {
const fullDuration = duration + delay;

const timeProgress = fullDuration * (((time - startTime) % fullDuration) / fullDuration);

if (timeProgress < delay) {
return 0;
}

return (timeProgress - delay) / duration;
}

function IconMaterialPath({ size, polygons, disableAnimation }: IconMaterialProps) {
const ref = React.useRef<SVGPathElement>(null);

const morphSequence = React.useMemo(() => {
function getShape(index: number, size: number) {
return shapes.shapeWithRotate(polygons[index], size);
}

return new Array(polygons.length).fill(0).map((_, index) => {
return interpolate(getShape(index, size), getShape((index + 1) % polygons.length, size), {
maxSegmentLength: 2,
});
});
}, [size, polygons]);

const initialPath = React.useMemo(() => svgPathToString(morphSequence[0](0)), [morphSequence]);

const callback = React.useCallback(
(time: number) => {
const rotationAnimationProgress = calcProgress(0, time, globalRotationDuration);
const globalRotation = rotationAnimationProgress * fullRotation;

// TODO: spring({
// dampingRatio: 0.6,
// stiffness: 200,
// visibilityThreshold: 0.1,
// })
const morphProgress = calcProgress(0, time, morphDuration, morphInterval);

const roundMorphIndex = Math.floor(time / (morphDuration + morphInterval));

const currentMorphIndex = roundMorphIndex % morphSequence.length;

const morphRotationTargetAngle = (roundMorphIndex * quarterRotation) % fullRotation;
const rotation = morphProgress * quarterRotation + morphRotationTargetAngle + globalRotation;

const morphFn = morphSequence[currentMorphIndex];
const morph = morphFn(morphProgress);

ref.current!.setAttribute(
'd',
svgPathToString(operation.rotate(morph, size / 2, size / 2, rotation)),
);
},
[morphSequence, size],
);

const isReducedMotion = useReducedMotion();
useAnimationFrame(callback, disableAnimation || isReducedMotion);

return <path ref={ref} fill="currentColor" d={initialPath}></path>;
}
31 changes: 31 additions & 0 deletions packages/vkui/src/components/Spinner/SvgIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { type HasRootRef } from '../../types';
import { RootComponent } from '../RootComponent/RootComponent';

/**
* Возвращает класс для иконки.
*/
export function iconClassName(size: number) {
return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`;
}

interface SvgIconProps extends React.ComponentProps<'svg'>, HasRootRef<SVGElement> {
/**
* Размер иконки.
*/
size: number;
}

export function SvgIcon({ size, children, ...restProps }: SvgIconProps) {
return (
<RootComponent
Component="svg"
baseClassName={iconClassName(size)}
aria-hidden="true"
width={size}
height={size}
{...restProps}
>
{children}
</RootComponent>
);
}
23 changes: 10 additions & 13 deletions packages/vkui/src/components/Spinner/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,54 @@
import type * as React from 'react';

function iconClassName(size: number) {
return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`;
}
import * as React from 'react';
import { SvgIcon } from './SvgIcon';

export function Icon16Spinner({ children }: React.PropsWithChildren) {
return (
<svg className={iconClassName(16)} aria-hidden="true" width="16" height="16">
<SvgIcon size={16}>
<path
fill="currentColor"
d="M8 3.25a4.75 4.75 0 0 0-4.149 7.065.75.75 0 1 1-1.31.732A6.25 6.25 0 1 1 8 14.25a.75.75 0 0 1 .001-1.5 4.75 4.75 0 1 0 0-9.5Z"
>
{children}
</path>
</svg>
</SvgIcon>
);
}

export function Icon24Spinner({ children }: React.PropsWithChildren) {
return (
<svg className={iconClassName(24)} aria-hidden="true" width="24" height="24">
<SvgIcon size={24}>
<path
fill="currentColor"
d="M16.394 5.077A8.2 8.2 0 0 0 4.58 15.49a.9.9 0 0 1-1.628.767A10 10 0 1 1 12 22a.9.9 0 0 1 0-1.8 8.2 8.2 0 0 0 4.394-15.123"
>
{children}
</path>
</svg>
</SvgIcon>
);
}

export function Icon32Spinner({ children }: React.PropsWithChildren) {
return (
<svg className={iconClassName(32)} aria-hidden="true" width="32" height="32">
<SvgIcon size={32}>
<path
fill="currentColor"
d="M16 32a1.5 1.5 0 0 1 0-3c7.18 0 13-5.82 13-13S23.18 3 16 3 3 8.82 3 16c0 1.557.273 3.074.8 4.502A1.5 1.5 0 1 1 .986 21.54 16 16 0 0 1 0 16C0 7.163 7.163 0 16 0s16 7.163 16 16-7.163 16-16 16"
>
{children}
</path>
</svg>
</SvgIcon>
);
}

export function Icon44Spinner({ children }: React.PropsWithChildren) {
return (
<svg className={iconClassName(44)} aria-hidden="true" width="44" height="44">
<SvgIcon size={44}>
<path
fill="currentColor"
d="M22 44a1.5 1.5 0 0 1 0-3c10.493 0 19-8.507 19-19S32.493 3 22 3 3 11.507 3 22c0 2.208.376 4.363 1.103 6.397a1.5 1.5 0 1 1-2.825 1.01A22 22 0 0 1 0 22C0 9.85 9.85 0 22 0s22 9.85 22 22-9.85 22-22 22"
>
{children}
</path>
</svg>
</SvgIcon>
);
}
45 changes: 45 additions & 0 deletions packages/vkui/src/hooks/useAnimationFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as React from 'react';

/**
* Обертка над `requestAnimationFrame`
*
* ```ts
* const animate = React.useCallback((delta: number) => {
* console.log('Delta:', delta);
* }, []);
*
* useAnimationFrame(animate);
* ```
*
* @param callback Функция, которая будет вызываться каждый раз при обновлении анимации.
* Принимает параметр `delta` - время в миллисекундах, прошедшее с первого кадра анимации.
*/
export function useAnimationFrame(callback: (delta: number) => void, disableAnimation = false) {
const handleRef = React.useRef<number>(undefined);
const startTimestampRef = React.useRef<number>(undefined);
const callbackRef = React.useRef(callback);

React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
Comment on lines +20 to +24
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const stableCallback = useStableCallback(callback);


React.useEffect(() => {
if (disableAnimation) {
return;
}

const animate = (timestamp: number) => {
if (startTimestampRef.current === undefined) {
startTimestampRef.current = timestamp;
}

const delta = timestamp - startTimestampRef.current;
callbackRef.current(delta);

handleRef.current = requestAnimationFrame(animate);
};

handleRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(handleRef.current!);
}, [disableAnimation]);
}
1 change: 1 addition & 0 deletions packages/vkui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ export { TabsItem } from './components/TabsItem/TabsItem';
export type { TabsItemProps } from './components/TabsItem/TabsItem';
export { Spinner } from './components/Spinner/Spinner';
export type { SpinnerProps } from './components/Spinner/Spinner';
export { unstable_ExpressiveSpinner } from './components/Spinner/ExpressiveSpinner/Spinner';
export { PullToRefresh } from './components/PullToRefresh/PullToRefresh';
export type { PullToRefreshProps } from './components/PullToRefresh/PullToRefresh';
export { Link } from './components/Link/Link';
Expand Down
9 changes: 9 additions & 0 deletions packages/vkui/src/lib/array.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as array from './array';

it('array.copy', () => {
const originalArray = [1, 2, 3];
const copiedArray = array.copy(originalArray);

expect(copiedArray).toStrictEqual(originalArray);
expect(copiedArray).not.toBe(originalArray);
});
Loading
Loading