Skip to content

Commit ab3b7dd

Browse files
committed
feat: unstable_ExpressiveSpinner
1 parent de3e8e0 commit ab3b7dd

26 files changed

+1379
-20
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.host {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
inline-size: 100%;
6+
block-size: 100%;
7+
color: var(--vkui--color_icon_medium);
8+
will-change: contents;
9+
}
10+
11+
.noColor {
12+
color: currentColor;
13+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { withCartesian } from '@project-tools/storybook-addon-cartesian';
2+
import type { Meta, StoryObj } from '@storybook/react';
3+
import { CanvasFullLayout } from '../../../storybook/constants';
4+
import { createStoryParameters } from '../../../testing/storybook/createStoryParameters';
5+
import { type MaterialSpinnerProps, Spinner } from './Spinner';
6+
7+
const story: Meta<MaterialSpinnerProps> = {
8+
title: 'Blocks/Spinner/Expressive',
9+
component: Spinner,
10+
parameters: createStoryParameters('Spinner', CanvasFullLayout),
11+
decorators: [withCartesian],
12+
};
13+
14+
export default story;
15+
16+
type Story = StoryObj<MaterialSpinnerProps>;
17+
18+
export const Playground: Story = {};
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
'use client';
2+
3+
import { classNames, hasReactNode } from '@vkontakte/vkjs';
4+
import { usePlatform } from '../../../hooks/usePlatform';
5+
import * as shapes from '../../../lib/material/shapes/shapes';
6+
import { RootComponent } from '../../RootComponent/RootComponent';
7+
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';
8+
import { Spinner as SimpleSpinner, type SpinnerProps } from '../Spinner';
9+
import { IconMaterial } from './icons';
10+
import styles from './Spinner.module.css';
11+
12+
const iconSizeMap = {
13+
s: 16,
14+
m: 24,
15+
l: 32,
16+
xl: 44,
17+
} as const;
18+
19+
const defaultShapesList = [
20+
shapes.softBurstParams,
21+
shapes.cookie9Params,
22+
shapes.pentagonParams,
23+
shapes.pillParams,
24+
shapes.sunnyParams,
25+
shapes.cookie4Params,
26+
shapes.ovalParams,
27+
] as const;
28+
29+
export interface MaterialSpinnerProps extends SpinnerProps {
30+
/**
31+
* Последовательность форм между которыми будет происходить анимация.
32+
*/
33+
polygons?: readonly [shapes.ShapeParameters, shapes.ShapeParameters, ...shapes.ShapeParameters[]];
34+
}
35+
36+
function MaterialSpinner({
37+
polygons = defaultShapesList,
38+
size = 'm',
39+
children = 'Загружается...',
40+
disableAnimation = false,
41+
noColor = false,
42+
...restProps
43+
}: MaterialSpinnerProps) {
44+
const iconSize = iconSizeMap[size];
45+
46+
return (
47+
<RootComponent
48+
Component="span"
49+
role="status"
50+
{...restProps}
51+
baseClassName={classNames(styles.host, noColor && styles.noColor)}
52+
>
53+
<IconMaterial size={iconSize} polygons={polygons} disableAnimation={disableAnimation} />
54+
{hasReactNode(children) && <VisuallyHidden>{children}</VisuallyHidden>}
55+
</RootComponent>
56+
);
57+
}
58+
59+
export function Spinner(props: SpinnerProps) {
60+
const platform = usePlatform();
61+
62+
const Component = platform === 'ios' ? SimpleSpinner : MaterialSpinner;
63+
64+
return <Component {...props} />;
65+
}
66+
67+
// eslint-disable-next-line @typescript-eslint/naming-convention
68+
export const unstable_ExpressiveSpinner = Spinner;
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useAnimationFrame } from '../../../hooks/useAnimationFrame';
5+
import { useReducedMotion } from '../../../lib/animation';
6+
import * as shapes from '../../../lib/material/shapes/shapes';
7+
import { interpolate } from '../../../lib/svg/path/interpolate';
8+
import { svgPathToString } from '../../../lib/svg/path/path';
9+
import * as operation from '../../../lib/svg/path/transform';
10+
import { SvgIcon } from '../SvgIcon';
11+
12+
interface IconMaterialProps {
13+
/**
14+
* Список форм.
15+
*/
16+
polygons: readonly shapes.ShapeParameters[];
17+
/**
18+
* Размер иконки.
19+
*/
20+
size: number;
21+
/**
22+
* Отключение анимации.
23+
*/
24+
disableAnimation: boolean;
25+
}
26+
27+
export function IconMaterial(props: IconMaterialProps) {
28+
return (
29+
<SvgIcon size={props.size}>
30+
<IconMaterialPath {...props} />
31+
</SvgIcon>
32+
);
33+
}
34+
35+
const globalRotationDuration = 4666;
36+
const morphDuration = 200;
37+
const morphInterval = 650;
38+
const fullRotation = 360;
39+
const quarterRotation = fullRotation / 4;
40+
41+
function calcProgress(startTime: number, time: number, duration: number, delay = 0) {
42+
const fullDuration = duration + delay;
43+
44+
const timeProgress = fullDuration * (((time - startTime) % fullDuration) / fullDuration);
45+
46+
if (timeProgress < delay) {
47+
return 0;
48+
}
49+
50+
return (timeProgress - delay) / duration;
51+
}
52+
53+
function IconMaterialPath({ size, polygons, disableAnimation }: IconMaterialProps) {
54+
const ref = React.useRef<SVGPathElement>(null);
55+
56+
const morphSequence = React.useMemo(() => {
57+
function getShape(index: number, size: number) {
58+
return shapes.shapeWithRotate(polygons[index], size);
59+
}
60+
61+
return new Array(polygons.length).fill(0).map((_, index) => {
62+
return interpolate(getShape(index, size), getShape((index + 1) % polygons.length, size), {
63+
maxSegmentLength: 2,
64+
});
65+
});
66+
}, [size, polygons]);
67+
68+
const initialPath = React.useMemo(() => svgPathToString(morphSequence[0](0)), [morphSequence]);
69+
70+
const callback = React.useCallback(
71+
(time: number) => {
72+
const rotationAnimationProgress = calcProgress(0, time, globalRotationDuration);
73+
const globalRotation = rotationAnimationProgress * fullRotation;
74+
75+
// TODO: spring({
76+
// dampingRatio: 0.6,
77+
// stiffness: 200,
78+
// visibilityThreshold: 0.1,
79+
// })
80+
const morphProgress = calcProgress(0, time, morphDuration, morphInterval);
81+
82+
const roundMorphIndex = Math.floor(time / (morphDuration + morphInterval));
83+
84+
const currentMorphIndex = roundMorphIndex % morphSequence.length;
85+
86+
const morphRotationTargetAngle = (roundMorphIndex * quarterRotation) % fullRotation;
87+
const rotation = morphProgress * quarterRotation + morphRotationTargetAngle + globalRotation;
88+
89+
const morphFn = morphSequence[currentMorphIndex];
90+
const morph = morphFn(morphProgress);
91+
92+
ref.current!.setAttribute(
93+
'd',
94+
svgPathToString(operation.rotate(morph, size / 2, size / 2, rotation)),
95+
);
96+
},
97+
[morphSequence, size],
98+
);
99+
100+
const isReducedMotion = useReducedMotion();
101+
useAnimationFrame(callback, disableAnimation && isReducedMotion);
102+
103+
return <path ref={ref} fill="currentColor" d={initialPath}></path>;
104+
}

