Skip to content
Draft
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',
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,68 @@
'use client';

import { classNames, hasReactNode } from '@vkontakte/vkjs';
import { usePlatform } from '../../../hooks/usePlatform';
import * as shapes from '../../../lib/material/shapes/shapes';
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';

const iconSizeMap = {

Check warning on line 12 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L12

Added line #L12 was not covered by tests
s: 16,
m: 24,
l: 32,
xl: 44,
} as const;

const defaultShapesList = [

Check warning on line 19 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L19

Added line #L19 was not covered by tests
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,

Check warning on line 41 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L36-L41

Added lines #L36 - L41 were not covered by tests
...restProps
}: MaterialSpinnerProps) {
const iconSize = iconSizeMap[size];

Check warning on line 44 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L44

Added line #L44 was not covered by tests

return (
<RootComponent
Component="span"
role="status"
{...restProps}
baseClassName={classNames(styles.host, noColor && styles.noColor)}

Check warning on line 51 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L51

Added line #L51 was not covered by tests
>
<IconMaterial size={iconSize} polygons={polygons} disableAnimation={disableAnimation} />
{hasReactNode(children) && <VisuallyHidden>{children}</VisuallyHidden>}

Check warning on line 54 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L54

Added line #L54 was not covered by tests
</RootComponent>
);
}

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

Check warning on line 60 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L59-L60

Added lines #L59 - L60 were not covered by tests

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

Check warning on line 62 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L62

Added line #L62 was not covered by tests

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

Check warning on line 64 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L64

Added line #L64 was not covered by tests
}

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

Check warning on line 68 in packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/Spinner.tsx#L68

Added line #L68 was not covered by tests
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) {

Check warning on line 27 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L27

Added line #L27 was not covered by tests
return (
<SvgIcon size={props.size}>
<IconMaterialPath {...props} />
</SvgIcon>
);
}

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

Check warning on line 39 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L35-L39

Added lines #L35 - L39 were not covered by tests

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

Check warning on line 42 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L41-L42

Added lines #L41 - L42 were not covered by tests

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

Check warning on line 44 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L44

Added line #L44 was not covered by tests

if (timeProgress < delay) {
return 0;

Check warning on line 47 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L46-L47

Added lines #L46 - L47 were not covered by tests
}

return (timeProgress - delay) / duration;

Check warning on line 50 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L50

Added line #L50 was not covered by tests
}

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

Check warning on line 54 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L53-L54

Added lines #L53 - L54 were not covered by tests

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

Check warning on line 58 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L56-L58

Added lines #L56 - L58 were not covered by tests
}

return new Array(polygons.length).fill(0).map((_, index) => {
return interpolate(getShape(index, size), getShape((index + 1) % polygons.length, size), {

Check warning on line 62 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L61-L62

Added lines #L61 - L62 were not covered by tests
maxSegmentLength: 2,
});
});
}, [size, polygons]);

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

Check warning on line 68 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L68

Added line #L68 was not covered by tests

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

Check warning on line 73 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L70-L73

Added lines #L70 - L73 were not covered by tests

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

Check warning on line 80 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L80

Added line #L80 was not covered by tests

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

Check warning on line 82 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L82

Added line #L82 was not covered by tests

const currentMorphIndex = roundMorphIndex % morphSequence.length;

Check warning on line 84 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L84

Added line #L84 was not covered by tests

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

Check warning on line 87 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L86-L87

Added lines #L86 - L87 were not covered by tests

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

Check warning on line 90 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L89-L90

Added lines #L89 - L90 were not covered by tests

ref.current!.setAttribute(

Check warning on line 92 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L92

Added line #L92 was not covered by tests
'd',
svgPathToString(operation.rotate(morph, size / 2, size / 2, rotation)),
);
},
[morphSequence, size],
);

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

Check warning on line 101 in packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/components/Spinner/ExpressiveSpinner/icons.tsx#L100-L101

Added lines #L100 - L101 were not covered by tests

return <path ref={ref} fill="currentColor" d={initialPath}></path>;
}
21 changes: 21 additions & 0 deletions packages/vkui/src/components/Spinner/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,24 @@
<Spinner size="s">Кастомный текст вместо "Загружается...", который озвучит скринридер</Spinner>
</Flex>
```

<br>

## unstable_ExpressiveSpinner

Нестабильный компонент индикации загрузки в стиле
[M3 Expressive](https://m3.material.io/components/loading-indicator/overview).
Принимает все свойства, которые принимает компонент `Spinner`.
Для платформы `ios` используется обычный `Spinner`.

```jsx { "props": { "layout": false, "iframe": false } }
<Flex
aria-busy={true}
aria-live="polite"
direction="column"
justify="center"
style={{ minHeight: 300 }}
>
<ExpressiveSpinner size="xl" />
</Flex>
```
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>
);
}
21 changes: 9 additions & 12 deletions packages/vkui/src/components/Spinner/icons.tsx
Original file line number Diff line number Diff line change
@@ -1,57 +1,54 @@
import * as React from 'react';

function iconClassName(size: number) {
return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`;
}
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>
);
}
43 changes: 43 additions & 0 deletions packages/vkui/src/hooks/useAnimationFrame.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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);

Check warning on line 19 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L17-L19

Added lines #L17 - L19 were not covered by tests

const frameRequestCallback = React.useCallback(
(timestamp: number) => {
if (disableAnimation) {
return;

Check warning on line 24 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L21-L24

Added lines #L21 - L24 were not covered by tests
}

if (startTimestampRef.current === undefined) {
startTimestampRef.current = timestamp;

Check warning on line 28 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L27-L28

Added lines #L27 - L28 were not covered by tests
}

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

Check warning on line 32 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L31-L32

Added lines #L31 - L32 were not covered by tests

handleRef.current = requestAnimationFrame(frameRequestCallback);

Check warning on line 34 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L34

Added line #L34 was not covered by tests
},
[callback, disableAnimation],
);

React.useEffect(() => {
handleRef.current = requestAnimationFrame(frameRequestCallback);
return () => cancelAnimationFrame(handleRef.current!);

Check warning on line 41 in packages/vkui/src/hooks/useAnimationFrame.tsx

View check run for this annotation

Codecov / codecov/patch

packages/vkui/src/hooks/useAnimationFrame.tsx#L39-L41

Added lines #L39 - L41 were not covered by tests
}, [frameRequestCallback]);
}
1 change: 1 addition & 0 deletions packages/vkui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,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