packages/vkui/src/components/Spinner/Readme.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,24 @@
1414
<Spinner size="s">Кастомный текст вместо "Загружается...", который озвучит скринридер</Spinner>
1515
</Flex>
1616
```
17+
18+
<br>
19+
20+
## unstable_ExpressiveSpinner
21+
22+
Нестабильный компонент индикации загрузки в стиле
23+
[M3 Expressive](https://m3.material.io/components/loading-indicator/overview).
24+
Принимает все свойства, которые принимает компонент `Spinner`.
25+
Для платформы `ios` используется обычный `Spinner`.
26+
27+
```jsx { "props": { "layout": false, "iframe": false } }
28+
<Flex
29+
aria-busy={true}
30+
aria-live="polite"
31+
direction="column"
32+
justify="center"
33+
style={{ minHeight: 300 }}
34+
>
35+
<ExpressiveSpinner size="xl" />
36+
</Flex>
37+
```
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type HasRootRef } from '../../types';
2+
import { RootComponent } from '../RootComponent/RootComponent';
3+
4+
/**
5+
* Возвращает класс для иконки.
6+
*/
7+
export function iconClassName(size: number) {
8+
return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`;
9+
}
10+
11+
interface SvgIconProps extends React.ComponentProps<'svg'>, HasRootRef<SVGElement> {
12+
/**
13+
* Размер иконки.
14+
*/
15+
size: number;
16+
}
17+
18+
export function SvgIcon({ size, children, ...restProps }: SvgIconProps) {
19+
return (
20+
<RootComponent
21+
Component="svg"
22+
baseClassName={iconClassName(size)}
23+
aria-hidden="true"
24+
width={size}
25+
height={size}
26+
{...restProps}
27+
>
28+
{children}
29+
</RootComponent>
30+
);
31+
}
Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,54 @@
11
import * as React from 'react';
2-
3-
function iconClassName(size: number) {
4-
return `vkuiIcon vkuiIcon--${size} vkuiIcon--w-${size} vkuiIcon--h-${size}`;
5-
}
2+
import { SvgIcon } from './SvgIcon';
63

74
export function Icon16Spinner({ children }: React.PropsWithChildren) {
85
return (
9-
<svg className={iconClassName(16)} aria-hidden="true" width="16" height="16">
6+
<SvgIcon size={16}>
107
<path
118
fill="currentColor"
129
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"
1310
>
1411
{children}
1512
</path>
16-
</svg>
13+
</SvgIcon>
1714
);
1815
}
1916

2017
export function Icon24Spinner({ children }: React.PropsWithChildren) {
2118
return (
22-
<svg className={iconClassName(24)} aria-hidden="true" width="24" height="24">
19+
<SvgIcon size={24}>
2320
<path
2421
fill="currentColor"
2522
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"
2623
>
2724
{children}
2825
</path>
29-
</svg>
26+
</SvgIcon>
3027
);
3128
}
3229

3330
export function Icon32Spinner({ children }: React.PropsWithChildren) {
3431
return (
35-
<svg className={iconClassName(32)} aria-hidden="true" width="32" height="32">
32+
<SvgIcon size={32}>
3633
<path
3734
fill="currentColor"
3835
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"
3936
>
4037
{children}
4138
</path>
42-
</svg>
39+
</SvgIcon>
4340
);
4441
}
4542

4643
export function Icon44Spinner({ children }: React.PropsWithChildren) {
4744
return (
48-
<svg className={iconClassName(44)} aria-hidden="true" width="44" height="44">
45+
<SvgIcon size={44}>
4946
<path
5047
fill="currentColor"
5148
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"
5249
>
5350
{children}
5451
</path>
55-
</svg>
52+
</SvgIcon>
5653
);
5754
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as React from 'react';
2+
3+
/**
4+
* Обертка над `requestAnimationFrame`. В функцию `` пере
5+
*
6+
* ```ts
7+
* const animate = React.useCallback((delta: number) => {
8+
* console.log('Delta:', delta);
9+
* }, []);
10+
*
11+
* useAnimationFrame(animate);
12+
* ```
13+
*
14+
* @param callback Функция, которая будет вызываться каждый раз при обновлении анимации.
15+
* Принимает параметр `delta` - время в миллисекундах, прошедшее с первого кадра анимации.
16+
*/
17+
export function useAnimationFrame(callback: (delta: number) => void, disableAnimation = false) {
18+
const handleRef = React.useRef<number>(undefined);
19+
const startTimestampRef = React.useRef<number>(undefined);
20+
21+
const frameRequestCallback = React.useCallback(
22+
(timestamp: number) => {
23+
if (disableAnimation) {
24+
return;
25+
}
26+
27+
if (startTimestampRef.current === undefined) {
28+
startTimestampRef.current = timestamp;
29+
}
30+
31+
const delta = timestamp - startTimestampRef.current;
32+
callback(delta);
33+
34+
handleRef.current = requestAnimationFrame(frameRequestCallback);
35+
},
36+
[callback, disableAnimation],
37+
);
38+
39+
React.useEffect(() => {
40+
handleRef.current = requestAnimationFrame(frameRequestCallback);
41+
return () => cancelAnimationFrame(handleRef.current!);
42+
}, [frameRequestCallback]);
43+
}

packages/vkui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ export { TabsItem } from './components/TabsItem/TabsItem';
233233
export type { TabsItemProps } from './components/TabsItem/TabsItem';
234234
export { Spinner } from './components/Spinner/Spinner';
235235
export type { SpinnerProps } from './components/Spinner/Spinner';
236+
export { unstable_ExpressiveSpinner } from './components/Spinner/ExpressiveSpinner/Spinner';
236237
export { PullToRefresh } from './components/PullToRefresh/PullToRefresh';
237238
export type { PullToRefreshProps } from './components/PullToRefresh/PullToRefresh';
238239
export { Link } from './components/Link/Link';

0 commit comments

Comments
 (0)